Raw
  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}