- 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>
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
import { useState, useMemo, useEffect } 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 { PageLoader } from '../../components/PageLoader';
|
|
import styles from './UserManagement.module.css';
|
|
|
|
export default function RolesTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) {
|
|
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);
|
|
|
|
// Auto-select highlighted item from cmd-k navigation
|
|
useEffect(() => {
|
|
if (highlightId && roles) {
|
|
const match = roles.find((r) => r.id === highlightId);
|
|
if (match) {
|
|
setSelectedId(match.id);
|
|
onHighlightConsumed?.();
|
|
}
|
|
}
|
|
}, [highlightId, roles]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 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', duration: 86_400_000 });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
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', duration: 86_400_000 });
|
|
setDeleteTarget(null);
|
|
},
|
|
});
|
|
}
|
|
|
|
function getAssignmentCount(role: RoleDetail): number {
|
|
return (
|
|
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
|
|
);
|
|
}
|
|
|
|
if (isLoading) return <PageLoader />;
|
|
|
|
return (
|
|
<>
|
|
<SplitPane
|
|
list={
|
|
<>
|
|
{creating && (
|
|
<div className={styles.createForm}>
|
|
<Input
|
|
placeholder="Role name *"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
/>
|
|
{duplicateRoleName && (
|
|
<span className={styles.errorText}>
|
|
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|