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,15 +1,20 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
Input,
MonoText,
Tag,
Select,
ConfirmDialog,
Spinner,
MonoText,
SectionHeader,
Tag,
InlineEdit,
MultiSelect,
ConfirmDialog,
AlertDialog,
SplitPane,
EntityList,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
@@ -25,26 +30,31 @@ import {
useUsers,
useRoles,
} from '../../api/queries/admin/rbac';
import type { GroupDetail } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
export default function GroupsTab() {
const [search, setSearch] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupParentId, setNewGroupParentId] = useState<string>('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [addMemberUserId, setAddMemberUserId] = useState<string>('');
const [addRoleId, setAddRoleId] = useState<string>('');
const { toast } = useToast();
const { data: groups = [], isLoading: groupsLoading } = useGroups();
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
const { data: users = [] } = useUsers();
const { data: roles = [] } = useRoles();
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<GroupDetail | null>(null);
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null);
// Create form state
const [newName, setNewName] = useState('');
const [newParent, setNewParent] = useState('');
// Detail query
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedId);
// Mutations
const createGroup = useCreateGroup();
const updateGroup = useUpdateGroup();
const deleteGroup = useDeleteGroup();
@@ -53,350 +63,385 @@ export default function GroupsTab() {
const addUserToGroup = useAddUserToGroup();
const removeUserFromGroup = useRemoveUserFromGroup();
const filteredGroups = groups.filter((g) =>
g.name.toLowerCase().includes(search.toLowerCase())
);
const filtered = useMemo(() => {
if (!search) return groups;
const q = search.toLowerCase();
return groups.filter((g) => g.name.toLowerCase().includes(q));
}, [groups, search]);
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
const parentOptions = [
{ value: '', label: 'Top-level' },
...groups.map((g) => ({ value: g.id, label: g.name })),
...groups
.filter((g) => g.id !== selectedId)
.map((g) => ({ value: g.id, label: g.name })),
];
const parentName = (parentGroupId: string | null) => {
const duplicateGroupName =
newName.trim() !== '' &&
groups.some(
(g) => g.name.toLowerCase() === newName.trim().toLowerCase(),
);
// Derived data for the detail pane
const children = selectedGroup?.childGroups ?? [];
const members = selectedGroup?.members ?? [];
const parentGroup = selectedGroup?.parentGroupId
? groups.find((g) => g.id === selectedGroup.parentGroupId)
: null;
const memberUserIds = new Set(members.map((m) => m.userId));
const assignedRoleIds = new Set(
(selectedGroup?.directRoles ?? []).map((r) => r.id),
);
const availableRoles = roles
.filter((r) => !assignedRoleIds.has(r.id))
.map((r) => ({ value: r.id, label: r.name }));
const availableMembers = users
.filter((u) => !memberUserIds.has(u.userId))
.map((u) => ({ value: u.userId, label: u.displayName }));
function parentName(parentGroupId: string | null): string {
if (!parentGroupId) return 'Top-level';
const parent = groups.find((g) => g.id === parentGroupId);
return parent ? parent.name : parentGroupId;
};
}
const handleCreate = async () => {
const name = newGroupName.trim();
if (!name) return;
async function handleCreate() {
if (!newName.trim()) return;
try {
await createGroup.mutateAsync({
name,
parentGroupId: newGroupParentId || null,
name: newName.trim(),
parentGroupId: newParent || null,
});
toast({ title: 'Group created', variant: 'success' });
setNewGroupName('');
setNewGroupParentId('');
setShowCreate(false);
toast({ title: 'Group created', description: newName.trim(), variant: 'success' });
setCreating(false);
setNewName('');
setNewParent('');
} catch {
toast({ title: 'Failed to create group', variant: 'error' });
}
};
}
const handleRename = async (newName: string) => {
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteGroup.mutateAsync(deleteTarget.id);
toast({
title: 'Group deleted',
description: deleteTarget.name,
variant: 'warning',
});
if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null);
} catch {
toast({ title: 'Failed to delete group', variant: 'error' });
setDeleteTarget(null);
}
}
async function handleRename(newNameVal: string) {
if (!selectedGroup) return;
try {
await updateGroup.mutateAsync({
id: selectedGroup.id,
name: newName,
name: newNameVal,
parentGroupId: selectedGroup.parentGroupId,
});
toast({ title: 'Group renamed', variant: 'success' });
} catch {
toast({ title: 'Failed to rename group', variant: 'error' });
}
};
}
const handleDelete = async () => {
async function handleRemoveMember(userId: string) {
if (!selectedGroup) return;
try {
await deleteGroup.mutateAsync(selectedGroup.id);
toast({ title: 'Group deleted', variant: 'success' });
setSelectedGroupId(null);
setDeleteOpen(false);
} catch {
toast({ title: 'Failed to delete group', variant: 'error' });
}
};
const handleAddMember = async () => {
if (!selectedGroup || !addMemberUserId) return;
try {
await addUserToGroup.mutateAsync({
userId: addMemberUserId,
await removeUserFromGroup.mutateAsync({
userId,
groupId: selectedGroup.id,
});
toast({ title: 'Member added', variant: 'success' });
setAddMemberUserId('');
} catch {
toast({ title: 'Failed to add member', variant: 'error' });
}
};
const handleRemoveMember = async (userId: string) => {
if (!selectedGroup) return;
try {
await removeUserFromGroup.mutateAsync({ userId, groupId: selectedGroup.id });
toast({ title: 'Member removed', variant: 'success' });
} catch {
toast({ title: 'Failed to remove member', variant: 'error' });
}
};
}
const handleAddRole = async () => {
if (!selectedGroup || !addRoleId) return;
try {
await assignRoleToGroup.mutateAsync({
groupId: selectedGroup.id,
roleId: addRoleId,
});
toast({ title: 'Role assigned', variant: 'success' });
setAddRoleId('');
} catch {
toast({ title: 'Failed to assign role', variant: 'error' });
async function handleAddMembers(userIds: string[]) {
if (!selectedGroup) return;
for (const userId of userIds) {
try {
await addUserToGroup.mutateAsync({
userId,
groupId: selectedGroup.id,
});
toast({ title: 'Member added', variant: 'success' });
} catch {
toast({ title: 'Failed to add member', variant: 'error' });
}
}
};
}
const handleRemoveRole = async (roleId: string) => {
async function handleAddRoles(roleIds: string[]) {
if (!selectedGroup) return;
for (const roleId of roleIds) {
try {
await assignRoleToGroup.mutateAsync({
groupId: selectedGroup.id,
roleId,
});
toast({ title: 'Role assigned', variant: 'success' });
} catch {
toast({ title: 'Failed to assign role', variant: 'error' });
}
}
}
async function handleRemoveRole(roleId: string) {
if (!selectedGroup) return;
try {
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
await removeRoleFromGroup.mutateAsync({
groupId: selectedGroup.id,
roleId,
});
toast({ title: 'Role removed', variant: 'success' });
} catch {
toast({ title: 'Failed to remove role', variant: 'error' });
}
};
}
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
// Build sets for quick lookup of already-assigned items
const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId));
const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id));
const availableUsers = users.filter((u) => !memberUserIds.has(u.userId));
const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id));
if (groupsLoading) return <Spinner size="md" />;
return (
<div className={styles.splitPane}>
{/* Left pane */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search groups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
/>
<Button
size="sm"
variant="secondary"
onClick={() => setShowCreate((v) => !v)}
>
+ Add Group
</Button>
</div>
{showCreate && (
<div className={styles.createForm}>
<Input
placeholder="Group name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<Select
options={parentOptions}
value={newGroupParentId}
onChange={(e) => setNewGroupParentId(e.target.value)}
/>
</div>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowCreate(false);
setNewGroupName('');
setNewGroupParentId('');
}}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
loading={createGroup.isPending}
onClick={handleCreate}
disabled={!newGroupName.trim()}
>
Create
</Button>
</div>
</div>
)}
{groupsLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filteredGroups.map((group) => {
const isSelected = group.id === selectedGroupId;
return (
<div
key={group.id}
role="option"
aria-selected={isSelected}
className={
styles.entityItem +
(isSelected ? ' ' + styles.entityItemSelected : '')
}
onClick={() => setSelectedGroupId(group.id)}
>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{group.parentGroupId
? `Child of ${parentName(group.parentGroupId)}`
: 'Top-level'}
</div>
</div>
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Group name *"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
{duplicateGroupName && (
<span style={{ color: 'var(--error)', fontSize: 11 }}>
Group name already exists
</span>
)}
<Select
options={parentOptions}
value={newParent}
onChange={(e) => setNewParent(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={createGroup.isPending}
disabled={!newName.trim() || duplicateGroupName}
>
Create
</Button>
</div>
);
})}
</div>
)}
</div>
{/* Right pane */}
<div className={styles.detailPane}>
{!selectedGroupId ? (
<div className={styles.emptyDetail}>Select a group to view details</div>
) : detailLoading ? (
<Spinner />
) : selectedGroup ? (
<div>
{/* Header */}
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="md" />
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
disabled={isBuiltinAdmins}
/>
<div className={styles.entityMeta}>
{selectedGroup.parentGroupId
? `Child of ${parentName(selectedGroup.parentGroupId)}`
: 'Top-level'}
</div>
</div>
<Button
variant="danger"
size="sm"
disabled={isBuiltinAdmins}
onClick={() => setDeleteOpen(true)}
>
Delete
</Button>
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>Group ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
<span className={styles.metaLabel}>Parent</span>
<span>{parentName(selectedGroup.parentGroupId)}</span>
</div>
{/* Members */}
<div className={styles.sectionTitle}>Members</div>
<div className={styles.sectionTags}>
{(selectedGroup.members ?? []).map((member) => (
<Tag
key={member.userId}
label={member.displayName}
onRemove={() => handleRemoveMember(member.userId)}
/>
))}
{(selectedGroup.members ?? []).length === 0 && (
<span className={styles.inheritedNote}>No members</span>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Add member...' },
...availableUsers.map((u) => ({
value: u.userId,
label: u.displayName,
})),
]}
value={addMemberUserId}
onChange={(e) => setAddMemberUserId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddMember}
disabled={!addMemberUserId || addUserToGroup.isPending}
>
Add
</Button>
</div>
{/* Assigned roles */}
<div className={styles.sectionTitle}>Assigned Roles</div>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((role) => (
<Badge
key={role.id}
label={role.name}
variant="outlined"
onRemove={() => handleRemoveRole(role.id)}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>No roles assigned</span>
)}
</div>
{(selectedGroup.effectiveRoles ?? []).length >
(selectedGroup.directRoles ?? []).length && (
<div className={styles.inheritedNote}>
+
{(selectedGroup.effectiveRoles ?? []).length -
(selectedGroup.directRoles ?? []).length}{' '}
inherited role(s)
</div>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Assign role...' },
...availableRoles.map((r) => ({
value: r.id,
label: r.name,
})),
]}
value={addRoleId}
onChange={(e) => setAddRoleId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddRole}
disabled={!addRoleId || assignRoleToGroup.isPending}
>
Add
</Button>
</div>
</div>
) : null}
</div>
{/* Delete confirmation */}
<EntityList
items={filtered}
renderItem={(group) => {
const groupChildren = groups.filter(
(g) => g.parentGroupId === group.id,
);
const groupParent = group.parentGroupId
? groups.find((g) => g.id === group.parentGroupId)
: null;
return (
<>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{groupParent
? `Child of ${groupParent.name}`
: 'Top-level'}
{' \u00b7 '}
{groupChildren.length} children
{' \u00b7 '}
{(group.members ?? []).length} members
</div>
<div className={styles.entityTags}>
{(group.directRoles ?? []).map((r) => (
<Badge key={r.id} label={r.name} color="warning" />
))}
</div>
</div>
</>
);
}}
getItemId={(group) => group.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search groups..."
onSearch={setSearch}
addLabel="+ Add group"
onAdd={() => setCreating(true)}
emptyMessage="No groups match your search"
/>
</>
}
detail={
selectedId && detailLoading ? (
<Spinner size="md" />
) : selectedGroup ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
{isBuiltinAdmins ? (
selectedGroup.name
) : (
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
/>
)}
</div>
<div className={styles.detailEmail}>
{parentGroup
? `${parentGroup.name} > ${selectedGroup.name}`
: 'Top-level group'}
{isBuiltinAdmins && ' (built-in)'}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selectedGroup)}
disabled={isBuiltinAdmins}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
</div>
{parentGroup && (
<>
<SectionHeader>Member of</SectionHeader>
<div className={styles.sectionTags}>
<Tag label={parentGroup.name} color="auto" />
</div>
</>
)}
<SectionHeader>Members (direct)</SectionHeader>
<div className={styles.sectionTags}>
{members.map((u) => (
<Tag
key={u.userId}
label={u.displayName}
color="auto"
onRemove={() => handleRemoveMember(u.userId)}
/>
))}
{members.length === 0 && (
<span className={styles.inheritedNote}>(no members)</span>
)}
<MultiSelect
options={availableMembers}
value={[]}
onChange={handleAddMembers}
placeholder="+ Add"
/>
</div>
{children.length > 0 && (
<span className={styles.inheritedNote}>
+ all members of {children.map((c) => c.name).join(', ')}
</span>
)}
<SectionHeader>Child groups</SectionHeader>
<div className={styles.sectionTags}>
{children.map((c) => (
<Tag key={c.id} label={c.name} color="success" />
))}
{children.length === 0 && (
<span className={styles.inheritedNote}>
(no child groups)
</span>
)}
</div>
<SectionHeader>Assigned roles</SectionHeader>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
if (members.length > 0) {
setRemoveRoleTarget(r.id);
} else {
handleRemoveRole(r.id);
}
}}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={handleAddRoles}
placeholder="+ Add"
/>
</div>
</>
) : null
}
emptyMessage="Select a group to view details"
/>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title="Delete Group"
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
confirmText="DELETE"
variant="danger"
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
loading={deleteGroup.isPending}
/>
</div>
<AlertDialog
open={removeRoleTarget !== null}
onClose={() => setRemoveRoleTarget(null)}
onConfirm={() => {
if (removeRoleTarget && selectedGroup) {
handleRemoveRole(removeRoleTarget);
}
setRemoveRoleTarget(null);
}}
title="Remove role from group"
description={`Removing this role will affect ${members.length} member(s) who inherit it. Continue?`}
confirmLabel="Remove"
variant="warning"
/>
</>
);
}