From cac6fcd6262295fd764cb11ac9461601dfb0130d Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sun, 3 May 2026 15:24:34 +0600 Subject: [PATCH] feat: admin grant/revoke from admin panel Add PUT /v1/admin/users/{id}/admin endpoint and frontend UI for granting and revoking platform admin status. Uses atomic conditional SQL (RevokeUserAdmin) to prevent race conditions that could remove the last admin. Includes idempotency check, audit logging, and confirmation dialog with self-demotion warning. --- db/queries/users.sql | 6 ++ frontend/src/lib/api/admin-users.ts | 4 + frontend/src/routes/admin/users/+page.svelte | 107 ++++++++++++++++++- internal/api/handlers_users.go | 55 ++++++++++ internal/api/openapi.yaml | 48 +++++++++ internal/api/server.go | 1 + pkg/audit/logger.go | 8 ++ pkg/db/users.sql.go | 15 +++ 8 files changed, 242 insertions(+), 2 deletions(-) diff --git a/db/queries/users.sql b/db/queries/users.sql index 81d3fe2..48b532c 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -22,6 +22,12 @@ RETURNING *; -- name: SetUserAdmin :exec UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1; +-- name: RevokeUserAdmin :execrows +UPDATE users u SET is_admin = false, updated_at = NOW() +WHERE u.id = $1 + AND u.is_admin = true + AND (SELECT COUNT(*) FROM users WHERE is_admin = true AND status != 'deleted') > 1; + -- name: GetAdminUsers :many SELECT * FROM users WHERE is_admin = TRUE ORDER BY created_at; diff --git a/frontend/src/lib/api/admin-users.ts b/frontend/src/lib/api/admin-users.ts index c5dd339..e22137a 100644 --- a/frontend/src/lib/api/admin-users.ts +++ b/frontend/src/lib/api/admin-users.ts @@ -26,3 +26,7 @@ export async function listAdminUsers(page: number = 1): Promise> { return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active }); } + +export async function setUserAdmin(id: string, admin: boolean): Promise> { + return apiFetch('PUT', `/api/v1/admin/users/${id}/admin`, { admin }); +} diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index 3630f4f..2935c9f 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -5,8 +5,10 @@ import { listAdminUsers, setUserActive, + setUserAdmin, type AdminUser, } from '$lib/api/admin-users'; + import { auth } from '$lib/auth.svelte'; // Data state let users = $state([]); @@ -22,6 +24,11 @@ // Toggle state let togglingId = $state(null); + // Admin dialog state + let adminTarget = $state(null); + let togglingAdmin = $state(false); + let adminError = $state(null); + async function fetchUsers(page: number = 1) { const wasEmpty = users.length === 0; if (wasEmpty) loading = true; @@ -56,6 +63,23 @@ togglingId = null; } + async function handleConfirmAdminToggle() { + if (!adminTarget) return; + togglingAdmin = true; + adminError = null; + const target = adminTarget; + const newAdmin = !target.is_admin; + const result = await setUserAdmin(target.id, newAdmin); + if (result.ok) { + adminTarget = null; + target.is_admin = newAdmin; + toast.success(`${target.email} ${newAdmin ? 'granted' : 'revoked'} admin`); + } else { + adminError = result.error; + } + togglingAdmin = false; + } + function goToPage(page: number) { if (page < 1 || page > totalPages) return; fetchUsers(page); @@ -222,8 +246,18 @@ -
- {user.is_admin ? 'Admin' : 'User'} +
+
@@ -292,3 +326,72 @@
+ + +{#if adminTarget} +
+ +
{ if (!togglingAdmin) adminTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !togglingAdmin) adminTarget = null; }} + >
+
+
+

+ {adminTarget.is_admin ? 'Revoke Admin' : 'Grant Admin'} +

+

+ {adminTarget.is_admin ? 'Remove admin access from' : 'Grant admin access to'} + {adminTarget.email}. + {adminTarget.is_admin + ? 'They will lose access to the admin panel immediately.' + : 'They will be able to manage all platform resources.'} +

