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) }