Files
cameleer-server/ui/src/pages/Admin/UsersTab.tsx
hsiegeln 81f85aa82d
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
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>
2026-03-24 16:42:16 +01:00

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} &middot;{' '}
{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"
/>
</>
);
}