Two things the dashboard was missing: it could not act, and it was a column of numbers. Account administration moves to /admin/users — approving into a project, suspending, reinstating. That is the work an operator does constantly and it should not need a terminal. Three things stay on the CLI, and the line is not arbitrary: promoting someone to admin, resetting a password, and issuing a key on another person's behalf each turn a stolen admin session into more than it was — permanent access, or somebody else's account. Approving and suspending do not. Suspending signs the account out everywhere, or suspension is advisory until the session happens to expire, and an admin cannot suspend themselves, which is not a thing an interface should let you do by misclicking. The charts are inline SVG generated in Go. A charting library would mean a JavaScript toolchain in CI and a megabyte of bundle for what is a list of rectangles, and the single-binary goal rules that out. Hourly over a day: fine enough to show a working day, coarse enough that one slow minute is not an incident. Empty hours are filled in, because a chart with hours missing where nothing happened reads as a chart with hours missing from the data. The SVG is marked as trusted HTML, so everything that reaches it is either a number this package computed or escaped explicitly, and a bar's colour can only be one of four names — a colour that could be anything is a way to write CSS into every page the chart appears on. There are tests for both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
164 lines
5.2 KiB
Go
164 lines
5.2 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
|
|
}
|
|
|
|
// 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,
|
|
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)
|
|
}
|