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>
600 lines
21 KiB
TypeScript
600 lines
21 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import {
|
|
Avatar,
|
|
Badge,
|
|
Button,
|
|
Input,
|
|
MonoText,
|
|
SectionHeader,
|
|
Tag,
|
|
InlineEdit,
|
|
RadioGroup,
|
|
RadioItem,
|
|
InfoCallout,
|
|
MultiSelect,
|
|
ConfirmDialog,
|
|
AlertDialog,
|
|
SplitPane,
|
|
EntityList,
|
|
Spinner,
|
|
useToast,
|
|
} from '@cameleer/design-system';
|
|
import {
|
|
useUsers,
|
|
useCreateUser,
|
|
useUpdateUser,
|
|
useDeleteUser,
|
|
useAssignRoleToUser,
|
|
useRemoveRoleFromUser,
|
|
useAddUserToGroup,
|
|
useRemoveUserFromGroup,
|
|
useSetPassword,
|
|
useGroups,
|
|
useRoles,
|
|
} from '../../api/queries/admin/rbac';
|
|
import type { UserDetail } from '../../api/queries/admin/rbac';
|
|
import { useAuthStore } from '../../auth/auth-store';
|
|
import styles from './UserManagement.module.css';
|
|
|
|
export default function UsersTab() {
|
|
const { toast } = useToast();
|
|
const { data: users, isLoading } = useUsers();
|
|
const { data: allGroups } = useGroups();
|
|
const { data: allRoles } = useRoles();
|
|
const currentUsername = useAuthStore((s) => s.username);
|
|
|
|
const [search, setSearch] = useState('');
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [creating, setCreating] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<UserDetail | null>(null);
|
|
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null);
|
|
|
|
// Create form state
|
|
const [newUsername, setNewUsername] = useState('');
|
|
const [newDisplay, setNewDisplay] = useState('');
|
|
const [newEmail, setNewEmail] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local');
|
|
|
|
// Password reset state
|
|
const [resettingPassword, setResettingPassword] = useState(false);
|
|
const [newPw, setNewPw] = useState('');
|
|
|
|
// Mutations
|
|
const createUser = useCreateUser();
|
|
const updateUser = useUpdateUser();
|
|
const deleteUser = useDeleteUser();
|
|
const assignRole = useAssignRoleToUser();
|
|
const removeRole = useRemoveRoleFromUser();
|
|
const addToGroup = useAddUserToGroup();
|
|
const removeFromGroup = useRemoveUserFromGroup();
|
|
const setPassword = useSetPassword();
|
|
|
|
const userList = users ?? [];
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!search) return userList;
|
|
const q = search.toLowerCase();
|
|
return userList.filter(
|
|
(u) =>
|
|
u.displayName.toLowerCase().includes(q) ||
|
|
(u.email ?? '').toLowerCase().includes(q) ||
|
|
u.userId.toLowerCase().includes(q),
|
|
);
|
|
}, [userList, search]);
|
|
|
|
const selected = userList.find((u) => u.userId === selectedId) ?? null;
|
|
|
|
const isSelf =
|
|
currentUsername != null &&
|
|
selected != null &&
|
|
selected.displayName === currentUsername;
|
|
|
|
const duplicateUsername =
|
|
newUsername.trim() !== '' &&
|
|
userList.some(
|
|
(u) => u.displayName.toLowerCase() === newUsername.trim().toLowerCase(),
|
|
);
|
|
|
|
// Derived data for detail pane
|
|
const directGroupIds = new Set(selected?.directGroups.map((g) => g.id) ?? []);
|
|
const directRoleIds = new Set(selected?.directRoles.map((r) => r.id) ?? []);
|
|
const inheritedRoles =
|
|
selected?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? [];
|
|
|
|
const availableGroups = (allGroups ?? [])
|
|
.filter((g) => !directGroupIds.has(g.id))
|
|
.map((g) => ({ value: g.id, label: g.name }));
|
|
|
|
const availableRoles = (allRoles ?? [])
|
|
.filter((r) => !directRoleIds.has(r.id))
|
|
.map((r) => ({ value: r.id, label: r.name }));
|
|
|
|
function handleCreate() {
|
|
if (!newUsername.trim()) return;
|
|
if (newProvider === 'local' && !newPassword.trim()) return;
|
|
createUser.mutate(
|
|
{
|
|
username: newUsername.trim(),
|
|
displayName: newDisplay.trim() || undefined,
|
|
email: newEmail.trim() || undefined,
|
|
password: newProvider === 'local' ? newPassword : undefined,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
toast({
|
|
title: 'User created',
|
|
description: newDisplay.trim() || newUsername.trim(),
|
|
variant: 'success',
|
|
});
|
|
setCreating(false);
|
|
setNewUsername('');
|
|
setNewDisplay('');
|
|
setNewEmail('');
|
|
setNewPassword('');
|
|
setNewProvider('local');
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Failed to create user', variant: 'error' });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleDelete() {
|
|
if (!deleteTarget) return;
|
|
deleteUser.mutate(deleteTarget.userId, {
|
|
onSuccess: () => {
|
|
toast({
|
|
title: 'User deleted',
|
|
description: deleteTarget.displayName,
|
|
variant: 'warning',
|
|
});
|
|
if (selectedId === deleteTarget.userId) setSelectedId(null);
|
|
setDeleteTarget(null);
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Failed to delete user', variant: 'error' });
|
|
setDeleteTarget(null);
|
|
},
|
|
});
|
|
}
|
|
|
|
function handleResetPassword() {
|
|
if (!selected || !newPw.trim()) return;
|
|
setPassword.mutate(
|
|
{ userId: selected.userId, password: newPw },
|
|
{
|
|
onSuccess: () => {
|
|
toast({
|
|
title: 'Password updated',
|
|
description: selected.displayName,
|
|
variant: 'success',
|
|
});
|
|
setResettingPassword(false);
|
|
setNewPw('');
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Failed to update password', variant: 'error' });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function getUserGroupPath(user: UserDetail): string {
|
|
if (user.directGroups.length === 0) return 'no groups';
|
|
return user.directGroups.map((g) => g.name).join(', ');
|
|
}
|
|
|
|
if (isLoading) return <Spinner size="md" />;
|
|
|
|
return (
|
|
<>
|
|
<SplitPane
|
|
list={
|
|
<>
|
|
{creating && (
|
|
<div className={styles.createForm}>
|
|
<RadioGroup
|
|
name="provider"
|
|
value={newProvider}
|
|
onChange={(v) => setNewProvider(v as 'local' | 'oidc')}
|
|
orientation="horizontal"
|
|
>
|
|
<RadioItem value="local" label="Local" />
|
|
<RadioItem value="oidc" label="OIDC" />
|
|
</RadioGroup>
|
|
<div className={styles.createFormRow}>
|
|
<Input
|
|
placeholder="Username *"
|
|
value={newUsername}
|
|
onChange={(e) => setNewUsername(e.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="Display name"
|
|
value={newDisplay}
|
|
onChange={(e) => setNewDisplay(e.target.value)}
|
|
/>
|
|
</div>
|
|
{duplicateUsername && (
|
|
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
|
Username already exists
|
|
</span>
|
|
)}
|
|
<Input
|
|
placeholder="Email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
/>
|
|
{newProvider === 'local' && (
|
|
<Input
|
|
placeholder="Password *"
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
/>
|
|
)}
|
|
{newProvider === 'oidc' && (
|
|
<InfoCallout variant="amber">
|
|
OIDC users authenticate via the configured identity provider.
|
|
Pre-register to assign roles/groups before their first login.
|
|
</InfoCallout>
|
|
)}
|
|
<div className={styles.createFormActions}>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setCreating(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
onClick={handleCreate}
|
|
loading={createUser.isPending}
|
|
disabled={
|
|
!newUsername.trim() ||
|
|
(newProvider === 'local' && !newPassword.trim()) ||
|
|
duplicateUsername
|
|
}
|
|
>
|
|
Create
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<EntityList
|
|
items={filtered}
|
|
renderItem={(user) => (
|
|
<>
|
|
<Avatar name={user.displayName} size="sm" />
|
|
<div className={styles.entityInfo}>
|
|
<div className={styles.entityName}>
|
|
{user.displayName}
|
|
{user.provider !== 'local' && (
|
|
<Badge
|
|
label={user.provider}
|
|
color="running"
|
|
variant="outlined"
|
|
className={styles.providerBadge}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className={styles.entityMeta}>
|
|
{user.email || user.userId} ·{' '}
|
|
{getUserGroupPath(user)}
|
|
</div>
|
|
<div className={styles.entityTags}>
|
|
{user.directRoles.map((r) => (
|
|
<Badge key={r.id} label={r.name} color="warning" />
|
|
))}
|
|
{user.directGroups.map((g) => (
|
|
<Badge key={g.id} label={g.name} color="success" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
getItemId={(user) => user.userId}
|
|
selectedId={selectedId ?? undefined}
|
|
onSelect={(id) => {
|
|
setSelectedId(id);
|
|
setResettingPassword(false);
|
|
}}
|
|
searchPlaceholder="Search users..."
|
|
onSearch={setSearch}
|
|
addLabel="+ Add user"
|
|
onAdd={() => setCreating(true)}
|
|
emptyMessage="No users match your search"
|
|
/>
|
|
</>
|
|
}
|
|
detail={
|
|
selected ? (
|
|
<>
|
|
<div className={styles.detailHeader}>
|
|
<Avatar name={selected.displayName} size="lg" />
|
|
<div className={styles.detailHeaderInfo}>
|
|
<div className={styles.detailName}>
|
|
<InlineEdit
|
|
value={selected.displayName}
|
|
onSave={(v) =>
|
|
updateUser.mutate(
|
|
{ userId: selected.userId, displayName: v },
|
|
{
|
|
onSuccess: () =>
|
|
toast({
|
|
title: 'Display name updated',
|
|
variant: 'success',
|
|
}),
|
|
onError: () =>
|
|
toast({
|
|
title: 'Failed to update name',
|
|
variant: 'error',
|
|
}),
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className={styles.detailEmail}>
|
|
{selected.email || selected.userId}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
onClick={() => setDeleteTarget(selected)}
|
|
disabled={isSelf}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
|
|
<SectionHeader>Status</SectionHeader>
|
|
<div className={styles.sectionTags}>
|
|
<Tag label="Active" color="success" />
|
|
</div>
|
|
|
|
<div className={styles.metaGrid}>
|
|
<span className={styles.metaLabel}>ID</span>
|
|
<MonoText size="xs">{selected.userId}</MonoText>
|
|
<span className={styles.metaLabel}>Created</span>
|
|
<span className={styles.metaValue}>
|
|
{new Date(selected.createdAt).toLocaleDateString()}
|
|
</span>
|
|
<span className={styles.metaLabel}>Provider</span>
|
|
<span className={styles.metaValue}>{selected.provider}</span>
|
|
</div>
|
|
|
|
<SectionHeader>Security</SectionHeader>
|
|
<div className={styles.securitySection}>
|
|
{selected.provider === 'local' ? (
|
|
<>
|
|
<div className={styles.securityRow}>
|
|
<span className={styles.metaLabel}>Password</span>
|
|
<span className={styles.passwordDots}>
|
|
••••••••
|
|
</span>
|
|
{!resettingPassword && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setResettingPassword(true);
|
|
setNewPw('');
|
|
}}
|
|
>
|
|
Reset password
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{resettingPassword && (
|
|
<div className={styles.resetForm}>
|
|
<Input
|
|
placeholder="New password"
|
|
type="password"
|
|
value={newPw}
|
|
onChange={(e) => setNewPw(e.target.value)}
|
|
className={styles.resetInput}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setResettingPassword(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
onClick={handleResetPassword}
|
|
loading={setPassword.isPending}
|
|
disabled={!newPw.trim()}
|
|
>
|
|
Set
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className={styles.securityRow}>
|
|
<span className={styles.metaLabel}>Authentication</span>
|
|
<span className={styles.metaValue}>
|
|
OIDC ({selected.provider})
|
|
</span>
|
|
</div>
|
|
<InfoCallout variant="amber">
|
|
Password managed by the identity provider.
|
|
</InfoCallout>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<SectionHeader>Group membership (direct only)</SectionHeader>
|
|
<div className={styles.sectionTags}>
|
|
{selected.directGroups.map((g) => (
|
|
<Tag
|
|
key={g.id}
|
|
label={g.name}
|
|
color="success"
|
|
onRemove={() => {
|
|
removeFromGroup.mutate(
|
|
{ userId: selected.userId, groupId: g.id },
|
|
{
|
|
onSuccess: () =>
|
|
toast({ title: 'Group removed', variant: 'success' }),
|
|
onError: () =>
|
|
toast({
|
|
title: 'Failed to remove group',
|
|
variant: 'error',
|
|
}),
|
|
},
|
|
);
|
|
}}
|
|
/>
|
|
))}
|
|
{selected.directGroups.length === 0 && (
|
|
<span className={styles.inheritedNote}>(no groups)</span>
|
|
)}
|
|
<MultiSelect
|
|
options={availableGroups}
|
|
value={[]}
|
|
onChange={(ids) => {
|
|
for (const groupId of ids) {
|
|
addToGroup.mutate(
|
|
{ userId: selected.userId, groupId },
|
|
{
|
|
onSuccess: () =>
|
|
toast({ title: 'Added to group', variant: 'success' }),
|
|
onError: () =>
|
|
toast({
|
|
title: 'Failed to add group',
|
|
variant: 'error',
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
}}
|
|
placeholder="+ Add"
|
|
/>
|
|
</div>
|
|
|
|
<SectionHeader>
|
|
Effective roles (direct + inherited)
|
|
</SectionHeader>
|
|
<div className={styles.sectionTags}>
|
|
{selected.directRoles.map((r) => (
|
|
<Tag
|
|
key={r.id}
|
|
label={r.name}
|
|
color="warning"
|
|
onRemove={() => {
|
|
removeRole.mutate(
|
|
{ userId: selected.userId, roleId: r.id },
|
|
{
|
|
onSuccess: () =>
|
|
toast({
|
|
title: 'Role removed',
|
|
description: r.name,
|
|
variant: 'success',
|
|
}),
|
|
onError: () =>
|
|
toast({
|
|
title: 'Failed to remove role',
|
|
variant: 'error',
|
|
}),
|
|
},
|
|
);
|
|
}}
|
|
/>
|
|
))}
|
|
{inheritedRoles.map((r) => (
|
|
<Badge
|
|
key={r.id}
|
|
label={`${r.name} ↑ group`}
|
|
color="warning"
|
|
variant="dashed"
|
|
className={styles.inherited}
|
|
/>
|
|
))}
|
|
{selected.directRoles.length === 0 &&
|
|
inheritedRoles.length === 0 && (
|
|
<span className={styles.inheritedNote}>(no roles)</span>
|
|
)}
|
|
<MultiSelect
|
|
options={availableRoles}
|
|
value={[]}
|
|
onChange={(roleIds) => {
|
|
for (const roleId of roleIds) {
|
|
assignRole.mutate(
|
|
{ userId: selected.userId, roleId },
|
|
{
|
|
onSuccess: () =>
|
|
toast({
|
|
title: 'Role assigned',
|
|
variant: 'success',
|
|
}),
|
|
onError: () =>
|
|
toast({
|
|
title: 'Failed to assign role',
|
|
variant: 'error',
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
}}
|
|
placeholder="+ Add"
|
|
/>
|
|
</div>
|
|
{inheritedRoles.length > 0 && (
|
|
<span className={styles.inheritedNote}>
|
|
Roles with ↑ are inherited through group membership
|
|
</span>
|
|
)}
|
|
</>
|
|
) : null
|
|
}
|
|
emptyMessage="Select a user to view details"
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={deleteTarget !== null}
|
|
onClose={() => setDeleteTarget(null)}
|
|
onConfirm={handleDelete}
|
|
message={`Delete user "${deleteTarget?.displayName}"? This cannot be undone.`}
|
|
confirmText={deleteTarget?.displayName ?? ''}
|
|
loading={deleteUser.isPending}
|
|
/>
|
|
<AlertDialog
|
|
open={removeGroupTarget !== null}
|
|
onClose={() => setRemoveGroupTarget(null)}
|
|
onConfirm={() => {
|
|
if (removeGroupTarget && selected) {
|
|
removeFromGroup.mutate(
|
|
{ userId: selected.userId, groupId: removeGroupTarget },
|
|
{
|
|
onSuccess: () =>
|
|
toast({ title: 'Group removed', variant: 'success' }),
|
|
onError: () =>
|
|
toast({
|
|
title: 'Failed to remove group',
|
|
variant: 'error',
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
setRemoveGroupTarget(null);
|
|
}}
|
|
title="Remove group membership"
|
|
description="Removing this group may also revoke inherited roles. Continue?"
|
|
confirmLabel="Remove"
|
|
variant="warning"
|
|
/>
|
|
</>
|
|
);
|
|
}
|