Goal
Build out the admin portal (frontend route already scaffolded in frontend/src/components/screens/Admin.tsx with tabs users | roles | achievements | cosmetics | analytics). This issue captures the full backend surface that exists today, gaps that block UI work, and decisions to make before implementation.
Auth gate
services/auth_guard.py::require_admin looks up the session user's user_roles and requires a row joining to roles where slug='admin'. Returns 401 (no session) or 403 (missing role). Every admin endpoint lives in routes/admin.py, mounted at /api/admin. is_admin: true is exposed on /api/auth/me for client routing.
Current backend surface
Users — /api/admin/users*
| Method | Path | Body | Effect |
|---|
| GET | /users | — | All users with decrypted name/email, is_approved, created_at, plus roles[] (id/name/slug/color). |
| PATCH | /users/{user_id}/approve | — | Sets users.is_approved = true. No unapprove path. |
Roles — /api/admin/roles*
| Method | Path | Body | Effect |
|---|
| GET | /roles | — | All roles, ordered by display_priority desc. |
| POST | /roles | CreateRoleBody | Insert. |
| PATCH | /roles/{id} | partial dict | Whitelist: name, color, icon, description, is_staff_assigned, is_earnable, display_priority. slug immutable. |
| DELETE | /roles/{id} | — | Hard delete; user_roles cascades. |
| POST | /roles/assign | AssignRoleBody | Insert user_roles. No conflict handling — re-assign 500s. |
| DELETE | /roles/revoke | RevokeRoleBody | Delete user_roles row. |
Seeded roles: early-adopter, moderator, admin, verified, vip.
Achievements — /api/admin/achievements*
| Method | Path | Body | Effect |
|---|
| GET | /achievements | — | All achievements. |
| POST | /achievements | CreateAchievementBody | Insert. |
| PATCH | /achievements/{id} | partial dict | Whitelist: name, description, icon, category, rarity, is_secret. |
| DELETE | /achievements/{id} | — | Cascades to user_achievements, achievement_triggers, achievement_cosmetics. |
| POST | /achievements/grant | GrantAchievementBody | Insert user_achievements (409 if duplicate); fires check_achievements(user_id, \"manual_admin_grant\", {}). |
| POST | /achievements/triggers | CreateAchievementTriggerBody | Insert trigger row. No list/update/delete for triggers. |
Cosmetics — /api/admin/cosmetics*
| Method | Path | Body | Effect |
|---|
| GET | /cosmetics | — | All cosmetics, ordered by type asc. |
| POST | /cosmetics | CreateCosmeticBody | Insert. |
| PATCH | /cosmetics/{id} | partial dict | Whitelist: name, asset_url, css_value, rarity, unlock_source. type and slug immutable. |
| DELETE | /cosmetics/{id} | — | Cascades to user_cosmetics, role_cosmetics. |
Cosmetic types: avatar_frame, banner, name_color, title. Asset upload happens client-side directly to Supabase Storage (cosmetic-assets bucket); API stores the URL only.
Beta allowlist — /api/admin/allowlist*
| Method | Path | Body | Effect |
|---|
| GET | /allowlist | — | All newsletter_emails rows (id, email, created_at, approved_at), newest first. |
| POST | /allowlist/approve | AllowlistEmailBody | Upsert email lowercased + approved_at = now(). |
| POST | /allowlist/revoke | AllowlistEmailBody | Set approved_at = NULL (preserves signup row). 404 if missing. |
Pre-approved emails are auto-promoted to users.is_approved=true on their next OAuth callback (routes/auth.py:308-345). No mid-session promotion.
Gaps blocking the UI
- Analytics tab is in
Admin.tsx's tab union but has no backend — needs new endpoints (user growth, session counts, document volume, etc.) before it's functional. - No audit log — nothing records who approved a user, granted a role, or whitelisted an email.
user_roles.granted_by exists but the route doesn't auto-fill it from the session. - No pagination on any list endpoint —
/users, /roles, /achievements, /cosmetics, /allowlist return everything every time. - No search/filter on
/users — can't find by name/email/role server-side. - No "unapprove" / suspend / ban / delete user —
PATCH /users/{id}/approve is one-way. - No "impersonate user" for support flows.
- Self-protection — nothing prevents an admin from revoking their own admin role or deleting the
admin role row. - Achievement triggers: only POST exists; can't list/delete via API.
- Role-cosmetic / achievement-cosmetic linking tables exist (
role_cosmetics, achievement_cosmetics) but no admin endpoints. - "Last sign-in" field referenced in
Admin.tsx types isn't returned by /users. /users decrypts every user's name + email per request — fine at current scale, hot CPU path past hundreds of rows; pagination amplifies the urgency.
Proposed tab structure
| Tab | Status | Notes |
|---|
| Users | Backend exists, needs additions | Pagination, search, unapprove, last_sign_in. |
| Allowlist | Backend complete (just landed) | Single-list view with approve/revoke + add-by-email. |
| Roles | Backend complete | Add fix for re-assign 500 (use upsert). Surface granted_by (auto-fill from session). |
| Achievements | Backend mostly complete | Add list/delete for triggers; expose achievement_cosmetics linking. |
| Cosmetics | Backend complete | Add role_cosmetics linking UI. |
| Analytics | Backend missing entirely | Decide scope (DAU/MAU? per-feature counts?) before scoping UI. |
Decisions needed
- Unapprove: do we want the inverse of
PATCH /users/{id}/approve? (Likely yes — manual mistake recovery.) - Audit log scope: a single
admin_audit_log table with (actor_id, action, target_type, target_id, payload, created_at), or per-table audit columns? - Analytics scope: out of scope for v1, or include simple counts (total users, approved users, signups by day)?
- Self-protection rules: prevent revoking own admin? Prevent deleting last admin? Prevent deleting
admin role row? - Pagination shape: cursor or offset? Page size default?
Related work
- Beta allowlist endpoints + auth callback promotion logic landed alongside this issue (PR pending).
- Audit-log + unapprove +
/users pagination/search would be the natural next backend chunk.
Goal
Build out the admin portal (frontend route already scaffolded in
frontend/src/components/screens/Admin.tsxwith tabsusers | roles | achievements | cosmetics | analytics). This issue captures the full backend surface that exists today, gaps that block UI work, and decisions to make before implementation.Auth gate
services/auth_guard.py::require_adminlooks up the session user'suser_rolesand requires a row joining toroleswhereslug='admin'. Returns 401 (no session) or 403 (missing role). Every admin endpoint lives inroutes/admin.py, mounted at/api/admin.is_admin: trueis exposed on/api/auth/mefor client routing.Current backend surface
Users —
/api/admin/users*/usersname/email,is_approved,created_at, plusroles[](id/name/slug/color)./users/{user_id}/approveusers.is_approved = true. No unapprove path.Roles —
/api/admin/roles*/rolesroles, ordered bydisplay_priority desc./rolesCreateRoleBody/roles/{id}slugimmutable./roles/{id}user_rolescascades./roles/assignAssignRoleBodyuser_roles. No conflict handling — re-assign 500s./roles/revokeRevokeRoleBodyuser_rolesrow.Seeded roles:
early-adopter,moderator,admin,verified,vip.Achievements —
/api/admin/achievements*/achievements/achievementsCreateAchievementBody/achievements/{id}/achievements/{id}user_achievements,achievement_triggers,achievement_cosmetics./achievements/grantGrantAchievementBodyuser_achievements(409 if duplicate); firescheck_achievements(user_id, \"manual_admin_grant\", {})./achievements/triggersCreateAchievementTriggerBodyCosmetics —
/api/admin/cosmetics*/cosmeticstype asc./cosmeticsCreateCosmeticBody/cosmetics/{id}typeandslugimmutable./cosmetics/{id}user_cosmetics,role_cosmetics.Cosmetic types:
avatar_frame,banner,name_color,title. Asset upload happens client-side directly to Supabase Storage (cosmetic-assetsbucket); API stores the URL only.Beta allowlist —
/api/admin/allowlist*/allowlistnewsletter_emailsrows (id, email, created_at, approved_at), newest first./allowlist/approveAllowlistEmailBodyapproved_at = now()./allowlist/revokeAllowlistEmailBodyapproved_at = NULL(preserves signup row). 404 if missing.Pre-approved emails are auto-promoted to
users.is_approved=trueon their next OAuth callback (routes/auth.py:308-345). No mid-session promotion.Gaps blocking the UI
Admin.tsx's tab union but has no backend — needs new endpoints (user growth, session counts, document volume, etc.) before it's functional.user_roles.granted_byexists but the route doesn't auto-fill it from the session./users,/roles,/achievements,/cosmetics,/allowlistreturn everything every time./users— can't find by name/email/role server-side.PATCH /users/{id}/approveis one-way.adminrole row.role_cosmetics,achievement_cosmetics) but no admin endpoints.Admin.tsxtypes isn't returned by/users./usersdecrypts every user's name + email per request — fine at current scale, hot CPU path past hundreds of rows; pagination amplifies the urgency.Proposed tab structure
granted_by(auto-fill from session).achievement_cosmeticslinking.role_cosmeticslinking UI.Decisions needed
PATCH /users/{id}/approve? (Likely yes — manual mistake recovery.)admin_audit_logtable with(actor_id, action, target_type, target_id, payload, created_at), or per-table audit columns?adminrole row?Related work
/userspagination/search would be the natural next backend chunk.