Files
cameleer-server/ui/src/pages/Admin/RolesTab.tsx
hsiegeln 81f85aa82d
Some checks failed
CI / build (push) Successful in 1m18s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 55s
CI / deploy-feature (push) Has been cancelled
CI / deploy (push) Has been cancelled
feat: replace UI with design system example pages wired to real API
Migrate all page components from the @cameleer/design-system v0.0.3
example UI, replacing mock data with real backend API hooks. This brings
richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline,
DateRangePicker, expandable rows) while preserving all existing API
integration, auth, and routing infrastructure.

Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail,
AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles).
Also enhanced LayoutShell CommandPalette with real search data from catalog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:42:16 +01:00

322 lines
9.8 KiB
TypeScript

import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
Input,
MonoText,
SectionHeader,
Tag,
ConfirmDialog,
SplitPane,
EntityList,
Spinner,
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 { toast } = useToast();
const { data: roles, isLoading } = useRoles();
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<RoleDetail | null>(null);
// Create form state
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
// Detail query
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
// Mutations
const createRole = useCreateRole();
const deleteRole = useDeleteRole();
const filtered = useMemo(() => {
const list = roles ?? [];
if (!search) return list;
const q = search.toLowerCase();
return list.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.description.toLowerCase().includes(q),
);
}, [roles, search]);
const duplicateRoleName =
newName.trim() !== '' &&
(roles ?? []).some((r) => r.name === newName.trim().toUpperCase());
function handleCreate() {
if (!newName.trim()) return;
createRole.mutate(
{ name: newName.trim().toUpperCase(), description: newDesc.trim() || undefined },
{
onSuccess: () => {
toast({
title: 'Role created',
description: newName.trim().toUpperCase(),
variant: 'success',
});
setCreating(false);
setNewName('');
setNewDesc('');
},
onError: () => {
toast({ title: 'Failed to create role', variant: 'error' });
},
},
);
}
function handleDelete() {
if (!deleteTarget) return;
deleteRole.mutate(deleteTarget.id, {
onSuccess: () => {
toast({
title: 'Role deleted',
description: deleteTarget.name,
variant: 'warning',
});
if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null);
},
onError: () => {
toast({ title: 'Failed to delete role', variant: 'error' });
setDeleteTarget(null);
},
});
}
function getAssignmentCount(role: RoleDetail): number {
return (
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
);
}
if (isLoading) return <Spinner size="md" />;
return (
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Role name *"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
{duplicateRoleName && (
<span style={{ color: 'var(--error)', fontSize: 11 }}>
Role name already exists
</span>
)}
<Input
placeholder="Description"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => setCreating(false)}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createRole.isPending}
disabled={!newName.trim() || duplicateRoleName}
>
Create
</Button>
</div>
</div>
)}
<EntityList
items={filtered}
renderItem={(role) => (
<>
<Avatar name={role.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{role.name}
{role.system && (
<Badge
label="system"
color="auto"
variant="outlined"
className={styles.providerBadge}
/>
)}
</div>
<div className={styles.entityMeta}>
{role.description || '\u2014'} \u00b7{' '}
{getAssignmentCount(role)} assignments
</div>
<div className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Badge key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
/>
))}
</div>
</div>
</>
)}
getItemId={(role) => role.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search roles..."
onSearch={setSearch}
addLabel="+ Add role"
onAdd={() => setCreating(true)}
emptyMessage="No roles match your search"
/>
</>
}
detail={
selectedId && (detailLoading || !detail) ? (
<Spinner size="md" />
) : detail ? (
<RoleDetailPanel
role={detail}
onDeleteRequest={() => setDeleteTarget(detail)}
/>
) : null
}
emptyMessage="Select a role to view details"
/>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
loading={deleteRole.isPending}
/>
</>
);
}
// ── Detail panel ──────────────────────────────────────────────────────────────
interface RoleDetailPanelProps {
role: RoleDetail;
onDeleteRequest: () => void;
}
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
const directUserIds = new Set(
(role.directUsers ?? []).map((u) => u.userId),
);
const assignedGroups = role.assignedGroups ?? [];
const directUsers = role.directUsers ?? [];
const effectivePrincipals = role.effectivePrincipals ?? [];
return (
<>
<div className={styles.detailHeader}>
<Avatar name={role.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>{role.name}</div>
{role.description && (
<div className={styles.detailEmail}>{role.description}</div>
)}
</div>
{!role.system && (
<Button size="sm" variant="danger" onClick={onDeleteRequest}>
Delete
</Button>
)}
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{role.id}</MonoText>
<span className={styles.metaLabel}>Scope</span>
<span className={styles.metaValue}>{role.scope || '\u2014'}</span>
{role.system && (
<>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>System role (read-only)</span>
</>
)}
</div>
<SectionHeader>Assigned to groups</SectionHeader>
<div className={styles.sectionTags}>
{assignedGroups.map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{assignedGroups.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
<SectionHeader>Assigned to users (direct)</SectionHeader>
<div className={styles.sectionTags}>
{directUsers.map((u) => (
<Tag key={u.userId} label={u.displayName} color="auto" />
))}
{directUsers.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
<SectionHeader>Effective principals</SectionHeader>
<div className={styles.sectionTags}>
{effectivePrincipals.map((u) => {
const isDirect = directUserIds.has(u.userId);
return isDirect ? (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
variant="filled"
/>
) : (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
variant="dashed"
/>
);
})}
{effectivePrincipals.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{effectivePrincipals.some((u) => !directUserIds.has(u.userId)) && (
<span className={styles.inheritedNote}>
Dashed entries inherit this role through group membership
</span>
)}
</>
);
}