package api import ( "encoding/json" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/audit" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) type adminUserView struct { ID string `json:"id"` Username string `json:"username"` DisplayName *string `json:"display_name"` IsAdmin bool `json:"is_admin"` CreatedAt string `json:"created_at"` } type listUsersResp struct { Users []adminUserView `json:"users"` } func (h *handlers) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { q := dbq.New(h.pool) rows, err := q.ListUsers(r.Context()) if err != nil { h.logger.Error("admin: list users failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "") return } out := make([]adminUserView, 0, len(rows)) for _, row := range rows { v := adminUserView{ ID: uuidToString(row.ID), Username: row.Username, DisplayName: row.DisplayName, IsAdmin: row.IsAdmin, CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339), } out = append(out, v) } writeJSON(w, http.StatusOK, listUsersResp{Users: out}) } type updateUserAdminReq struct { IsAdmin bool `json:"is_admin"` } func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request) { caller, ok := auth.UserFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "auth_required", "") return } targetID, ok := parseUUID(chi.URLParam(r, "id")) if !ok { writeErr(w, http.StatusBadRequest, "invalid_id", "") return } var req updateUserAdminReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid_body", "") return } q := dbq.New(h.pool) // Last-admin guard: if demoting (is_admin=false) AND target is // currently the only admin, refuse. The check has a race window // (between counting and updating, another admin could be demoted) // but the worst-case outcome is "no admins left," which the // operator can recover from via the env-driven bootstrap or the // CLI password-reset path. The check still prevents the common // path of "admin demotes themselves and loses access." if !req.IsAdmin { count, err := q.CountAdmins(r.Context()) if err != nil { h.logger.Error("admin: count admins failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "") return } // We need to know whether the target is currently an admin. // If they're already not-admin, the demote is a no-op (we // allow it). If they ARE admin and they're the last one, refuse. current, err := q.GetUserByID(r.Context(), targetID) if err != nil { h.logger.Error("admin: get user failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "") return } if current.IsAdmin && count <= 1 { writeErr(w, http.StatusConflict, "last_admin", "can't remove the last admin — promote someone else first") return } } updated, err := q.UpdateUserAdmin(r.Context(), dbq.UpdateUserAdminParams{ ID: targetID, IsAdmin: req.IsAdmin, }) if err != nil { h.logger.Error("admin: update user admin failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "") return } action := audit.ActionPromoteAdmin if !req.IsAdmin { action = audit.ActionDemoteAdmin } if err := audit.Write(r.Context(), h.pool, caller.ID, targetID, action, nil); err != nil { h.logger.Warn("admin: promote/demote audit failed", "err", err) } writeJSON(w, http.StatusOK, adminUserView{ ID: uuidToString(updated.ID), Username: updated.Username, DisplayName: updated.DisplayName, IsAdmin: updated.IsAdmin, CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339), }) }