From 4c45b5afa7bd0b2ee8cd4d67bea17b1f0cb22f17 Mon Sep 17 00:00:00 2001 From: aarbit Date: Tue, 8 Sep 2026 16:01:01 -0500 Subject: [PATCH] Add Roles & Admin/Super-Admin interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per organized-ideas.md §6: role assignment, account ban/unban/delete, and direct Admin/Super-Admin CRUD of public catalog entries outside the submission workflow. Backend: - list_users_for_admin(): Super-Admin-gated SECURITY DEFINER function joining profiles + auth.users (username, email, role, banned_until) — auth.users isn't exposed through PostgREST, so this is the only way to list accounts at all. - New Edge Function admin-user-action (ban/unban/delete), using @supabase/server's `auth: 'user'` mode to verify the caller's JWT, then Supabase Auth's Admin API for the actual mutation. This is deliberately an Edge Function rather than a Postgres function like everything else in this codebase: touching auth.users needs the Admin API, the stable documented interface, not a direct write to a schema Supabase manages internally. Self-action guard; verify_jwt = true at the gateway on top of the function's own JWT verification. - 5 new pgTAP tests (43/43 total) for list_users_for_admin (Super-Admin-only, even regular Admins get 42501). - CatalogRepository gains admin* methods (direct edit of a public port/cable/ device entry, plus adminUnpublish which flips is_public rather than deleting) — the update methods were already ownership-agnostic (RLS's is_admin() clause is what actually permits it), so these are thin aliases, not duplicated logic. Frontend: - authStore/AdminUserRepository: minimal role plumbing, shared UserRole type. - adminUserStore + AdminUsersModal: list/role-dropdown/ban/unban/delete, gated to Super Admin only via a new "Manage Users" TopBar button. - PortTypeManager/CableTypeManager/DevicePalette: built-in entries now show direct "Edit"/"Unpublish" for Admins (regular Admin included, per §6's capability table — not Super-Admin-exclusive) instead of "Suggest edit"; unpublish reuses the review-queue's impact-check RPC before confirming. - DeviceTemplateEditor gains an `adminMode` save path alongside its existing submissionMode/resubmitId ones. Verified: tsc -b and oxlint clean; supabase db reset + 43/43 pgTAP tests pass; confirmed both new privileged endpoints (the SQL function and the Edge Function) actually work through the real REST API via live curl calls — signup, email confirm, role promotion, ban/unban/delete round trips, self-action guard, non-super-admin rejection, and verify_jwt=true compatibility all exercised directly, not just asserted. --- src/components/admin/AdminUsersModal.tsx | 137 ++++++++++++++++++ src/components/layout/TopBar.tsx | 12 ++ src/components/palette/CableTypeManager.tsx | 88 +++++++++-- src/components/palette/DevicePalette.tsx | 41 +++++- .../palette/DeviceTemplateEditor.tsx | 32 +++- src/components/palette/PortTypeManager.tsx | 83 +++++++++-- src/data/AdminUserRepository.ts | 32 ++++ src/data/CatalogRepository.ts | 25 +++- src/data/SupabaseAdminSubmissionRepository.ts | 10 +- src/data/SupabaseAdminUserRepository.ts | 73 ++++++++++ src/data/SupabaseCatalogRepository.ts | 27 +++- src/data/catalogRowMapping.ts | 11 ++ src/state/adminUserStore.ts | 50 +++++++ src/state/authStore.ts | 10 +- src/state/catalogStore.ts | 38 +++++ supabase/config.toml | 15 ++ supabase/functions/admin-user-action/.npmrc | 3 + .../functions/admin-user-action/deno.json | 6 + supabase/functions/admin-user-action/index.ts | 89 ++++++++++++ .../20260912000000_admin_user_management.sql | 30 ++++ supabase/tests/rls.sql | 43 +++++- 21 files changed, 804 insertions(+), 51 deletions(-) create mode 100644 src/components/admin/AdminUsersModal.tsx create mode 100644 src/data/AdminUserRepository.ts create mode 100644 src/data/SupabaseAdminUserRepository.ts create mode 100644 src/state/adminUserStore.ts create mode 100644 supabase/functions/admin-user-action/.npmrc create mode 100644 supabase/functions/admin-user-action/deno.json create mode 100644 supabase/functions/admin-user-action/index.ts create mode 100644 supabase/migrations/20260912000000_admin_user_management.sql diff --git a/src/components/admin/AdminUsersModal.tsx b/src/components/admin/AdminUsersModal.tsx new file mode 100644 index 0000000..1d4fbad --- /dev/null +++ b/src/components/admin/AdminUsersModal.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react' +import type { AdminUserSummary, UserRole } from '../../data/AdminUserRepository' +import { useAdminUserStore } from '../../state/adminUserStore' +import { useAuthStore } from '../../state/authStore' +import Modal from '../common/Modal' + +const ROLE_OPTIONS: UserRole[] = ['regular', 'admin', 'super_admin'] + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { dateStyle: 'medium' }) +} + +function isBanned(user: AdminUserSummary): boolean { + return !!user.bannedUntil && new Date(user.bannedUntil).getTime() > Date.now() +} + +export default function AdminUsersModal({ onClose }: { onClose: () => void }) { + const currentUserId = useAuthStore((s) => s.userId) + const users = useAdminUserStore((s) => s.users) + const loadUsers = useAdminUserStore((s) => s.loadUsers) + const updateRole = useAdminUserStore((s) => s.updateRole) + const banUser = useAdminUserStore((s) => s.banUser) + const unbanUser = useAdminUserStore((s) => s.unbanUser) + const deleteUser = useAdminUserStore((s) => s.deleteUser) + const [busyId, setBusyId] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + loadUsers() + }, [loadUsers]) + + const runAction = async (userId: string, action: () => Promise) => { + setError(null) + setBusyId(userId) + try { + await action() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setBusyId(null) + } + } + + const handleRoleChange = (user: AdminUserSummary, role: UserRole) => { + if (user.id === currentUserId && role !== 'super_admin') { + const confirmed = window.confirm( + `Change your own role to "${role}"? You'll lose Super Admin access immediately — another Super Admin (or direct DB access) would be needed to undo this.`, + ) + if (!confirmed) return + } + runAction(user.id, () => updateRole(user.id, role)) + } + + const handleBanToggle = (user: AdminUserSummary) => { + const banned = isBanned(user) + const confirmed = window.confirm( + banned ? `Unban "${user.username}"? They'll be able to sign in again.` : `Ban "${user.username}"? This blocks sign-in but keeps their data intact — reversible.`, + ) + if (!confirmed) return + runAction(user.id, () => (banned ? unbanUser(user.id) : banUser(user.id))) + } + + const handleDelete = (user: AdminUserSummary) => { + const confirmed = window.confirm( + `Permanently delete "${user.username}"? This deletes their account AND all their diagrams and private catalog entries. This cannot be undone.`, + ) + if (!confirmed) return + runAction(user.id, () => deleteUser(user.id)) + } + + return ( + + {error &&

{error}

} +
+ {users.map((user) => { + const busy = busyId === user.id + const self = user.id === currentUserId + const banned = isBanned(user) + return ( +
+
+
+ {user.username} + {self && ( + You + )} + {banned && ( + Banned + )} +
+
+ {user.email} · joined {formatDate(user.createdAt)} +
+
+
+ + + +
+
+ ) + })} + {users.length === 0 &&

No users found.

} +
+
+ +
+
+ ) +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index c2d1733..5576233 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -5,6 +5,7 @@ import { useAdminReviewStore } from '../../state/adminReviewStore' import { useAuthStore } from '../../state/authStore' import { useProjectStore } from '../../state/projectStore' import { useSubmissionStore } from '../../state/submissionStore' +import AdminUsersModal from '../admin/AdminUsersModal' import AdminReviewModal from '../submissions/AdminReviewModal' import MySubmissionsModal from '../submissions/MySubmissionsModal' import DiagramManagerModal from './DiagramManagerModal' @@ -37,11 +38,13 @@ export default function TopBar() { const username = useAuthStore((s) => s.username) const role = useAuthStore((s) => s.role) const isAdmin = role === 'admin' || role === 'super_admin' + const isSuperAdmin = role === 'super_admin' const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length) const fileInputRef = useRef(null) const [diagramManagerOpen, setDiagramManagerOpen] = useState(false) const [submissionsOpen, setSubmissionsOpen] = useState(false) const [adminReviewOpen, setAdminReviewOpen] = useState(false) + const [adminUsersOpen, setAdminUsersOpen] = useState(false) const pendingSubmissionCount = useMemo( () => mySubmissions.filter((s) => s.status === 'pending').length, @@ -121,6 +124,14 @@ export default function TopBar() { )} )} + {isSuperAdmin && ( + + )} @@ -142,6 +153,7 @@ export default function TopBar() { {diagramManagerOpen && setDiagramManagerOpen(false)} />} {submissionsOpen && setSubmissionsOpen(false)} />} {adminReviewOpen && setAdminReviewOpen(false)} />} + {adminUsersOpen && setAdminUsersOpen(false)} />} ) } diff --git a/src/components/palette/CableTypeManager.tsx b/src/components/palette/CableTypeManager.tsx index fd3bf84..b39bfc8 100644 --- a/src/components/palette/CableTypeManager.tsx +++ b/src/components/palette/CableTypeManager.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from 'react' import { allCableTypes, allPortTypes } from '../../domain/project' import type { CableType, PortType } from '../../domain/types' import { cableTypeToRow } from '../../data/catalogRowMapping' +import { useAdminReviewStore } from '../../state/adminReviewStore' +import { useAuthStore } from '../../state/authStore' import { useCatalogStore } from '../../state/catalogStore' import { useSubmissionStore } from '../../state/submissionStore' import { resolveCableTypeFields, type CableTypeFormFields } from './cableTypeFormFields' @@ -129,11 +131,17 @@ export default function CableTypesPanel() { const catalog = useCatalogStore((s) => s.catalog) const addCustomCableType = useCatalogStore((s) => s.addCustomCableType) const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType) + const adminUpdateCableType = useCatalogStore((s) => s.adminUpdateCableType) + const adminUnpublish = useCatalogStore((s) => s.adminUnpublish) const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const submitForReview = useSubmissionStore((s) => s.submitForReview) + const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact) + const role = useAuthStore((s) => s.role) + const isAdmin = role === 'admin' || role === 'super_admin' const [creating, setCreating] = useState(false) const [editingId, setEditingId] = useState(null) const [suggestingId, setSuggestingId] = useState(null) + const [adminEditingId, setAdminEditingId] = useState(null) const portTypes = useMemo( () => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)), @@ -185,6 +193,24 @@ export default function CableTypesPanel() { setSuggestingId(null) } + const handleAdminSave = (id: string, fields: CableTypeFormFields) => { + const resolved = resolveCableTypeFields(fields, portTypes) + if (!resolved) return + adminUpdateCableType(id, resolved) + setAdminEditingId(null) + } + + const handleUnpublish = async (ct: CableType) => { + const impact = await getUsageImpact('cable_type', ct.id) + const impactNote = + impact.diagramCount > 0 + ? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.` + : '\n\nNot currently used in any diagram.' + const confirmed = window.confirm(`Unpublish "${ct.name}" from the public catalog?${impactNote}`) + if (!confirmed) return + adminUnpublish('cable_type', ct.id) + } + return (
@@ -263,22 +289,52 @@ export default function CableTypesPanel() { Built-in ({builtInTypes.length})
- {builtInTypes.map((ct) => - suggestingId === ct.id ? ( -
- setSuggestingId(null)} - onSave={(fields) => handleSuggestEdit(ct.id, fields)} - /> -
- ) : ( + {builtInTypes.map((ct) => { + if (isAdmin && adminEditingId === ct.id) { + return ( +
+ setAdminEditingId(null)} + onSave={(fields) => handleAdminSave(ct.id, fields)} + /> +
+ ) + } + if (!isAdmin && suggestingId === ct.id) { + return ( +
+ setSuggestingId(null)} + onSave={(fields) => handleSuggestEdit(ct.id, fields)} + /> +
+ ) + } + return (
{ct.name} · {describeEnds(ct)} - {pendingEntityIds.has(ct.id) ? ( + {isAdmin ? ( +
+ + +
+ ) : pendingEntityIds.has(ct.id) ? ( Edit pending review @@ -291,11 +347,13 @@ export default function CableTypesPanel() { )}
- ), - )} + ) + })}

- Built-in cable types can't be edited directly — "Suggest edit" sends a proposed change to Admins for review. + {isAdmin + ? 'As an Admin, you can edit or unpublish public cable types directly.' + : 'Built-in cable types can\'t be edited directly — "Suggest edit" sends a proposed change to Admins for review.'}

diff --git a/src/components/palette/DevicePalette.tsx b/src/components/palette/DevicePalette.tsx index 8bbc7c8..2755554 100644 --- a/src/components/palette/DevicePalette.tsx +++ b/src/components/palette/DevicePalette.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from 'react' import { deviceTemplateToRow } from '../../data/catalogRowMapping' import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/project' import type { DeviceTemplate } from '../../domain/types' +import { useAdminReviewStore } from '../../state/adminReviewStore' +import { useAuthStore } from '../../state/authStore' import { useCatalogStore } from '../../state/catalogStore' import { useProjectStore } from '../../state/projectStore' import { useSubmissionStore } from '../../state/submissionStore' @@ -10,7 +12,11 @@ import Chevron from '../common/Chevron' import ConnectorLibraryModal from './ConnectorLibraryModal' import DeviceTemplateEditor from './DeviceTemplateEditor' -type EditorTarget = { mode: 'new' } | { mode: 'edit'; template: DeviceTemplate } | { mode: 'suggestEdit'; template: DeviceTemplate } +type EditorTarget = + | { mode: 'new' } + | { mode: 'edit'; template: DeviceTemplate } + | { mode: 'suggestEdit'; template: DeviceTemplate } + | { mode: 'adminEdit'; template: DeviceTemplate } export default function DevicePalette() { const project = useProjectStore((s) => s.project) @@ -20,8 +26,12 @@ export default function DevicePalette() { const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate) const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate) const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate) + const adminUnpublish = useCatalogStore((s) => s.adminUnpublish) const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const submitForReview = useSubmissionStore((s) => s.submitForReview) + const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact) + const role = useAuthStore((s) => s.role) + const isAdmin = role === 'admin' || role === 'super_admin' const [editorTarget, setEditorTarget] = useState(null) const [portTypesOpen, setPortTypesOpen] = useState(false) const [hiddenListOpen, setHiddenListOpen] = useState(false) @@ -99,6 +109,17 @@ export default function DevicePalette() { submitForReview('device_template', template.id, deviceTemplateToRow(template)) } + const handleUnpublish = async (template: DeviceTemplate) => { + const impact = await getUsageImpact('device_template', template.id) + const impactNote = + impact.diagramCount > 0 + ? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.` + : '\n\nNot currently used in any diagram.' + const confirmed = window.confirm(`Unpublish "${template.name}" from the public catalog?${impactNote}`) + if (!confirmed) return + adminUnpublish('device_template', template.id) + } + return (