Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
657 lines
23 KiB
Go
657 lines
23 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// AccountAdmin is the store half of account administration.
|
|
type AccountAdmin interface {
|
|
Users(ctx context.Context, state store.UserState) ([]store.User, error)
|
|
UserByID(ctx context.Context, id string) (store.User, error)
|
|
ApproveUser(ctx context.Context, id, projectID, by string, at time.Time) error
|
|
SetUserState(ctx context.Context, id string, state store.UserState) error
|
|
DeleteUserSessions(ctx context.Context, userID string) error
|
|
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
|
|
|
|
// The tenancy tree an account is approved into. Creating it used to need a
|
|
// shell — `llmgw keys init` — so the first thing an operator had to do
|
|
// after installing was leave the interface they were already in.
|
|
AllOrgs(ctx context.Context) ([]store.Org, error)
|
|
AllTeams(ctx context.Context) ([]store.TeamPath, error)
|
|
CreateOrg(ctx context.Context, name string) (string, error)
|
|
CreateTeam(ctx context.Context, orgID, name string) (string, error)
|
|
CreateProject(ctx context.Context, teamID, name string) (string, error)
|
|
|
|
// And keeping it in shape afterwards, which used to have no interface at
|
|
// all: nothing could be renamed, moved or removed, and an approved person
|
|
// stayed in the project they were first put in.
|
|
Tree(ctx context.Context) ([]store.TreeOrg, error)
|
|
RenameOrg(ctx context.Context, id, name string) error
|
|
RenameTeam(ctx context.Context, id, name string) error
|
|
RenameProject(ctx context.Context, id, name string) error
|
|
MoveTeam(ctx context.Context, teamID, orgID string) error
|
|
MoveProject(ctx context.Context, projectID, teamID string) error
|
|
DeleteOrg(ctx context.Context, id string) error
|
|
DeleteTeam(ctx context.Context, id string) error
|
|
DeleteProject(ctx context.Context, id string) error
|
|
AddMembership(ctx context.Context, userID string, scope store.Scope, scopeID, role, by string, at time.Time) error
|
|
RemoveMembership(ctx context.Context, userID string, scope store.Scope, scopeID string, at time.Time) (int, error)
|
|
AllMemberships(ctx context.Context) ([]store.Membership, error)
|
|
DeleteUser(ctx context.Context, userID string, at time.Time) error
|
|
AllKeys(ctx context.Context) ([]store.KeyRow, error)
|
|
RevokeKey(ctx context.Context, id string, at time.Time) error
|
|
}
|
|
|
|
// userRow is an account as the page shows it: with where it is, not an id.
|
|
type userRow struct {
|
|
store.User
|
|
// Memberships are where the person is, by path; none is a person who uses
|
|
// the gateway on their own.
|
|
Memberships []store.Membership
|
|
// Role is their role in the scope a list of members is drawn for.
|
|
Role string
|
|
}
|
|
|
|
// scopeOption is somewhere a person can be put, for a chooser.
|
|
type scopeOption struct {
|
|
Value string // "project:proj-…"
|
|
Label string // "Acme / ml / training"
|
|
}
|
|
|
|
// keyRow is a key as the page shows it.
|
|
type keyRow struct {
|
|
store.KeyRow
|
|
State string
|
|
}
|
|
|
|
type adminUsersData struct {
|
|
Nav Nav
|
|
Pending []store.User
|
|
Accounts []userRow
|
|
Tree []store.TreeOrg
|
|
// Members is each scope's own members, keyed "project:<id>" and likewise
|
|
// for teams and organizations; Keys is keyed by project id.
|
|
Members map[string][]userRow
|
|
// Scopes is every place a person can be added, organizations and teams
|
|
// included.
|
|
Scopes []scopeOption
|
|
Keys map[string][]keyRow
|
|
Projects []store.ProjectPath
|
|
Orgs []store.Org
|
|
Teams []store.TeamPath
|
|
// Sel is the branch or person whose panel is open, "team:<id>", so the
|
|
// tree can mark it.
|
|
Sel string
|
|
// Open is the project whose section is shown expanded: the one just acted
|
|
// on, so the result of a change is in view without the reader hunting for
|
|
// it. Scripted pages keep every open section open; this is for the rest.
|
|
Open string
|
|
AdminID string
|
|
Message string
|
|
Error string
|
|
CSRF string
|
|
Version string
|
|
Now string
|
|
// HasMirror and HasQuotas keep the nav from offering a page that was never
|
|
// mounted.
|
|
HasMirror bool
|
|
HasQuotas bool
|
|
}
|
|
|
|
// RegisterAdminUsers mounts account and tenancy administration under
|
|
// /admin/users.
|
|
//
|
|
// What is here and what is not is the whole design. Approving, suspending,
|
|
// moving people and keeping the tree in shape are the work an operator does
|
|
// constantly and should not need a terminal for. Making someone an admin,
|
|
// resetting a password and issuing keys on another person's behalf are not
|
|
// here: each of them turns a stolen admin session into more than it was —
|
|
// more access, or somebody else's account — and each is rare enough that a
|
|
// shell is no hardship. Revoking a key is here, because it only ever takes
|
|
// access away.
|
|
func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) {
|
|
p.peopleStore = s
|
|
mux.HandleFunc("GET /admin/users", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
|
p.renderUsers(w, r, s, admin, "", "", r.URL.Query().Get("open"))
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/approve", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, error) {
|
|
project := r.FormValue("project")
|
|
if err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil {
|
|
return notice{}, err
|
|
}
|
|
if project == "" {
|
|
return say("users.msg.approved_alone"), nil
|
|
}
|
|
return say("users.msg.approved_into", projectPath(ctx, s, project)), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/suspend", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, _ *http.Request) (notice, error) {
|
|
if id == admin.ID {
|
|
// Locking yourself out is not a thing an interface should let
|
|
// you do by misclicking.
|
|
return notice{}, refuse("users.err.self_suspend")
|
|
}
|
|
if err := s.SetUserState(ctx, id, store.UserSuspended); err != nil {
|
|
return notice{}, err
|
|
}
|
|
// Otherwise they stay signed in and suspension is advisory until
|
|
// their session happens to expire.
|
|
if err := s.DeleteUserSessions(ctx, id); err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.suspended"), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s,
|
|
func(ctx context.Context, _ store.User, id string, _ *http.Request) (notice, error) {
|
|
if err := s.SetUserState(ctx, id, store.UserActive); err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.reinstated"), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/join", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, error) {
|
|
scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":")
|
|
if !ok || scopeID == "" {
|
|
return notice{}, refuse("users.err.choose_where")
|
|
}
|
|
if err := s.AddMembership(ctx, id, store.Scope(scope), scopeID, store.RoleMember,
|
|
admin.Email, time.Now()); err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.added_to", scopeLabel(ctx, s, store.Scope(scope), scopeID)), nil
|
|
}))
|
|
|
|
// Managing is a role on an organization membership: it lets the person see
|
|
// that organization's page, and nothing else changes.
|
|
mux.HandleFunc("POST /admin/users/{id}/role", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, error) {
|
|
scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":")
|
|
role := r.FormValue("role")
|
|
if !ok || store.Scope(scope) != store.ScopeOrg || (role != store.RoleManager && role != store.RoleMember) {
|
|
return notice{}, refuse("users.err.manager_role")
|
|
}
|
|
if err := s.AddMembership(ctx, id, store.ScopeOrg, scopeID, role, admin.Email, time.Now()); err != nil {
|
|
return notice{}, err
|
|
}
|
|
label := scopeLabel(ctx, s, store.ScopeOrg, scopeID)
|
|
if role == store.RoleManager {
|
|
return say("users.msg.now_manages", label), nil
|
|
}
|
|
return say("users.msg.no_longer_manages", label), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/leave", p.adminAction(s,
|
|
func(ctx context.Context, _ store.User, id string, r *http.Request) (notice, error) {
|
|
scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":")
|
|
if !ok {
|
|
return notice{}, refuse("users.err.nothing_to_leave")
|
|
}
|
|
label := scopeLabel(ctx, s, store.Scope(scope), scopeID)
|
|
revoked, err := s.RemoveMembership(ctx, id, store.Scope(scope), scopeID, time.Now())
|
|
if err != nil {
|
|
return notice{}, err
|
|
}
|
|
switch {
|
|
case revoked == 1:
|
|
return say("users.msg.removed_from_one_key", label), nil
|
|
case revoked > 1:
|
|
return say("users.msg.removed_from_keys", label, revoked), nil
|
|
}
|
|
return say("users.msg.removed_from", label), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/delete", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, _ *http.Request) (notice, error) {
|
|
if id == admin.ID {
|
|
return notice{}, refuse("users.err.self_remove")
|
|
}
|
|
if err := s.DeleteUser(ctx, id, time.Now()); err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.removed_keys_revoked"), nil
|
|
}))
|
|
|
|
// The tenancy tree. Creating a container is not granting access: nobody can
|
|
// use one until an account is approved into it.
|
|
mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (notice, error) {
|
|
_, err := s.CreateOrg(ctx, r.FormValue("name"))
|
|
if err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.org_created", r.FormValue("name")), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/team", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (notice, error) {
|
|
org := r.FormValue("org")
|
|
if org == "" {
|
|
return notice{}, refuse("users.err.choose_org")
|
|
}
|
|
_, err := s.CreateTeam(ctx, org, r.FormValue("name"))
|
|
if err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.team_created", r.FormValue("name")), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/project", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (notice, error) {
|
|
team := r.FormValue("team")
|
|
if team == "" {
|
|
return notice{}, refuse("users.err.choose_team")
|
|
}
|
|
id, err := s.CreateProject(ctx, team, r.FormValue("name"))
|
|
if err != nil {
|
|
return notice{}, err
|
|
}
|
|
r.Form.Set("open", id)
|
|
return say("users.msg.project_created", r.FormValue("name")), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/rename", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (notice, error) {
|
|
id, name := r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))
|
|
var err error
|
|
switch r.PathValue("kind") {
|
|
case "org":
|
|
err = s.RenameOrg(ctx, id, name)
|
|
case "team":
|
|
err = s.RenameTeam(ctx, id, name)
|
|
case "project":
|
|
err = s.RenameProject(ctx, id, name)
|
|
default:
|
|
return notice{}, refuse("users.err.no_such_kind")
|
|
}
|
|
if err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.renamed", name), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/move", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (notice, error) {
|
|
id, to := r.PathValue("id"), r.FormValue("to")
|
|
if to == "" {
|
|
return notice{}, refuse("users.err.choose_destination")
|
|
}
|
|
switch r.PathValue("kind") {
|
|
case "team":
|
|
if err := s.MoveTeam(ctx, id, to); err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.team_moved"), nil
|
|
case "project":
|
|
if err := s.MoveProject(ctx, id, to); err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.project_moved", projectPath(ctx, s, id)), nil
|
|
}
|
|
return notice{}, refuse("users.err.only_team_project_moves")
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/delete", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (notice, error) {
|
|
id := r.PathValue("id")
|
|
var err error
|
|
switch r.PathValue("kind") {
|
|
case "org":
|
|
err = s.DeleteOrg(ctx, id)
|
|
case "team":
|
|
err = s.DeleteTeam(ctx, id)
|
|
case "project":
|
|
err = s.DeleteProject(ctx, id)
|
|
default:
|
|
return notice{}, refuse("users.err.no_such_kind")
|
|
}
|
|
if err != nil {
|
|
return notice{}, err
|
|
}
|
|
r.Form.Del("open")
|
|
return say("users.msg.removed"), nil
|
|
}))
|
|
|
|
// The same as joining, from the scope's side: a form on a project, team or
|
|
// organization that names the person.
|
|
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/add", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (notice, error) {
|
|
user := r.FormValue("user")
|
|
if user == "" {
|
|
return notice{}, refuse("users.err.choose_who")
|
|
}
|
|
scope, id := store.Scope(r.PathValue("kind")), r.PathValue("id")
|
|
if err := s.AddMembership(ctx, user, scope, id, store.RoleMember, "", time.Now()); err != nil {
|
|
return notice{}, err
|
|
}
|
|
who := user
|
|
if u, err := s.UserByID(ctx, user); err == nil {
|
|
who = u.Email
|
|
}
|
|
return say("users.msg.member_added", who, scopeLabel(ctx, s, scope, id)), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/keys/{id}/revoke", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (notice, error) {
|
|
if err := s.RevokeKey(ctx, r.PathValue("id"), time.Now()); err != nil {
|
|
return notice{}, err
|
|
}
|
|
return say("users.msg.key_revoked"), nil
|
|
}))
|
|
}
|
|
|
|
// notice is what the page says about a change, kept as a catalogue key and
|
|
// its arguments rather than as text: the log and the audit record take it in
|
|
// English, which is what they are searched in, and the page in the language
|
|
// of the person who made the change.
|
|
type notice struct {
|
|
key string
|
|
args []any
|
|
}
|
|
|
|
func say(key string, args ...any) notice { return notice{key: key, args: args} }
|
|
|
|
// in is the notice in one language.
|
|
func (n notice) in(l *Lang) string {
|
|
if n.key == "" {
|
|
return ""
|
|
}
|
|
return l.T(n.key, n.args...)
|
|
}
|
|
|
|
// refusal is a change the page refused, said as a notice so the person who
|
|
// asked reads why in their own language. As an error it is English, for the
|
|
// log and the audit record.
|
|
type refusal struct{ notice }
|
|
|
|
func (e refusal) Error() string { return e.in(english) }
|
|
|
|
func refuse(key string, args ...any) error { return refusal{say(key, args...)} }
|
|
|
|
// errorIn is an error from a change as the page shows it.
|
|
func errorIn(l *Lang, err error) string {
|
|
var r refusal
|
|
if errors.As(err, &r) {
|
|
return r.in(l)
|
|
}
|
|
return l.Err(err)
|
|
}
|
|
|
|
// The notices are built by key in the handlers above; the catalogue test
|
|
// cannot see a key passed to say, so they are listed here.
|
|
func init() {
|
|
registerKeys(
|
|
"users.msg.approved_alone", "users.msg.approved_into", "users.msg.suspended",
|
|
"users.msg.reinstated", "users.msg.added_to", "users.msg.now_manages",
|
|
"users.msg.no_longer_manages", "users.msg.removed_from", "users.msg.removed_from_one_key",
|
|
"users.msg.removed_from_keys", "users.msg.removed_keys_revoked", "users.msg.org_created",
|
|
"users.msg.team_created", "users.msg.project_created", "users.msg.renamed",
|
|
"users.msg.team_moved", "users.msg.project_moved", "users.msg.removed",
|
|
"users.msg.member_added", "users.msg.key_revoked",
|
|
"users.err.self_suspend", "users.err.choose_where", "users.err.manager_role",
|
|
"users.err.nothing_to_leave", "users.err.self_remove", "users.err.choose_org",
|
|
"users.err.choose_team", "users.err.no_such_kind", "users.err.choose_destination",
|
|
"users.err.only_team_project_moves", "users.err.choose_who", "users.err.duplicate",
|
|
"users.err.not_empty", "users.err.gone",
|
|
// The words for an account's and a key's state, looked up by the
|
|
// state, which is also the row's class.
|
|
"users.state.active", "users.state.pending", "users.state.suspended",
|
|
"users.key_state.active", "users.key_state.revoked", "users.key_state.expired",
|
|
)
|
|
}
|
|
|
|
func projectPath(ctx context.Context, s AccountAdmin, id string) string {
|
|
all, err := s.AllProjects(ctx)
|
|
if err == nil {
|
|
for _, p := range all {
|
|
if p.ID == id {
|
|
return p.Path
|
|
}
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
// scopeLabel names an org, team or project by its path.
|
|
func scopeLabel(ctx context.Context, s AccountAdmin, scope store.Scope, id string) string {
|
|
switch scope {
|
|
case store.ScopeProject:
|
|
return projectPath(ctx, s, id)
|
|
case store.ScopeTeam:
|
|
if teams, err := s.AllTeams(ctx); err == nil {
|
|
for _, t := range teams {
|
|
if t.ID == id {
|
|
return t.OrgName + " / " + t.Name
|
|
}
|
|
}
|
|
}
|
|
case store.ScopeOrg:
|
|
if orgs, err := s.AllOrgs(ctx); err == nil {
|
|
for _, o := range orgs {
|
|
if o.ID == id {
|
|
return o.Name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
// plainError says what a store error means to the person who caused it.
|
|
//
|
|
// What a scope still holds when it is not empty comes from the store in
|
|
// English ("it still has 1 member and 2 keys"), and is shown as it is.
|
|
func plainError(err error) error {
|
|
switch {
|
|
case errors.Is(err, store.ErrDuplicateName):
|
|
return refuse("users.err.duplicate")
|
|
case errors.Is(err, store.ErrNotEmpty):
|
|
return refuse("users.err.not_empty", strings.TrimPrefix(err.Error(), store.ErrNotEmpty.Error()+": "))
|
|
case errors.Is(err, store.ErrNotFound):
|
|
return refuse("users.err.gone")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// tenancyAction wraps a change to the tree: admin session, CSRF, audit, render.
|
|
//
|
|
// Its own wrapper rather than adminAction because that one is about a subject
|
|
// account and looks its email up to name it in the log. There is no subject
|
|
// here, and passing an empty id through it would write audit lines about
|
|
// nobody.
|
|
func (p *AccountPages) tenancyAction(s AccountAdmin,
|
|
do func(ctx context.Context, r *http.Request) (notice, error),
|
|
) http.HandlerFunc {
|
|
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
|
sess, _, _ := p.session(r)
|
|
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
l := langOf(r)
|
|
msg, err := do(r.Context(), r)
|
|
if err != nil {
|
|
err = plainError(err)
|
|
p.log.Warn("a tenancy change was refused", "admin", admin.Email,
|
|
"path", r.URL.Path, "err", err)
|
|
noteAudit(r, "", "", err.Error())
|
|
p.renderUsers(w, r, s, admin, "", errorIn(l, err), r.FormValue("open"))
|
|
return
|
|
}
|
|
p.log.Info("the tenancy tree was changed", "admin", admin.Email,
|
|
"path", r.URL.Path, "what", msg.in(english))
|
|
noteAudit(r, "", msg.in(english), "")
|
|
p.renderUsers(w, r, s, admin, msg.in(l), "", r.FormValue("open"))
|
|
})
|
|
}
|
|
|
|
// adminOnly is the guard every page under /admin shares.
|
|
func (p *AccountPages) adminOnly(
|
|
next func(w http.ResponseWriter, r *http.Request, admin store.User),
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
_, user, ok := p.session(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if !staffMay(user, r) {
|
|
// 404 rather than 403: a 403 confirms the page exists.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
p.audited(w, p.withNav(r, user), user, func(w http.ResponseWriter, r *http.Request) { next(w, r, user) })
|
|
}
|
|
}
|
|
|
|
// adminAction wraps a change to one account: admin session, CSRF, audit line,
|
|
// render.
|
|
func (p *AccountPages) adminAction(s AccountAdmin,
|
|
do func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, error),
|
|
) http.HandlerFunc {
|
|
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
|
sess, _, _ := p.session(r)
|
|
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
l := langOf(r)
|
|
|
|
subject := id
|
|
if u, err := s.UserByID(r.Context(), id); err == nil {
|
|
subject = u.Email
|
|
}
|
|
|
|
msg, err := do(r.Context(), admin, id, r)
|
|
if err != nil {
|
|
err = plainError(err)
|
|
// Logged at warn whether it worked or not: a refused administrative
|
|
// action is as worth knowing about as one that went through.
|
|
p.log.Warn("an administrative action was refused",
|
|
"admin", admin.Email, "subject", subject, "err", err)
|
|
noteAudit(r, subject, "", err.Error())
|
|
p.renderUsers(w, r, s, admin, "", l.T("users.err.about", subject, errorIn(l, err)), r.FormValue("open"))
|
|
return
|
|
}
|
|
p.log.Info("an account was changed",
|
|
"admin", admin.Email, "subject", subject, "what", msg.in(english))
|
|
noteAudit(r, subject, msg.in(english), "")
|
|
p.renderUsers(w, r, s, admin, l.T("users.msg.about", subject, msg.in(l)), "", r.FormValue("open"))
|
|
})
|
|
}
|
|
|
|
func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request,
|
|
_ AccountAdmin, admin store.User, msg, errMsg, open string,
|
|
) {
|
|
p.renderPeople(w, r, admin, peopleView{Tab: tabPeople, Msg: msg, Err: errMsg, Open: open})
|
|
}
|
|
|
|
// buildUsers is the tree and its people, as the first tab shows them.
|
|
func (p *AccountPages) buildUsers(r *http.Request, s AccountAdmin, admin store.User, msg, errMsg, open string) adminUsersData {
|
|
ctx, l := r.Context(), langOf(r)
|
|
d := adminUsersData{
|
|
Nav: navFrom(r, "people"),
|
|
Message: msg, Error: errMsg, Open: open, AdminID: admin.ID, Sel: r.FormValue("sel"),
|
|
CSRF: csrfFor(r),
|
|
Version: p.version,
|
|
HasMirror: p.hasMirror,
|
|
HasQuotas: p.hasQuotas,
|
|
Now: time.Now().UTC().Format(time.RFC3339),
|
|
Members: map[string][]userRow{},
|
|
Keys: map[string][]keyRow{},
|
|
}
|
|
var err error
|
|
// logErr logs what could not be read in English and says so on the page,
|
|
// once, in the reader's language.
|
|
logErr := func(what, said string, err error) {
|
|
if err != nil {
|
|
p.log.Error("could not read "+what, "err", err)
|
|
if d.Error == "" {
|
|
d.Error = said
|
|
}
|
|
}
|
|
}
|
|
|
|
d.Projects, err = s.AllProjects(ctx)
|
|
logErr("the projects", l.T("users.err.read_projects"), err)
|
|
paths := make(map[string]string, len(d.Projects))
|
|
for _, pp := range d.Projects {
|
|
paths[pp.ID] = pp.Path
|
|
}
|
|
|
|
all, err := s.Users(ctx, "")
|
|
logErr("the accounts", l.T("users.err.read_accounts"), err)
|
|
memberships, err := s.AllMemberships(ctx)
|
|
logErr("the memberships", l.T("users.err.read_memberships"), err)
|
|
byUser := map[string][]store.Membership{}
|
|
for _, m := range memberships {
|
|
byUser[m.UserID] = append(byUser[m.UserID], m)
|
|
}
|
|
users := map[string]store.User{}
|
|
for _, u := range all {
|
|
users[u.ID] = u
|
|
if u.State == store.UserPending {
|
|
d.Pending = append(d.Pending, u)
|
|
continue
|
|
}
|
|
d.Accounts = append(d.Accounts, userRow{User: u, Memberships: byUser[u.ID]})
|
|
}
|
|
for _, m := range memberships {
|
|
if u, ok := users[m.UserID]; ok {
|
|
k := string(m.Scope) + ":" + m.ScopeID
|
|
d.Members[k] = append(d.Members[k], userRow{User: u, Memberships: byUser[u.ID], Role: m.Role})
|
|
}
|
|
}
|
|
|
|
keys, err := s.AllKeys(ctx)
|
|
logErr("the keys", l.T("users.err.read_keys"), err)
|
|
now := time.Now()
|
|
for _, k := range keys {
|
|
d.Keys[k.ProjectID] = append(d.Keys[k.ProjectID], keyRow{KeyRow: k, State: keyState(k.Key, now)})
|
|
}
|
|
|
|
d.Tree, err = s.Tree(ctx)
|
|
logErr("the tree", l.T("users.err.read_tree"), err)
|
|
d.Orgs, err = s.AllOrgs(ctx)
|
|
logErr("the organizations", l.T("users.err.read_orgs"), err)
|
|
d.Teams, err = s.AllTeams(ctx)
|
|
logErr("the teams", l.T("users.err.read_teams"), err)
|
|
for _, o := range d.Orgs {
|
|
d.Scopes = append(d.Scopes, scopeOption{"org:" + o.ID, o.Name})
|
|
}
|
|
for _, t := range d.Teams {
|
|
d.Scopes = append(d.Scopes, scopeOption{"team:" + t.ID, t.OrgName + " / " + t.Name})
|
|
}
|
|
for _, pp := range d.Projects {
|
|
d.Scopes = append(d.Scopes, scopeOption{"project:" + pp.ID, pp.Path})
|
|
}
|
|
sort.Slice(d.Scopes, func(i, j int) bool { return d.Scopes[i].Label < d.Scopes[j].Label })
|
|
return d
|
|
}
|
|
|
|
// keyState is a key's state as a value: the row's class, and the key its word
|
|
// on the page is looked up by.
|
|
func keyState(k store.Key, now time.Time) string {
|
|
switch {
|
|
case !k.RevokedAt.IsZero():
|
|
return "revoked"
|
|
case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt):
|
|
return "expired"
|
|
default:
|
|
return "active"
|
|
}
|
|
}
|