Files
llmgw/internal/web/admin_users.go
T
Ilker ManapandClaude Opus 5 cd7eedf09e Give the mirror a page, so managing it is not a curl command
/admin/mirror, behind the same admin session as the other admin pages. What is
held, what is arriving with its byte count, what failed and why; start a fetch,
retry a failed one, remove a version.

Not mounted at all without a mirror configured. The right page for a deployment
with no mirror is no page, rather than an empty one pointing at nothing.

It reloads itself only while something is downloading. A reload at any other
moment takes with it whatever is half-typed into the fetch form, and there is
nothing to watch when nothing is moving.

A failed row offers Retry, not Remove. A fetch that failed left nothing on disk,
so the remove could only answer "the mirror holds nothing for this" — a button
whose single outcome is a confusing error. Starting the same download again is
what somebody looking at that row wants, and the mirror allows it: only a
download still running is refused.

An unreachable mirror is said outright instead of rendering as an empty table.
The two look nearly identical and mean opposite things: one is a mirror waiting
to be filled, the other is a machine to go and look at.

Dashboard takes NavExtras rather than a second bool. Two adjacent booleans at a
call site get swapped silently and are discovered by somebody clicking the
wrong link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 10:17:12 +02:00

167 lines
5.3 KiB
Go

package web
import (
"context"
"errors"
"net/http"
"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)
}
type adminUsersData struct {
Pending []store.User
Active []store.User
Projects []store.ProjectPath
Message string
Error string
CSRF string
Version string
Now string
// HasMirror keeps the nav from offering a page that was never mounted.
HasMirror bool
}
// RegisterAdminUsers mounts account administration under /admin/users.
//
// What is here and what is not is the whole design. Approving and suspending
// 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.
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, "", "")
}))
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 " + 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
}))
}
// 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, r, user)
}
}
// adminAction wraps a state change: admin session, CSRF, audit line, redirect.
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 {
// 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, "", err.Error())
return
}
p.log.Info("an account was changed",
"admin", admin.Email, "subject", subject, "what", msg)
p.renderUsers(w, r, s, subject+" "+msg, "")
})
}
func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request,
s AccountAdmin, msg, errMsg string,
) {
d := adminUsersData{
Message: msg, Error: errMsg,
CSRF: csrfFor(r),
Version: p.version,
HasMirror: p.hasMirror,
Now: time.Now().UTC().Format(time.RFC3339),
}
all, err := s.Users(r.Context(), "")
if err != nil {
p.log.Error("could not list accounts", "err", err)
d.Error = "could not read the accounts"
}
for _, u := range all {
if u.State == store.UserPending {
d.Pending = append(d.Pending, u)
} else {
d.Active = append(d.Active, u)
}
}
if d.Projects, err = s.AllProjects(r.Context()); err != nil {
p.log.Error("could not list projects", "err", err)
}
render(w, "admin_users", d)
}