feat: add Roles tab with system role protection and principal display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-23 18:32:07 +01:00
parent 83caf4be5b
commit 907bcd5017
2 changed files with 308 additions and 4 deletions

View File

@@ -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 = () => <div>Users tab coming soon</div>;
const GroupsTab = () => <div>Groups tab coming soon</div>;
const RolesTab = () => <div>Roles tab coming soon</div>;
export default function RbacPage() {
const { data: stats } = useRbacStats();

View File

@@ -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<string | null>(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 (
<div className={styles.splitPane}>
{/* Left pane — list */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowCreate((v) => !v)}
>
+ Add Role
</Button>
</div>
{showCreate && (
<div className={styles.createForm}>
<Input
placeholder="Role name (e.g. EDITOR)"
value={newName}
onChange={(e) => setNewName(e.target.value.toUpperCase())}
style={{ marginBottom: 8 }}
/>
<Input
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreate(false);
setNewName('');
setNewDescription('');
}}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
loading={createRole.isPending}
disabled={!newName.trim()}
onClick={handleCreate}
>
Create
</Button>
</div>
</div>
)}
{isLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filtered.map((role) => {
const assignmentCount =
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
return (
<div
key={role.id}
className={
styles.entityItem +
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
}
role="option"
aria-selected={selectedId === role.id}
onClick={() => setSelectedId(role.id)}
>
<Avatar name={role.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{role.name}
{role.system && <Badge label="system" variant="outlined" />}
</div>
<div className={styles.entityMeta}>
{role.description || '—'} · {assignmentCount} assignment
{assignmentCount !== 1 ? 's' : ''}
</div>
{((role.assignedGroups?.length ?? 0) > 0 ||
(role.directUsers?.length ?? 0) > 0) && (
<div className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right pane — detail */}
<div className={styles.detailPane}>
{!selectedId ? (
<div className={styles.emptyDetail}>Select a role to view details</div>
) : detailLoading || !detail ? (
<Spinner />
) : (
<RoleDetailPanel
role={detail}
onDeleteRequest={() => setConfirmDelete(true)}
/>
)}
</div>
{detail && (
<ConfirmDialog
open={confirmDelete}
onClose={() => 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}
/>
)}
</div>
);
}
// ── 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 (
<div>
{/* Header */}
<div className={styles.detailHeader}>
<Avatar name={role.name} size="md" />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
{role.description && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{role.description}
</div>
)}
</div>
<Button
variant="danger"
size="sm"
disabled={role.system}
onClick={onDeleteRequest}
>
Delete
</Button>
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{role.id}</MonoText>
<span className={styles.metaLabel}>Scope</span>
<span>{role.scope || '—'}</span>
<span className={styles.metaLabel}>Type</span>
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
</div>
{/* Assigned to groups */}
<div className={styles.sectionTitle}>Assigned to groups</div>
<div className={styles.sectionTags}>
{(role.assignedGroups ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))
)}
</div>
{/* Assigned to users (direct) */}
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
<div className={styles.sectionTags}>
{(role.directUsers ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))
)}
</div>
{/* Effective principals */}
<div className={styles.sectionTitle}>Effective principals</div>
<div className={styles.sectionTags}>
{(role.effectivePrincipals ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.effectivePrincipals ?? []).map((u) => {
const isDirect = directUserIds.has(u.userId);
return isDirect ? (
<Badge key={u.userId} label={u.displayName} variant="filled" />
) : (
<Badge
key={u.userId}
label={`${u.displayName}`}
variant="dashed"
/>
);
})
)}
</div>
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
<div className={styles.inheritedNote}>
Dashed entries inherit this role through group membership
</div>
)}
</div>
);
}