From 907bcd5017c096a3433ee0f69bd107186f178f75 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:32:07 +0100 Subject: [PATCH] feat: add Roles tab with system role protection and principal display Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/Admin/RbacPage.tsx | 7 +- ui/src/pages/Admin/RolesTab.tsx | 305 ++++++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 ui/src/pages/Admin/RolesTab.tsx diff --git a/ui/src/pages/Admin/RbacPage.tsx b/ui/src/pages/Admin/RbacPage.tsx index b00f28a5..f5e7a147 100644 --- a/ui/src/pages/Admin/RbacPage.tsx +++ b/ui/src/pages/Admin/RbacPage.tsx @@ -2,12 +2,11 @@ import { useState } from 'react'; import { StatCard, Tabs } from '@cameleer/design-system'; import { useRbacStats } from '../../api/queries/admin/rbac'; import styles from './UserManagement.module.css'; +import GroupsTab from './GroupsTab'; +import RolesTab from './RolesTab'; -// Lazy imports for tab components (will be created in tasks 17-19) -// For now, use placeholder components so the page compiles +// Placeholder component for Users tab (task 17) const UsersTab = () =>
Users tab — coming soon
; -const GroupsTab = () =>
Groups tab — coming soon
; -const RolesTab = () =>
Roles tab — coming soon
; export default function RbacPage() { const { data: stats } = useRbacStats(); diff --git a/ui/src/pages/Admin/RolesTab.tsx b/ui/src/pages/Admin/RolesTab.tsx new file mode 100644 index 00000000..9202c214 --- /dev/null +++ b/ui/src/pages/Admin/RolesTab.tsx @@ -0,0 +1,305 @@ +import { useState } from 'react'; +import { + Avatar, + Badge, + Button, + ConfirmDialog, + Input, + MonoText, + Spinner, + Tag, + useToast, +} from '@cameleer/design-system'; +import { + useRoles, + useRole, + useCreateRole, + useDeleteRole, +} from '../../api/queries/admin/rbac'; +import type { RoleDetail } from '../../api/queries/admin/rbac'; +import styles from './UserManagement.module.css'; + +export default function RolesTab() { + const { data: roles, isLoading } = useRoles(); + const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(false); + + const { data: detail, isLoading: detailLoading } = useRole(selectedId); + const createRole = useCreateRole(); + const deleteRole = useDeleteRole(); + const { toast } = useToast(); + + const filtered = (roles ?? []).filter((r) => + r.name.toLowerCase().includes(search.toLowerCase()), + ); + + function handleCreate() { + if (!newName.trim()) return; + createRole.mutate( + { name: newName.trim(), description: newDescription.trim() || undefined }, + { + onSuccess: () => { + toast({ title: 'Role created', variant: 'success' }); + setShowCreate(false); + setNewName(''); + setNewDescription(''); + }, + onError: () => { + toast({ title: 'Failed to create role', variant: 'error' }); + }, + }, + ); + } + + function handleDelete() { + if (!selectedId) return; + deleteRole.mutate(selectedId, { + onSuccess: () => { + toast({ title: 'Role deleted', variant: 'success' }); + setSelectedId(null); + setConfirmDelete(false); + }, + onError: () => { + toast({ title: 'Failed to delete role', variant: 'error' }); + setConfirmDelete(false); + }, + }); + } + + return ( +
+ {/* Left pane — list */} +
+
+ setSearch(e.target.value)} + /> + +
+ + {showCreate && ( +
+ setNewName(e.target.value.toUpperCase())} + style={{ marginBottom: 8 }} + /> + setNewDescription(e.target.value)} + /> +
+ + +
+
+ )} + + {isLoading ? ( + + ) : ( +
+ {filtered.map((role) => { + const assignmentCount = + (role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0); + return ( +
setSelectedId(role.id)} + > + +
+
+ {role.name} + {role.system && } +
+
+ {role.description || '—'} · {assignmentCount} assignment + {assignmentCount !== 1 ? 's' : ''} +
+ {((role.assignedGroups?.length ?? 0) > 0 || + (role.directUsers?.length ?? 0) > 0) && ( +
+ {(role.assignedGroups ?? []).map((g) => ( + + ))} + {(role.directUsers ?? []).map((u) => ( + + ))} +
+ )} +
+
+ ); + })} +
+ )} +
+ + {/* Right pane — detail */} +
+ {!selectedId ? ( +
Select a role to view details
+ ) : detailLoading || !detail ? ( + + ) : ( + setConfirmDelete(true)} + /> + )} +
+ + {detail && ( + setConfirmDelete(false)} + onConfirm={handleDelete} + title="Delete role" + message={`Delete role "${detail.name}"? This cannot be undone.`} + confirmText={detail.name} + confirmLabel="Delete" + variant="danger" + loading={deleteRole.isPending} + /> + )} +
+ ); +} + +// ── Detail panel ────────────────────────────────────────────────────────────── + +interface RoleDetailPanelProps { + role: RoleDetail; + onDeleteRequest: () => void; +} + +function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) { + // Build a set of directly-assigned user IDs for distinguishing inherited principals + const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId)); + + return ( +
+ {/* Header */} +
+ +
+
{role.name}
+ {role.description && ( +
+ {role.description} +
+ )} +
+ +
+ + {/* Metadata */} +
+ ID + {role.id} + + Scope + {role.scope || '—'} + + Type + {role.system ? 'System role (read-only)' : 'Custom role'} +
+ + {/* Assigned to groups */} +
Assigned to groups
+
+ {(role.assignedGroups ?? []).length === 0 ? ( + None + ) : ( + (role.assignedGroups ?? []).map((g) => ( + + )) + )} +
+ + {/* Assigned to users (direct) */} +
Assigned to users (direct)
+
+ {(role.directUsers ?? []).length === 0 ? ( + None + ) : ( + (role.directUsers ?? []).map((u) => ( + + )) + )} +
+ + {/* Effective principals */} +
Effective principals
+
+ {(role.effectivePrincipals ?? []).length === 0 ? ( + None + ) : ( + (role.effectivePrincipals ?? []).map((u) => { + const isDirect = directUserIds.has(u.userId); + return isDirect ? ( + + ) : ( + + ); + }) + )} +
+ {(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && ( +
+ Dashed entries inherit this role through group membership +
+ )} +
+ ); +}