Files
llmgw/internal/web/admin_users.go
T
Ilker ManapandClaude Opus 5.5 c9cd25efc2 Draw one menu on every page
Each page wrote its own nav: the same links in a different order, some
missing, the current page sometimes listed and sometimes not. A shared
"nav" template now draws it everywhere — Cluster, People, Quotas, Usage,
Mirror for an administrator, then Chat, Your keys and Sign out — with
the current page marked by aria-current. Pages rendered outside the
account pages (the dashboard, node logs) get it through the request
context their admin wrapper sets. The admin listener's own page keeps
its metrics/pprof/api links.

The chat and key pages said "project proj-3kq7…"; they now show the
project's path, and the key page no longer repeats the email.

A test renders several admin pages and requires the same menu on each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:55:36 +02:00

454 lines
15 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"net/http"
"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
MoveUser(ctx context.Context, userID, projectID string) (int, 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
ProjectPath string
}
// 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
// People and Keys are keyed by project id.
People map[string][]userRow
Keys map[string][]keyRow
Projects []store.ProjectPath
Orgs []store.Org
Teams []store.TeamPath
// 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) {
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) (string, error) {
project := r.FormValue("project")
if project == "" {
return "", errors.New("choose a project for their keys")
}
if err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil {
return "", err
}
return "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) (string, error) {
if id == admin.ID {
// Locking yourself out is not a thing an interface should let
// you do by misclicking.
return "", errors.New("you cannot suspend your own account")
}
if err := s.SetUserState(ctx, id, store.UserSuspended); err != nil {
return "", 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 "", err
}
return "suspended, and signed out everywhere", nil
}))
mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s,
func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) {
if err := s.SetUserState(ctx, id, store.UserActive); err != nil {
return "", err
}
return "reinstated", nil
}))
mux.HandleFunc("POST /admin/users/{id}/move", p.adminAction(s,
func(ctx context.Context, _ store.User, id string, r *http.Request) (string, error) {
project := r.FormValue("project")
if project == "" {
return "", errors.New("choose the project they go to")
}
moved, err := s.MoveUser(ctx, id, project)
if err != nil {
return "", err
}
return fmt.Sprintf("moved to %s, with %s", projectPath(ctx, s, project), plural(moved, "key", "keys")), nil
}))
mux.HandleFunc("POST /admin/users/{id}/delete", p.adminAction(s,
func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) {
if id == admin.ID {
return "", errors.New("you cannot remove your own account")
}
if err := s.DeleteUser(ctx, id, time.Now()); err != nil {
return "", err
}
return "removed; their keys are 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) (string, error) {
_, err := s.CreateOrg(ctx, r.FormValue("name"))
if err != nil {
return "", err
}
return "organization " + r.FormValue("name") + " created", nil
}))
mux.HandleFunc("POST /admin/tenancy/team", p.tenancyAction(s,
func(ctx context.Context, r *http.Request) (string, error) {
org := r.FormValue("org")
if org == "" {
return "", errors.New("choose the organization this team belongs to")
}
_, err := s.CreateTeam(ctx, org, r.FormValue("name"))
if err != nil {
return "", err
}
return "team " + r.FormValue("name") + " created", nil
}))
mux.HandleFunc("POST /admin/tenancy/project", p.tenancyAction(s,
func(ctx context.Context, r *http.Request) (string, error) {
team := r.FormValue("team")
if team == "" {
return "", errors.New("choose the team this project belongs to")
}
id, err := s.CreateProject(ctx, team, r.FormValue("name"))
if err != nil {
return "", err
}
r.Form.Set("open", id)
return "project " + r.FormValue("name") + " created", nil
}))
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/rename", p.tenancyAction(s,
func(ctx context.Context, r *http.Request) (string, 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 "", errors.New("nothing of that kind")
}
if err != nil {
return "", err
}
return "renamed to " + name, nil
}))
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/move", p.tenancyAction(s,
func(ctx context.Context, r *http.Request) (string, error) {
id, to := r.PathValue("id"), r.FormValue("to")
if to == "" {
return "", errors.New("choose where it goes")
}
switch r.PathValue("kind") {
case "team":
if err := s.MoveTeam(ctx, id, to); err != nil {
return "", err
}
return "team moved", nil
case "project":
if err := s.MoveProject(ctx, id, to); err != nil {
return "", err
}
return "project moved to " + projectPath(ctx, s, id), nil
}
return "", errors.New("only a team or a project moves")
}))
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/delete", p.tenancyAction(s,
func(ctx context.Context, r *http.Request) (string, 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 "", errors.New("nothing of that kind")
}
if err != nil {
return "", err
}
r.Form.Del("open")
return "removed", nil
}))
mux.HandleFunc("POST /admin/keys/{id}/revoke", p.tenancyAction(s,
func(ctx context.Context, r *http.Request) (string, error) {
if err := s.RevokeKey(ctx, r.PathValue("id"), time.Now()); err != nil {
return "", err
}
return "key revoked; a running server stops accepting it when its cache expires", nil
}))
}
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
}
// plainError says what a store error means to the person who caused it.
func plainError(err error) error {
switch {
case errors.Is(err, store.ErrDuplicateName):
return errors.New("there is already one of those here")
case errors.Is(err, store.ErrNotEmpty):
return fmt.Errorf("not removed: %s", strings.TrimPrefix(err.Error(), store.ErrNotEmpty.Error()+": "))
case errors.Is(err, store.ErrNotFound):
return errors.New("that is no longer there; the page was out of date")
}
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) (string, 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
}
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)
p.renderUsers(w, r, s, admin, "", err.Error(), r.FormValue("open"))
return
}
p.log.Info("the tenancy tree was changed", "admin", admin.Email,
"path", r.URL.Path, "what", msg)
p.renderUsers(w, r, s, admin, msg, "", 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 !user.IsAdmin {
// 404 rather than 403: a 403 confirms the page exists.
http.NotFound(w, r)
return
}
next(w, p.withNav(r, user), 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) (string, 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")
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)
p.renderUsers(w, r, s, admin, "", subject+": "+err.Error(), r.FormValue("open"))
return
}
p.log.Info("an account was changed",
"admin", admin.Email, "subject", subject, "what", msg)
p.renderUsers(w, r, s, admin, subject+" "+msg, "", r.FormValue("open"))
})
}
func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request,
s AccountAdmin, admin store.User, msg, errMsg, open string,
) {
ctx := r.Context()
d := adminUsersData{
Nav: navFrom(r, "people"),
Message: msg, Error: errMsg, Open: open, AdminID: admin.ID,
CSRF: csrfFor(r),
Version: p.version,
HasMirror: p.hasMirror,
HasQuotas: p.hasQuotas,
Now: time.Now().UTC().Format(time.RFC3339),
People: map[string][]userRow{},
Keys: map[string][]keyRow{},
}
var err error
logErr := func(what string, err error) {
if err != nil {
p.log.Error("could not read "+what, "err", err)
if d.Error == "" {
d.Error = "could not read " + what
}
}
}
d.Projects, err = s.AllProjects(ctx)
logErr("the 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", err)
for _, u := range all {
if u.State == store.UserPending && u.ProjectID == "" {
d.Pending = append(d.Pending, u)
continue
}
row := userRow{User: u, ProjectPath: paths[u.ProjectID]}
d.Accounts = append(d.Accounts, row)
d.People[u.ProjectID] = append(d.People[u.ProjectID], row)
}
keys, err := s.AllKeys(ctx)
logErr("the 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", err)
d.Orgs, err = s.AllOrgs(ctx)
logErr("the organizations", err)
d.Teams, err = s.AllTeams(ctx)
logErr("the teams", err)
render(w, "admin_users", d)
}
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"
}
}