1package web
2
3import (
4 "fmt"
5 "log/slog"
6 "net/http"
7 "slices"
8 "strconv"
9 "strings"
10 "time"
11
12 "tug/internal/transport/web/text"
13)
14
15var adminInvitationStates = map[AdminInvitationState]text.Key{
16 AdminInvitationPending: text.AdminInvitationStatePending,
17 AdminInvitationUsed: text.AdminInvitationStateUsed,
18 AdminInvitationRevoked: text.AdminInvitationStateRevoked,
19 AdminInvitationExpired: text.AdminInvitationStateExpired,
20}
21
22type adminInvitationGroupView struct {
23 Name Ident
24 Deleted bool
25}
26
27type adminInvitationView struct {
28 ID Ident
29 Label string
30 CreatedBy Ident
31 Created string
32 Expires string
33 State text.Key
34 Revocable bool
35 UsedBy Ident
36 UsedByID string
37 UsedByPending bool
38 UsedByDeleted bool
39 Batch string
40 BatchRevocable bool
41 Groups []adminInvitationGroupView
42 GroupsMore int
43}
44
45type adminInvitationsData struct {
46 Viewer Ident
47 Rows []adminInvitationView
48 Pager adminPager
49}
50
51type adminGroupChoiceView struct {
52 ID string
53 Name Ident
54 Checked bool
55}
56
57type adminExpiryView struct {
58 Days int
59 Text text.Key
60 Checked bool
61}
62
63type adminNewInvitationData struct {
64 Viewer Ident
65 Labels string
66 Choices []adminGroupChoiceView
67 More bool
68 Defaults []Ident
69 GroupNames string
70 Expiries []adminExpiryView
71 Cap Ident
72 Message text.Key
73 chosen []string
74 days int
75}
76
77type adminViewerData struct {
78 Viewer Ident
79}
80
81func (a *admin) invitations(w http.ResponseWriter, r *http.Request) {
82 p, ok := a.kit.principal(w, r)
83 if !ok {
84 return
85 }
86 page := pageNumber(r)
87 list, err := a.b.ListInvitations(r.Context(), page)
88 if err != nil {
89 a.failed(w, r, "list invitations", err)
90 return
91 }
92 rows := list.Invitations[:min(len(list.Invitations), AdminInvitationsPerPage)]
93 d := adminInvitationsData{Viewer: p.Username, Pager: newPager("/invitations", "", page, AdminInvitationsPerPage, len(rows), list.Total)}
94 for _, inv := range rows {
95 state, known := adminInvitationStates[inv.State]
96 if !known {
97 a.failed(w, r, "list invitations", fmt.Errorf("web: unknown invitation state %d", inv.State))
98 return
99 }
100 v := adminInvitationView{
101 ID: Ident(inv.ID),
102 Label: inv.Label,
103 CreatedBy: Ident(inv.CreatedBy),
104 Created: stamp(inv.CreatedAt),
105 Expires: stamp(inv.ExpiresAt),
106 State: state,
107 Revocable: inv.State == AdminInvitationPending,
108 UsedBy: Ident(inv.UsedBy),
109 UsedByID: inv.UsedByID,
110 UsedByPending: inv.UsedByPending && inv.UsedByID != "",
111 UsedByDeleted: inv.State == AdminInvitationUsed && inv.UsedBy == "",
112 Batch: inv.Batch,
113 BatchRevocable: inv.Batch != "" && inv.State == AdminInvitationPending,
114 }
115 shown := inv.Groups[:min(len(inv.Groups), adminInvitationBadges)]
116 for _, g := range shown {
117 v.Groups = append(v.Groups, adminInvitationGroupView{Name: Ident(g.Name), Deleted: g.Deleted})
118 }
119 v.GroupsMore = len(inv.Groups) - len(shown)
120 d.Rows = append(d.Rows, v)
121 }
122 a.render(w, r, http.StatusOK, "admin-invitations", d)
123}
124
125var adminInvitationExpiryDays = []struct {
126 days int
127 text text.Key
128}{{1, text.AdminInvitationNewExpiry1}, {7, text.AdminInvitationNewExpiry7}, {30, text.AdminInvitationNewExpiry30}}
129
130const (
131 adminInvitationDefaultDays = 7
132 adminInvitationBadges = 3
133 echoGroupNamesBytes = 1024
134)
135
136func (a *admin) invitationForm(w http.ResponseWriter, r *http.Request, status int, d adminNewInvitationData) {
137 choices, err := a.b.InvitationGroupChoices(r.Context())
138 if err != nil {
139 a.failed(w, r, "list invitation groups", err)
140 return
141 }
142 for _, g := range choices.Groups[:min(len(choices.Groups), adminPickerOptions)] {
143 d.Choices = append(d.Choices, adminGroupChoiceView{ID: g.ID, Name: Ident(g.Name), Checked: slices.Contains(d.chosen, g.ID)})
144 }
145 d.More = choices.More || len(choices.Groups) > adminPickerOptions
146 for _, name := range choices.Defaults {
147 d.Defaults = append(d.Defaults, Ident(name))
148 }
149 if d.days == 0 {
150 d.days = adminInvitationDefaultDays
151 }
152 for _, e := range adminInvitationExpiryDays {
153 d.Expiries = append(d.Expiries, adminExpiryView{Days: e.days, Text: e.text, Checked: e.days == d.days})
154 }
155 d.Cap = Ident(strconv.Itoa(a.kit.batchCap))
156 p := a.formPage(w, r, d)
157 p.Once = a.kit.formNonce(r)
158 a.kit.render(w, r, status, "admin-invitation-new", p)
159}
160
161func (a *admin) newInvitation(w http.ResponseWriter, r *http.Request) {
162 if p, ok := a.kit.principal(w, r); ok {
163 a.invitationForm(w, r, http.StatusOK, adminNewInvitationData{Viewer: p.Username})
164 }
165}
166
167func invitationLabels(field string) []string {
168 var labels []string
169 for _, line := range strings.Split(field, "\n") {
170 if line = strings.TrimSuffix(line, "\r"); strings.TrimSpace(line) != "" {
171 labels = append(labels, line)
172 }
173 }
174 return labels
175}
176
177func invitationExpiry(field string) (int, time.Duration, bool) {
178 if field == "" {
179 return adminInvitationDefaultDays, time.Duration(adminInvitationDefaultDays) * 24 * time.Hour, true
180 }
181 for _, e := range adminInvitationExpiryDays {
182 if field == strconv.Itoa(e.days) {
183 return e.days, time.Duration(e.days) * 24 * time.Hour, true
184 }
185 }
186 return 0, 0, false
187}
188
189func (a *admin) createInvitation(w http.ResponseWriter, r *http.Request) {
190 p, ok := a.kit.principal(w, r)
191 if !ok {
192 return
193 }
194 field := r.PostFormValue("labels")
195 names := r.PostFormValue("group_names")
196 d := adminNewInvitationData{Viewer: p.Username, Labels: echoed(field, a.kit.batchCap*(batchLabelBytes+2)), GroupNames: echoed(names, echoGroupNamesBytes), chosen: r.PostForm["group"]}
197 days, expiry, known := invitationExpiry(r.PostFormValue("expiry"))
198 d.days = days
199 switch a.kit.SpendOnce(r) {
200 case OnceSpent:
201 a.render(w, r, http.StatusOK, "admin-invitation-spent", adminViewerData{Viewer: p.Username})
202 return
203 case OnceRefused:
204 d.Message = text.AdminInvitationNewExpired
205 a.invitationForm(w, r, http.StatusOK, d)
206 return
207 }
208 labels := invitationLabels(field)
209 switch {
210 case len(labels) == 0 || len(labels) > a.kit.batchCap:
211 d.Message = adminProblemKeys[AdminBatchSize]
212 a.invitationForm(w, r, http.StatusOK, d)
213 return
214 case !known:
215 d.Message = adminProblemKeys[AdminExpiryInvalid]
216 a.invitationForm(w, r, http.StatusOK, d)
217 return
218 }
219 created, err := a.b.CreateInvitations(r.Context(), p.UserID, AdminNewInvitations{Labels: labels, GroupIDs: r.PostForm["group"], GroupNames: strings.Fields(names), Expiry: expiry})
220 if key, ok := adminProblemKey(err); ok {
221 d.Message = key
222 a.invitationForm(w, r, http.StatusOK, d)
223 return
224 }
225 if err == nil && len(created.Invitations) == 0 {
226 err = fmt.Errorf("web: the backend created no invitation")
227 }
228 if err != nil {
229 slog.Error("admin page failed", "step", "create invitation", "err", err)
230 d.Message = text.AdminInvitationNewFailed
231 a.invitationForm(w, r, http.StatusInternalServerError, d)
232 return
233 }
234 expires := stamp(created.ExpiresAt)
235 var groups []Ident
236 for _, name := range created.Groups {
237 groups = append(groups, Ident(name))
238 }
239 site := invitationSite(a.kit.server.SiteName, a.kit.server.DomainAccount)
240 if len(created.Invitations) == 1 {
241 i := created.Invitations[0]
242 a.kit.RenderOneTime(w, r, OneTime{Title: text.AdminInvitationOneTimeTitle, Back: "/invitations", BackText: text.AdminInvitationsBack, Secret: Ident(i.URL), Label: i.Label, Expires: Ident(expires), Groups: groups, Message: invitationMessage(site, expires, i.URL)})
243 return
244 }
245 b := OneTimeBatch{Expires: Ident(expires), Groups: groups}
246 for _, i := range created.Invitations {
247 b.Rows = append(b.Rows, OneTimeBatchRow{Label: i.Label, URL: Ident(i.URL), Message: invitationMessage(site, expires, i.URL)})
248 }
249 a.kit.RenderOneTimeBatch(w, r, b)
250}
251
252func (a *admin) invitationRevoke() adminConfirm {
253 return adminConfirm{
254 a: a, title: text.AdminInvitationRevokeTitle, body: text.AdminInvitationRevokeBody, button: text.AdminInvitationsRevoke, flash: text.FlashAdminInvitationRevoked,
255 load: func(r *http.Request, id string) (Ident, error) {
256 _, err := a.b.Invitation(r.Context(), id)
257 return Ident(id), err
258 },
259 act: func(r *http.Request, actor, id string) error { return a.b.RevokeInvitation(r.Context(), actor, id) },
260 action: func(id string) string { return "/invitations/" + id + "/revoke" },
261 cancel: func(string) string { return "/invitations" },
262 after: func(string) string { return "/invitations" },
263 }
264}
265
266func (a *admin) invitationBatchRevoke() adminConfirm {
267 return adminConfirm{
268 a: a, title: text.AdminInvitationBatchRevokeTitle, body: text.AdminInvitationBatchRevokeBody, button: text.AdminInvitationBatchRevokeButton, flash: text.FlashAdminInvitationBatchRevoked,
269 load: func(r *http.Request, id string) (Ident, error) { return Ident(id), nil },
270 act: func(r *http.Request, actor, id string) error {
271 return a.b.RevokeInvitationBatch(r.Context(), actor, id)
272 },
273 action: func(id string) string { return "/invitations/batch/" + id + "/revoke" },
274 cancel: func(string) string { return "/invitations" },
275 after: func(string) string { return "/invitations" },
276 }
277}