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>
322 lines
9.8 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|