feat: replace UI with design system example pages wired to real API
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

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>
This commit is contained in:
hsiegeln
2026-03-24 16:42:16 +01:00
parent dafd7adb00
commit 81f85aa82d
23 changed files with 4439 additions and 2542 deletions

View File

@@ -1,13 +1,16 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
ConfirmDialog,
Input,
MonoText,
Spinner,
SectionHeader,
Tag,
ConfirmDialog,
SplitPane,
EntityList,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
@@ -20,33 +23,54 @@ 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 [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 [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 { toast } = useToast();
const filtered = (roles ?? []).filter((r) =>
r.name.toLowerCase().includes(search.toLowerCase()),
);
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(), description: newDescription.trim() || undefined },
{ name: newName.trim().toUpperCase(), description: newDesc.trim() || undefined },
{
onSuccess: () => {
toast({ title: 'Role created', variant: 'success' });
setShowCreate(false);
toast({
title: 'Role created',
description: newName.trim().toUpperCase(),
variant: 'success',
});
setCreating(false);
setNewName('');
setNewDescription('');
setNewDesc('');
},
onError: () => {
toast({ title: 'Failed to create role', variant: 'error' });
@@ -56,152 +80,144 @@ export default function RolesTab() {
}
function handleDelete() {
if (!selectedId) return;
deleteRole.mutate(selectedId, {
if (!deleteTarget) return;
deleteRole.mutate(deleteTarget.id, {
onSuccess: () => {
toast({ title: 'Role deleted', variant: 'success' });
setSelectedId(null);
setConfirmDelete(false);
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' });
setConfirmDelete(false);
setDeleteTarget(null);
},
});
}
function getAssignmentCount(role: RoleDetail): number {
return (
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
);
}
if (isLoading) return <Spinner size="md" />;
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>
<>
<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>
)}
{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)}
>
<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" variant="outlined" />}
{role.system && (
<Badge
label="system"
color="auto"
variant="outlined"
className={styles.providerBadge}
/>
)}
</div>
<div className={styles.entityMeta}>
{role.description || ''} · {assignmentCount} assignment
{assignmentCount !== 1 ? 's' : ''}
{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>
{((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>
</>
)}
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"
/>
{/* 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>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
loading={deleteRole.isPending}
/>
</>
);
}
@@ -213,93 +229,93 @@ interface RoleDetailPanelProps {
}
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));
const directUserIds = new Set(
(role.directUsers ?? []).map((u) => u.userId),
);
const assignedGroups = role.assignedGroups ?? [];
const directUsers = role.directUsers ?? [];
const effectivePrincipals = role.effectivePrincipals ?? [];
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>
<Avatar name={role.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>{role.name}</div>
{role.description && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{role.description}
</div>
<div className={styles.detailEmail}>{role.description}</div>
)}
</div>
<Button
variant="danger"
size="sm"
disabled={role.system}
onClick={onDeleteRequest}
>
Delete
</Button>
{!role.system && (
<Button size="sm" variant="danger" 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" />
))
<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>
{/* Assigned to users (direct) */}
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
<SectionHeader>Assigned to groups</SectionHeader>
<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} />
))
{assignedGroups.map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{assignedGroups.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{/* Effective principals */}
<div className={styles.sectionTitle}>Effective principals</div>
<SectionHeader>Assigned to users (direct)</SectionHeader>
<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"
/>
);
})
{directUsers.map((u) => (
<Tag key={u.userId} label={u.displayName} color="auto" />
))}
{directUsers.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
<div className={styles.inheritedNote}>
<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
</div>
</span>
)}
</div>
</>
);
}