/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>
167 lines
5.3 KiB
Go
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)
|
|
}
|