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>
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import {
|
|
Avatar,
|
|
Badge,
|
|
Button,
|
|
Input,
|
|
Select,
|
|
MonoText,
|
|
SectionHeader,
|
|
Tag,
|
|
InlineEdit,
|
|
MultiSelect,
|
|
ConfirmDialog,
|
|
AlertDialog,
|
|
SplitPane,
|
|
EntityList,
|
|
Spinner,
|
|
useToast,
|
|
} from '@cameleer/design-system';
|
|
import {
|
|
useGroups,
|
|
useGroup,
|
|
useCreateGroup,
|
|
useUpdateGroup,
|
|
useDeleteGroup,
|
|
useAssignRoleToGroup,
|
|
useRemoveRoleFromGroup,
|
|
useAddUserToGroup,
|
|
useRemoveUserFromGroup,
|
|
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 { toast } = useToast();
|
|
const { data: groups = [], isLoading: groupsLoading } = useGroups();
|
|
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();
|
|
const assignRoleToGroup = useAssignRoleToGroup();
|
|
const removeRoleFromGroup = useRemoveRoleFromGroup();
|
|
const addUserToGroup = useAddUserToGroup();
|
|
const removeUserFromGroup = useRemoveUserFromGroup();
|
|
|
|
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
|
|
.filter((g) => g.id !== selectedId)
|
|
.map((g) => ({ value: g.id, label: g.name })),
|
|
];
|
|
|
|
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;
|
|
}
|
|
|
|
async function handleCreate() {
|
|
if (!newName.trim()) return;
|
|
try {
|
|
await createGroup.mutateAsync({
|
|
name: newName.trim(),
|
|
parentGroupId: newParent || null,
|
|
});
|
|
toast({ title: 'Group created', description: newName.trim(), variant: 'success' });
|
|
setCreating(false);
|
|
setNewName('');
|
|
setNewParent('');
|
|
} catch {
|
|
toast({ title: 'Failed to create group', variant: 'error' });
|
|
}
|
|
}
|
|
|
|
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: newNameVal,
|
|
parentGroupId: selectedGroup.parentGroupId,
|
|
});
|
|
toast({ title: 'Group renamed', variant: 'success' });
|
|
} catch {
|
|
toast({ title: 'Failed to rename group', variant: 'error' });
|
|
}
|
|
}
|
|
|
|
async function handleRemoveMember(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' });
|
|
}
|
|
}
|
|
|
|
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' });
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
toast({ title: 'Role removed', variant: 'success' });
|
|
} catch {
|
|
toast({ title: 'Failed to remove role', variant: 'error' });
|
|
}
|
|
}
|
|
|
|
if (groupsLoading) return <Spinner size="md" />;
|
|
|
|
return (
|
|
<>
|
|
<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>
|
|
)}
|
|
|
|
<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={deleteTarget !== null}
|
|
onClose={() => setDeleteTarget(null)}
|
|
onConfirm={handleDelete}
|
|
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
|
|
confirmText={deleteTarget?.name ?? ''}
|
|
loading={deleteGroup.isPending}
|
|
/>
|
|
<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"
|
|
/>
|
|
</>
|
|
);
|
|
}
|