- RolesTab: wrap \u00b7 in JS expression {'\u00b7'} so JSX renders the middle dot correctly instead of literal backslash-u sequence
- UsersTab: add confirm password field with mismatch validation, hint text for password policy, and reset on cancel/success
- UserManagement.module.css: add .hintText style for password policy hint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
652 lines
23 KiB
TypeScript
652 lines
23 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import {
|
|
Avatar,
|
|
Badge,
|
|
Button,
|
|
Input,
|
|
MonoText,
|
|
SectionHeader,
|
|
Tag,
|
|
InlineEdit,
|
|
RadioGroup,
|
|
RadioItem,
|
|
InfoCallout,
|
|
MultiSelect,
|
|
ConfirmDialog,
|
|
AlertDialog,
|
|
SplitPane,
|
|
EntityList,
|
|
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 { PageLoader } from '../../components/PageLoader';
|
|
import styles from './UserManagement.module.css';
|
|
import sectionStyles from '../../styles/section-card.module.css';
|
|
|
|
export default function UsersTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) {
|
|
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);
|
|
const [removeRoleTarget, setRemoveRoleTarget] = useState<{ id: string; name: string } | null>(null);
|
|
|
|
// Auto-select highlighted item from cmd-k navigation
|
|
useEffect(() => {
|
|
if (highlightId && users) {
|
|
const match = users.find((u) => u.userId === highlightId);
|
|
if (match) {
|
|
setSelectedId(match.userId);
|
|
onHighlightConsumed?.();
|
|
}
|
|
}
|
|
}, [highlightId, users]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Create form state
|
|
const [newUsername, setNewUsername] = useState('');
|
|
const [newDisplay, setNewDisplay] = useState('');
|
|
const [newEmail, setNewEmail] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
|
|
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local');
|
|
|
|
const passwordMismatch = newPassword.length > 0 && newPasswordConfirm.length > 0 && newPassword !== newPasswordConfirm;
|
|
|
|
// 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('');
|
|
setNewPasswordConfirm('');
|
|
setNewProvider('local');
|
|
},
|
|
onError: (err: unknown) => {
|
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
toast({ title: 'Failed to create user', description: message, variant: 'error', duration: 86_400_000 });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
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', duration: 86_400_000 });
|
|
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', duration: 86_400_000 });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function getUserGroupPath(user: UserDetail): string {
|
|
if (user.directGroups.length === 0) return 'no groups';
|
|
return user.directGroups.map((g) => g.name).join(', ');
|
|
}
|
|
|
|
if (isLoading) return <PageLoader />;
|
|
|
|
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 className={styles.errorText}>
|
|
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)}
|
|
/>
|
|
<Input
|
|
placeholder="Confirm Password *"
|
|
type="password"
|
|
value={newPasswordConfirm}
|
|
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
|
/>
|
|
{passwordMismatch && (
|
|
<span className={styles.errorText}>Passwords do not match</span>
|
|
)}
|
|
<span className={styles.hintText}>Min 12 chars, 3 of 4: uppercase, lowercase, number, special</span>
|
|
</>
|
|
)}
|
|
{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); setNewPasswordConfirm(''); }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
onClick={handleCreate}
|
|
loading={createUser.isPending}
|
|
disabled={
|
|
!newUsername.trim() ||
|
|
(newProvider === 'local' && !newPassword.trim()) ||
|
|
duplicateUsername ||
|
|
passwordMismatch
|
|
}
|
|
>
|
|
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',
|
|
duration: 86_400_000,
|
|
}),
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className={styles.detailEmail}>
|
|
{selected.email || selected.userId}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
onClick={() => setDeleteTarget(selected)}
|
|
disabled={isSelf}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
|
|
<div className={sectionStyles.section}>
|
|
<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>
|
|
</div>
|
|
|
|
<div className={sectionStyles.section}>
|
|
<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>
|
|
</div>
|
|
|
|
<div className={sectionStyles.section}>
|
|
<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',
|
|
duration: 86_400_000,
|
|
}),
|
|
},
|
|
);
|
|
}}
|
|
/>
|
|
))}
|
|
{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',
|
|
duration: 86_400_000,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
}}
|
|
placeholder="+ Add"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={sectionStyles.section}>
|
|
<SectionHeader>
|
|
Effective roles (direct + inherited)
|
|
</SectionHeader>
|
|
<div className={styles.sectionTags}>
|
|
{selected.directRoles.map((r) => (
|
|
<Tag
|
|
key={r.id}
|
|
label={r.name}
|
|
color="warning"
|
|
onRemove={() => setRemoveRoleTarget(r)}
|
|
/>
|
|
))}
|
|
{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',
|
|
duration: 86_400_000,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
}}
|
|
placeholder="+ Add"
|
|
/>
|
|
</div>
|
|
{inheritedRoles.length > 0 && (
|
|
<span className={styles.inheritedNote}>
|
|
Roles with ↑ are inherited through group membership
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : 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',
|
|
duration: 86_400_000,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
setRemoveGroupTarget(null);
|
|
}}
|
|
title="Remove group membership"
|
|
description="Removing this group may also revoke inherited roles. Continue?"
|
|
confirmLabel="Remove"
|
|
variant="warning"
|
|
/>
|
|
<AlertDialog
|
|
open={removeRoleTarget !== null}
|
|
onClose={() => setRemoveRoleTarget(null)}
|
|
onConfirm={() => {
|
|
if (removeRoleTarget && selected) {
|
|
removeRole.mutate(
|
|
{ userId: selected.userId, roleId: removeRoleTarget.id },
|
|
{
|
|
onSuccess: () => {
|
|
toast({ title: 'Role removed', description: removeRoleTarget.name, variant: 'success' });
|
|
setRemoveRoleTarget(null);
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Failed to remove role', variant: 'error', duration: 86_400_000 });
|
|
setRemoveRoleTarget(null);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}}
|
|
title="Remove role"
|
|
description={`Remove the "${removeRoleTarget?.name}" role from this user?`}
|
|
confirmLabel="Remove"
|
|
variant="warning"
|
|
/>
|
|
</>
|
|
);
|
|
}
|