Files
llmgw/internal/web/admin_users.go
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
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>
2026-10-05 00:12:53 +02:00

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