import { useState } from 'react'; import { Link, useParams, useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { authGetRole, authListPermissions, authUpdateRole, authDeleteRole, authAddRolePermission, authRemoveRolePermission, type AuthPermission, } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; import ConfirmDialog from '../../components/ConfirmDialog'; import { STALE_TIME } from '../../api/queryConstants'; // ============================================================================= // Bundle 1 Phase 10 — RoleDetailPage. // // Shows a single role plus its current permission grants. Surfaces: // // - Edit role modal (auth.role.edit) // - Delete role action (auth.role.delete) — disabled when actors hold // the role (server returns 409; UX surfaces via ErrorState). // - Add permission picker (auth.role.edit) populated from the // canonical catalogue. // - Remove permission action per row (auth.role.edit). // // Each action is HIDDEN when the caller lacks the permission. The // server still 403s an end-run; client-side hide is UX, not security. // ============================================================================= // Audit 2026-05-10 LOW-11 — default role ids the server seeds via // migrations 000029 + 000039. The backend rejects DELETE on any of // these with HTTP 409; this set mirrors the seed so the GUI hides // the Delete button on system roles. Keep in sync with the migrations. const DEFAULT_ROLE_IDS = new Set([ 'r-admin', 'r-operator', 'r-viewer', 'r-agent', 'r-mcp', 'r-cli', 'r-auditor', ]); export default function RoleDetailPage() { const { id = '' } = useParams<{ id: string }>(); const me = useAuthMe(); const qc = useQueryClient(); const navigate = useNavigate(); const detailQuery = useQuery({ queryKey: ['auth', 'role', id], queryFn: () => authGetRole(id), enabled: Boolean(id), staleTime: STALE_TIME.REAL_TIME, // operator editing — fresh data }); const permsCatalogue = useQuery({ queryKey: ['auth', 'permissions'], queryFn: authListPermissions, staleTime: STALE_TIME.CONSTANT, // catalogue — effectively immutable }); const [editOpen, setEditOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [actionError, setActionError] = useState(null); // UX-H2 closure — replace window.confirm with ConfirmDialog. const [confirmDelete, setConfirmDelete] = useState(false); const canEdit = me.hasPerm('auth.role.edit') || me.isAdmin(); const canDelete = me.hasPerm('auth.role.delete') || me.isAdmin(); if (detailQuery.isLoading) return ; if (detailQuery.error || !detailQuery.data) return (
qc.invalidateQueries({ queryKey: ['auth', 'role', id] })} />
); const { role, permissions } = detailQuery.data; const handleDelete = () => { setConfirmDelete(true); }; const performDelete = async () => { setConfirmDelete(false); setSubmitting(true); setActionError(null); try { await authDeleteRole(role.id); toast.success(`Role ${role.name} deleted`); navigate('/auth/roles'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setActionError(msg); toast.error(`Delete failed: ${msg}`); } finally { setSubmitting(false); } }; // Audit 2026-05-10 MED-8 — extended permission grant body with // scope_type + scope_id. The select dropdown drives `perm`; scope // inputs are read from inline state hoisted from the form below. const handleAddPermission = async (perm: string, scope?: { scope_type?: string; scope_id?: string }) => { setSubmitting(true); setActionError(null); try { await authAddRolePermission(role.id, { permission: perm, ...(scope ?? {}) }); qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }; const handleRemovePermission = async (perm: string) => { setSubmitting(true); setActionError(null); try { await authRemoveRolePermission(role.id, perm); qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }; const grantedPermNames = new Set(permissions.map(p => p.permission_id)); const availablePerms = (permsCatalogue.data ?? []).filter(p => !grantedPermNames.has(p.name)); return (
Back {canEdit && ( )} {canDelete && ( // Audit 2026-05-10 LOW-11 closure — hide Delete on // default roles. The backend already rejects deletion of // default roles (DELETE returns 409 with // 'cannot delete default role'); this is pure UX so // operators don't click a button that's destined to fail. DEFAULT_ROLE_IDS.has(role.id) ? ( System role (cannot be deleted) ) : ( ) )}
} /> {actionError && (
{actionError}
)}
Description
{role.description || (none)}
Permissions ({permissions.length})
Permissions granted at the listed scope. Global wins over more-specific scopes.
{canEdit && availablePerms.length > 0 && ( p.name)} onSubmit={(perm, scope) => void handleAddPermission(perm, scope)} /> )}
{permissions.length === 0 ? (
No permissions granted. {canEdit ? 'Use the picker above to add some.' : ''}
) : ( {canEdit && } {permissions.map(p => { const permName = lookupPermNameByID(permsCatalogue.data ?? [], p.permission_id); return ( {canEdit && ( )} ); })}
Permission Scope
{permName} {p.scope_type} {p.scope_id ? ` (${p.scope_id})` : ''}
)}
{editOpen && ( setEditOpen(false)} onSuccess={() => { setEditOpen(false); qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] }); qc.invalidateQueries({ queryKey: ['auth', 'roles'] }); }} /> )} setConfirmDelete(false)} /> ); } function lookupPermNameByID(catalogue: AuthPermission[], id: string): string { // The role-permissions response uses permission_id which the server // populates as the canonical permission NAME (the schema treats // permission name as the row id surrogate). Belt-and-braces // fallback: if the catalogue knows the id, return its display name. const m = catalogue.find(p => p.id === id || p.name === id); return m?.name ?? id; } interface EditModalProps { roleId: string; initialName: string; initialDescription: string; onClose: () => void; onSuccess: () => void; } function EditRoleModal({ roleId, initialName, initialDescription, onClose, onSuccess }: EditModalProps) { const [name, setName] = useState(initialName); const [description, setDescription] = useState(initialDescription); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const dirty = name !== initialName || description !== initialDescription; const handleClose = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; onClose(); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitting(true); setError(null); try { await authUpdateRole(roleId, { name: name.trim(), description: description.trim() }); onSuccess(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }; return (
e.stopPropagation()} data-testid="edit-role-modal" >

Edit role

{error && (
{error}
)}
setName(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm" required data-testid="edit-role-name" />