package web import ( "context" "errors" "net/http" "tug/internal/transport/web/text" ) type adminUserRowView struct { ID Ident Username Ident DisplayName string Disabled bool Pending bool Groups []Ident More int } type adminUsersData struct { Viewer Ident Rows []adminUserRowView Pager adminPager } type adminGroupView struct { ID Ident Name Ident Primary bool } type adminUserData struct { Viewer Ident ID Ident Username Ident DisplayName string UIDNumber int Disabled bool Pending bool Admin bool LastSignIn string Groups []adminGroupView More int Google bool GoogleSince string Sessions int RefreshTokens int Activity []adminAuditView SSHKeys sshKeyList Message text.Key } type adminNewUserData struct { Viewer Ident Username string DisplayName string Message text.Key } type adminMembershipsData struct { Viewer Ident ID Ident Username Ident Groups []adminGroupView Choices []Ident Pager adminPager GroupInput string Message text.Key } func groupViews(refs []AdminGroupRef) []adminGroupView { out := make([]adminGroupView, 0, len(refs)) for _, g := range refs { out = append(out, adminGroupView{ID: Ident(g.ID), Name: Ident(g.Name), Primary: g.Primary}) } return out } func userRowView(u AdminUserRow) adminUserRowView { shown := u.Groups[:min(len(u.Groups), AdminListBadges)] v := adminUserRowView{ID: Ident(u.ID), Username: Ident(u.Username), DisplayName: u.DisplayName, Disabled: u.Disabled, Pending: u.Pending, More: max(u.GroupCount, len(u.Groups)) - len(shown)} for _, g := range shown { v.Groups = append(v.Groups, Ident(g)) } return v } func groupChoices(candidates []string) []Ident { if len(candidates) > adminPickerOptions { return nil } out := make([]Ident, 0, len(candidates)) for _, g := range candidates { out = append(out, Ident(g)) } return out } func userView(viewer Ident, id string, u AdminUser) adminUserData { shown := u.Groups[:min(len(u.Groups), AdminDetailBadges)] d := adminUserData{ Viewer: viewer, ID: Ident(id), Username: Ident(u.Username), DisplayName: u.DisplayName, UIDNumber: u.UIDNumber, Disabled: u.Disabled, Pending: u.Pending, Admin: u.Admin, Groups: groupViews(shown), More: max(u.GroupCount, len(u.Groups)) - len(shown), Sessions: u.Sessions, RefreshTokens: u.RefreshTokens, Activity: auditViews(u.Activity), } add := "/users/" + id + SSHKeyNewPath if u.Disabled || u.Pending { add = "" } d.SSHKeys = newSSHKeyList(u.SSHKeys, "/users/"+id+"/ssh-keys/", add) if u.LastLoginAt != nil { d.LastSignIn = stamp(*u.LastLoginAt) } if u.Google != nil { d.Google = true d.GoogleSince = stamp(u.Google.LinkedAt) } return d } func (a *admin) users(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } q, page := searchQuery(r), pageNumber(r) list, err := a.b.ListUsers(r.Context(), q, page) if err != nil { a.failed(w, r, "list users", err) return } rows := list.Users[:min(len(list.Users), AdminUsersPerPage)] d := adminUsersData{Viewer: p.Username, Pager: newPager("/users", q, page, AdminUsersPerPage, len(rows), list.Total)} for _, u := range rows { d.Rows = append(d.Rows, userRowView(u)) } a.render(w, r, http.StatusOK, "admin-users", d) } func (a *admin) user(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } id, ok := a.id(w, r) if !ok { return } u, err := a.b.User(r.Context(), id) if err != nil { a.failed(w, r, "user", err) return } a.render(w, r, http.StatusOK, "admin-user", userView(p.Username, id, u)) } func (a *admin) newUser(w http.ResponseWriter, r *http.Request) { if p, ok := a.kit.principal(w, r); ok { a.render(w, r, http.StatusOK, "admin-user-new", adminNewUserData{Viewer: p.Username}) } } func (a *admin) createUser(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } f := AdminNewUser{Username: r.PostFormValue("username"), DisplayName: r.PostFormValue("display_name")} id, err := a.b.CreateUser(r.Context(), p.UserID, f) if key, ok := adminProblemKey(err); ok { a.render(w, r, http.StatusOK, "admin-user-new", adminNewUserData{Viewer: p.Username, Username: echoed(f.Username, echoUsernameBytes), DisplayName: echoed(f.DisplayName, echoDisplayNameBytes), Message: key}) return } if err == nil && !reAdminID.MatchString(id) { err = errors.New("web: the backend returned a malformed user id") } if err != nil { a.failed(w, r, "create user", err) return } a.done(w, r, text.FlashAdminUserCreated, Ident(f.Username), "/users/"+id+"/reset-link") } func (a *admin) loadUser(w http.ResponseWriter, r *http.Request) (Principal, string, AdminUser, bool) { p, ok := a.kit.principal(w, r) if !ok { return p, "", AdminUser{}, false } id, ok := a.id(w, r) if !ok { return p, id, AdminUser{}, false } u, err := a.b.User(r.Context(), id) if err != nil { a.failed(w, r, "user", err) return p, id, u, false } return p, id, u, true } func (a *admin) profile(w http.ResponseWriter, r *http.Request) { if p, id, u, ok := a.loadUser(w, r); ok { a.render(w, r, http.StatusOK, "admin-user-profile", userView(p.Username, id, u)) } } func (a *admin) saveProfile(w http.ResponseWriter, r *http.Request) { p, id, u, ok := a.loadUser(w, r) if !ok { return } f := AdminProfile{DisplayName: r.PostFormValue("display_name")} err := a.b.UpdateProfile(r.Context(), p.UserID, id, f) if key, ok := adminProblemKey(err); ok { d := userView(p.Username, id, u) d.DisplayName, d.Message = echoed(f.DisplayName, echoDisplayNameBytes), key a.render(w, r, http.StatusOK, "admin-user-profile", d) return } if err != nil { a.failed(w, r, "save profile", err) return } a.done(w, r, text.FlashAdminProfileSaved, Ident(u.Username), "/users/"+id) } func (a *admin) renderMemberships(w http.ResponseWriter, r *http.Request, p Principal, id string, page int, input string, message text.Key) { m, err := a.b.Memberships(r.Context(), id, page) if err != nil { a.failed(w, r, "memberships", err) return } shown := m.Groups[:min(len(m.Groups), AdminMembersPerPage)] a.render(w, r, http.StatusOK, "admin-user-groups", adminMembershipsData{ Viewer: p.Username, ID: Ident(id), Username: Ident(m.Username), Groups: groupViews(shown), Pager: newPager("/users/"+id+"/groups", "", page, AdminMembersPerPage, len(shown), m.Total), Choices: groupChoices(m.Candidates), GroupInput: input, Message: message, }) } func (a *admin) memberships(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } if id, ok := a.id(w, r); ok { a.renderMemberships(w, r, p, id, pageNumber(r), "", "") } } func (a *admin) changeMembership(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } id, ok := a.id(w, r) if !ok { return } var err error group := r.PostFormValue("group") flash := text.FlashAdminJoined switch r.PostFormValue("op") { case "add": err = a.b.AddMembership(r.Context(), p.UserID, id, group) case "remove": gid := r.PostFormValue("group_id") if !reAdminID.MatchString(gid) { a.badOp(w, r) return } group, flash = "", text.FlashAdminLeft err = a.b.RemoveMembership(r.Context(), p.UserID, id, gid) default: a.badOp(w, r) return } if key, ok := adminProblemKey(err); ok { a.renderMemberships(w, r, p, id, 1, echoed(group, echoGroupNameBytes), key) return } if err != nil { a.failed(w, r, "change membership", err) return } a.done(w, r, flash, Ident(group), "/users/"+id+"/groups") } func (a *admin) userConfirm(path string, title, body, button, flash text.Key, safe bool, act func(ctx context.Context, actor, id string) error, after func(id string) string) adminAction { return adminAction{path: path, adminConfirm: adminConfirm{ a: a, title: title, body: body, button: button, flash: flash, safe: safe, load: func(r *http.Request, id string) (Ident, error) { u, err := a.b.User(r.Context(), id) return Ident(u.Username), err }, act: func(r *http.Request, actor, id string) error { return act(r.Context(), actor, id) }, action: func(id string) string { return "/users/" + id + "/" + path }, cancel: func(id string) string { return "/users/" + id }, after: after, }} } func (a *admin) userActions() []adminAction { detail := func(id string) string { return "/users/" + id } return []adminAction{ a.userConfirm("disable", text.AdminDisableTitle, text.AdminDisableBody, text.AdminUserDisable, text.FlashAdminUserDisabled, false, a.b.DisableUser, detail), a.userConfirm("enable", text.AdminEnableTitle, text.AdminEnableBody, text.AdminUserEnable, text.FlashAdminUserEnabled, true, a.b.EnableUser, detail), a.userConfirm("sessions/revoke-all", text.AdminRevokeAllTitle, text.AdminRevokeAllBody, text.AdminUserRevokeAll, text.FlashAdminSignedOut, false, a.b.RevokeAllSessions, detail), a.userConfirm("google/unlink", text.AdminGoogleUnlinkTitle, text.AdminGoogleUnlinkBody, text.AdminUserGoogleUnlink, text.FlashAdminGoogleUnlinked, false, a.b.UnlinkGoogle, detail).withSelf(text.AdminGoogleUnlinkSelfElsewhere), a.userConfirm("delete", text.AdminDeleteUserTitle, text.AdminDeleteUserBody, text.AdminUserDelete, text.FlashAdminUserDeleted, false, a.b.DeleteUser, func(string) string { return "/users" }), a.userConfirm("reject", text.AdminRejectTitle, text.AdminRejectBody, text.AdminRejectButton, text.FlashAdminUserRejected, false, a.b.RejectAccount, func(string) string { return "/users" }), } } func (act adminAction) withSelf(key text.Key) adminAction { act.self = key return act }