package web import ( "fmt" "log/slog" "net/http" "slices" "strconv" "strings" "time" "tug/internal/transport/web/text" ) var adminInvitationStates = map[AdminInvitationState]text.Key{ AdminInvitationPending: text.AdminInvitationStatePending, AdminInvitationUsed: text.AdminInvitationStateUsed, AdminInvitationRevoked: text.AdminInvitationStateRevoked, AdminInvitationExpired: text.AdminInvitationStateExpired, } type adminInvitationGroupView struct { Name Ident Deleted bool } type adminInvitationView struct { ID Ident Label string CreatedBy Ident Created string Expires string State text.Key Revocable bool UsedBy Ident UsedByID string UsedByPending bool UsedByDeleted bool Batch string BatchRevocable bool Groups []adminInvitationGroupView GroupsMore int } type adminInvitationsData struct { Viewer Ident Rows []adminInvitationView Pager adminPager } type adminGroupChoiceView struct { ID string Name Ident Checked bool } type adminExpiryView struct { Days int Text text.Key Checked bool } type adminNewInvitationData struct { Viewer Ident Labels string Choices []adminGroupChoiceView More bool Defaults []Ident GroupNames string Expiries []adminExpiryView Cap Ident Message text.Key chosen []string days int } type adminViewerData struct { Viewer Ident } func (a *admin) invitations(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } page := pageNumber(r) list, err := a.b.ListInvitations(r.Context(), page) if err != nil { a.failed(w, r, "list invitations", err) return } rows := list.Invitations[:min(len(list.Invitations), AdminInvitationsPerPage)] d := adminInvitationsData{Viewer: p.Username, Pager: newPager("/invitations", "", page, AdminInvitationsPerPage, len(rows), list.Total)} for _, inv := range rows { state, known := adminInvitationStates[inv.State] if !known { a.failed(w, r, "list invitations", fmt.Errorf("web: unknown invitation state %d", inv.State)) return } v := adminInvitationView{ ID: Ident(inv.ID), Label: inv.Label, CreatedBy: Ident(inv.CreatedBy), Created: stamp(inv.CreatedAt), Expires: stamp(inv.ExpiresAt), State: state, Revocable: inv.State == AdminInvitationPending, UsedBy: Ident(inv.UsedBy), UsedByID: inv.UsedByID, UsedByPending: inv.UsedByPending && inv.UsedByID != "", UsedByDeleted: inv.State == AdminInvitationUsed && inv.UsedBy == "", Batch: inv.Batch, BatchRevocable: inv.Batch != "" && inv.State == AdminInvitationPending, } shown := inv.Groups[:min(len(inv.Groups), adminInvitationBadges)] for _, g := range shown { v.Groups = append(v.Groups, adminInvitationGroupView{Name: Ident(g.Name), Deleted: g.Deleted}) } v.GroupsMore = len(inv.Groups) - len(shown) d.Rows = append(d.Rows, v) } a.render(w, r, http.StatusOK, "admin-invitations", d) } var adminInvitationExpiryDays = []struct { days int text text.Key }{{1, text.AdminInvitationNewExpiry1}, {7, text.AdminInvitationNewExpiry7}, {30, text.AdminInvitationNewExpiry30}} const ( adminInvitationDefaultDays = 7 adminInvitationBadges = 3 echoGroupNamesBytes = 1024 ) func (a *admin) invitationForm(w http.ResponseWriter, r *http.Request, status int, d adminNewInvitationData) { choices, err := a.b.InvitationGroupChoices(r.Context()) if err != nil { a.failed(w, r, "list invitation groups", err) return } for _, g := range choices.Groups[:min(len(choices.Groups), adminPickerOptions)] { d.Choices = append(d.Choices, adminGroupChoiceView{ID: g.ID, Name: Ident(g.Name), Checked: slices.Contains(d.chosen, g.ID)}) } d.More = choices.More || len(choices.Groups) > adminPickerOptions for _, name := range choices.Defaults { d.Defaults = append(d.Defaults, Ident(name)) } if d.days == 0 { d.days = adminInvitationDefaultDays } for _, e := range adminInvitationExpiryDays { d.Expiries = append(d.Expiries, adminExpiryView{Days: e.days, Text: e.text, Checked: e.days == d.days}) } d.Cap = Ident(strconv.Itoa(a.kit.batchCap)) p := a.formPage(w, r, d) p.Once = a.kit.formNonce(r) a.kit.render(w, r, status, "admin-invitation-new", p) } func (a *admin) newInvitation(w http.ResponseWriter, r *http.Request) { if p, ok := a.kit.principal(w, r); ok { a.invitationForm(w, r, http.StatusOK, adminNewInvitationData{Viewer: p.Username}) } } func invitationLabels(field string) []string { var labels []string for _, line := range strings.Split(field, "\n") { if line = strings.TrimSuffix(line, "\r"); strings.TrimSpace(line) != "" { labels = append(labels, line) } } return labels } func invitationExpiry(field string) (int, time.Duration, bool) { if field == "" { return adminInvitationDefaultDays, time.Duration(adminInvitationDefaultDays) * 24 * time.Hour, true } for _, e := range adminInvitationExpiryDays { if field == strconv.Itoa(e.days) { return e.days, time.Duration(e.days) * 24 * time.Hour, true } } return 0, 0, false } func (a *admin) createInvitation(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } field := r.PostFormValue("labels") names := r.PostFormValue("group_names") d := adminNewInvitationData{Viewer: p.Username, Labels: echoed(field, a.kit.batchCap*(batchLabelBytes+2)), GroupNames: echoed(names, echoGroupNamesBytes), chosen: r.PostForm["group"]} days, expiry, known := invitationExpiry(r.PostFormValue("expiry")) d.days = days switch a.kit.SpendOnce(r) { case OnceSpent: a.render(w, r, http.StatusOK, "admin-invitation-spent", adminViewerData{Viewer: p.Username}) return case OnceRefused: d.Message = text.AdminInvitationNewExpired a.invitationForm(w, r, http.StatusOK, d) return } labels := invitationLabels(field) switch { case len(labels) == 0 || len(labels) > a.kit.batchCap: d.Message = adminProblemKeys[AdminBatchSize] a.invitationForm(w, r, http.StatusOK, d) return case !known: d.Message = adminProblemKeys[AdminExpiryInvalid] a.invitationForm(w, r, http.StatusOK, d) return } created, err := a.b.CreateInvitations(r.Context(), p.UserID, AdminNewInvitations{Labels: labels, GroupIDs: r.PostForm["group"], GroupNames: strings.Fields(names), Expiry: expiry}) if key, ok := adminProblemKey(err); ok { d.Message = key a.invitationForm(w, r, http.StatusOK, d) return } if err == nil && len(created.Invitations) == 0 { err = fmt.Errorf("web: the backend created no invitation") } if err != nil { slog.Error("admin page failed", "step", "create invitation", "err", err) d.Message = text.AdminInvitationNewFailed a.invitationForm(w, r, http.StatusInternalServerError, d) return } expires := stamp(created.ExpiresAt) var groups []Ident for _, name := range created.Groups { groups = append(groups, Ident(name)) } site := invitationSite(a.kit.server.SiteName, a.kit.server.DomainAccount) if len(created.Invitations) == 1 { i := created.Invitations[0] 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)}) return } b := OneTimeBatch{Expires: Ident(expires), Groups: groups} for _, i := range created.Invitations { b.Rows = append(b.Rows, OneTimeBatchRow{Label: i.Label, URL: Ident(i.URL), Message: invitationMessage(site, expires, i.URL)}) } a.kit.RenderOneTimeBatch(w, r, b) } func (a *admin) invitationRevoke() adminConfirm { return adminConfirm{ a: a, title: text.AdminInvitationRevokeTitle, body: text.AdminInvitationRevokeBody, button: text.AdminInvitationsRevoke, flash: text.FlashAdminInvitationRevoked, load: func(r *http.Request, id string) (Ident, error) { _, err := a.b.Invitation(r.Context(), id) return Ident(id), err }, act: func(r *http.Request, actor, id string) error { return a.b.RevokeInvitation(r.Context(), actor, id) }, action: func(id string) string { return "/invitations/" + id + "/revoke" }, cancel: func(string) string { return "/invitations" }, after: func(string) string { return "/invitations" }, } } func (a *admin) invitationBatchRevoke() adminConfirm { return adminConfirm{ a: a, title: text.AdminInvitationBatchRevokeTitle, body: text.AdminInvitationBatchRevokeBody, button: text.AdminInvitationBatchRevokeButton, flash: text.FlashAdminInvitationBatchRevoked, load: func(r *http.Request, id string) (Ident, error) { return Ident(id), nil }, act: func(r *http.Request, actor, id string) error { return a.b.RevokeInvitationBatch(r.Context(), actor, id) }, action: func(id string) string { return "/invitations/batch/" + id + "/revoke" }, cancel: func(string) string { return "/invitations" }, after: func(string) string { return "/invitations" }, } }