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>
454 lines
15 KiB
Go
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"
|
|
}
|
|
}
|