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 `