+ + {#if adminTarget.is_admin && adminTarget.id === auth.userId} +
+ + + + + You are removing your own admin access. You will lose access to this panel. + +
+ {/if} + + {#if adminError} +
+ {adminError} +
+ {/if} + +
+ + +
+
+
+
+{/if} diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 1a82653..5cd6837 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -162,3 +162,58 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +// SetUserAdmin handles PUT /v1/admin/users/{id}/admin +// Grants or revokes platform admin status. Cannot remove the last admin. +func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + userIDStr := chi.URLParam(r, "id") + + userID, err := id.ParseUserID(userIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID") + return + } + + var req struct { + Admin bool `json:"admin"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + user, err := h.db.GetUserByID(r.Context(), userID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "user not found") + return + } + + if user.IsAdmin == req.Admin { + w.WriteHeader(http.StatusNoContent) + return + } + + if req.Admin { + if err := h.db.SetUserAdmin(r.Context(), db.SetUserAdminParams{ + ID: userID, + IsAdmin: true, + }); err != nil { + writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status") + return + } + h.audit.LogUserGrantAdmin(r.Context(), ac, userID, user.Email) + } else { + affected, err := h.db.RevokeUserAdmin(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status") + return + } + if affected == 0 { + writeError(w, http.StatusBadRequest, "invalid_request", "cannot remove the last admin") + return + } + h.audit.LogUserRevokeAdmin(r.Context(), ac, userID, user.Email) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index c18c575..6501061 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -2346,6 +2346,54 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/admin/users/{id}/admin: + put: + summary: Grant or revoke platform admin + operationId: setUserAdmin + tags: [admin] + description: | + Sets the platform admin flag on a user. Cannot remove the last admin. + Requires platform admin access (JWT + is_admin). + The target user's JWT is not re-issued — their frontend will reflect the + change on next login or team switch. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + example: "usr-a1b2c3d4" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [admin] + properties: + admin: + type: boolean + description: true to grant admin, false to revoke. + responses: + "204": + description: Admin status updated + "400": + $ref: "#/components/responses/BadRequest" + "403": + description: Caller is not a platform admin + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: securitySchemes: apiKeyAuth: diff --git a/internal/api/server.go b/internal/api/server.go index 11b6fbb..e59eecd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -269,6 +269,7 @@ func New( r.Delete("/teams/{id}", teamH.AdminDeleteTeam) r.Get("/users", usersH.AdminListUsers) r.Put("/users/{id}/active", usersH.SetUserActive) + r.Put("/users/{id}/admin", usersH.SetUserAdmin) r.Get("/audit-logs", auditH.AdminList) r.Get("/templates", buildH.ListTemplates) r.Delete("/templates/{name}", buildH.DeleteTemplate) diff --git a/pkg/audit/logger.go b/pkg/audit/logger.go index eb73d70..ae26729 100644 --- a/pkg/audit/logger.go +++ b/pkg/audit/logger.go @@ -365,6 +365,14 @@ func (l *AuditLogger) LogUserDeactivate(ctx context.Context, ac auth.AuthContext l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "deactivate", "warning", map[string]any{"email": email})) } +func (l *AuditLogger) LogUserGrantAdmin(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) { + l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "grant_admin", "success", map[string]any{"email": email})) +} + +func (l *AuditLogger) LogUserRevokeAdmin(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) { + l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "revoke_admin", "warning", map[string]any{"email": email})) +} + // --- Team admin events (scope: admin) --- func (l *AuditLogger) LogTeamSetBYOC(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID, enabled bool) { diff --git a/pkg/db/users.sql.go b/pkg/db/users.sql.go index b2d79e8..da4b436 100644 --- a/pkg/db/users.sql.go +++ b/pkg/db/users.sql.go @@ -415,6 +415,21 @@ func (q *Queries) ListUsersAdmin(ctx context.Context, arg ListUsersAdminParams) return items, nil } +const revokeUserAdmin = `-- name: RevokeUserAdmin :execrows +UPDATE users u SET is_admin = false, updated_at = NOW() +WHERE u.id = $1 + AND u.is_admin = true + AND (SELECT COUNT(*) FROM users WHERE is_admin = true AND status != 'deleted') > 1 +` + +func (q *Queries) RevokeUserAdmin(ctx context.Context, id pgtype.UUID) (int64, error) { + result, err := q.db.Exec(ctx, revokeUserAdmin, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const searchUsersByEmailPrefix = `-- name: SearchUsersByEmailPrefix :many SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10 `