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>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user