306 lines
9.7 KiB
TypeScript
306 lines
9.7 KiB
TypeScript
|
|
import { useState } from 'react';
|
||
|
|
import {
|
||
|
|
Avatar,
|
||
|
|
Badge,
|
||
|
|
Button,
|
||
|
|
ConfirmDialog,
|
||
|
|
Input,
|
||
|
|
MonoText,
|
||
|
|
Spinner,
|
||
|
|
Tag,
|
||
|
|
useToast,
|
||
|
|
} from '@cameleer/design-system';
|
||
|
|
import {
|
||
|
|
useRoles,
|
||
|
|
useRole,
|
||
|
|
useCreateRole,
|
||
|
|
useDeleteRole,
|
||
|
|
} from '../../api/queries/admin/rbac';
|
||
|
|
import type { RoleDetail } from '../../api/queries/admin/rbac';
|
||
|
|
import styles from './UserManagement.module.css';
|
||
|
|
|
||
|
|
export default function RolesTab() {
|
||
|
|
const { data: roles, isLoading } = useRoles();
|
||
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
const [showCreate, setShowCreate] = useState(false);
|
||
|
|
const [newName, setNewName] = useState('');
|
||
|
|
const [newDescription, setNewDescription] = useState('');
|
||
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
|
|
|
||
|
|
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
|
||
|
|
const createRole = useCreateRole();
|
||
|
|
const deleteRole = useDeleteRole();
|
||
|
|
const { toast } = useToast();
|
||
|
|
|
||
|
|
const filtered = (roles ?? []).filter((r) =>
|
||
|
|
r.name.toLowerCase().includes(search.toLowerCase()),
|
||
|
|
);
|
||
|
|
|
||
|
|
function handleCreate() {
|
||
|
|
if (!newName.trim()) return;
|
||
|
|
createRole.mutate(
|
||
|
|
{ name: newName.trim(), description: newDescription.trim() || undefined },
|
||
|
|
{
|
||
|
|
onSuccess: () => {
|
||
|
|
toast({ title: 'Role created', variant: 'success' });
|
||
|
|
setShowCreate(false);
|
||
|
|
setNewName('');
|
||
|
|
setNewDescription('');
|
||
|
|
},
|
||
|
|
onError: () => {
|
||
|
|
toast({ title: 'Failed to create role', variant: 'error' });
|
||
|
|
},
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDelete() {
|
||
|
|
if (!selectedId) return;
|
||
|
|
deleteRole.mutate(selectedId, {
|
||
|
|
onSuccess: () => {
|
||
|
|
toast({ title: 'Role deleted', variant: 'success' });
|
||
|
|
setSelectedId(null);
|
||
|
|
setConfirmDelete(false);
|
||
|
|
},
|
||
|
|
onError: () => {
|
||
|
|
toast({ title: 'Failed to delete role', variant: 'error' });
|
||
|
|
setConfirmDelete(false);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={styles.splitPane}>
|
||
|
|
{/* Left pane — list */}
|
||
|
|
<div className={styles.listPane}>
|
||
|
|
<div className={styles.listHeader}>
|
||
|
|
<Input
|
||
|
|
placeholder="Search roles…"
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
variant="secondary"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setShowCreate((v) => !v)}
|
||
|
|
>
|
||
|
|
+ Add Role
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{showCreate && (
|
||
|
|
<div className={styles.createForm}>
|
||
|
|
<Input
|
||
|
|
placeholder="Role name (e.g. EDITOR)"
|
||
|
|
value={newName}
|
||
|
|
onChange={(e) => setNewName(e.target.value.toUpperCase())}
|
||
|
|
style={{ marginBottom: 8 }}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
placeholder="Description (optional)"
|
||
|
|
value={newDescription}
|
||
|
|
onChange={(e) => setNewDescription(e.target.value)}
|
||
|
|
/>
|
||
|
|
<div className={styles.createFormActions}>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => {
|
||
|
|
setShowCreate(false);
|
||
|
|
setNewName('');
|
||
|
|
setNewDescription('');
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="primary"
|
||
|
|
size="sm"
|
||
|
|
loading={createRole.isPending}
|
||
|
|
disabled={!newName.trim()}
|
||
|
|
onClick={handleCreate}
|
||
|
|
>
|
||
|
|
Create
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{isLoading ? (
|
||
|
|
<Spinner />
|
||
|
|
) : (
|
||
|
|
<div className={styles.entityList} role="listbox">
|
||
|
|
{filtered.map((role) => {
|
||
|
|
const assignmentCount =
|
||
|
|
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={role.id}
|
||
|
|
className={
|
||
|
|
styles.entityItem +
|
||
|
|
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
|
||
|
|
}
|
||
|
|
role="option"
|
||
|
|
aria-selected={selectedId === role.id}
|
||
|
|
onClick={() => setSelectedId(role.id)}
|
||
|
|
>
|
||
|
|
<Avatar name={role.name} size="sm" />
|
||
|
|
<div className={styles.entityInfo}>
|
||
|
|
<div className={styles.entityName}>
|
||
|
|
{role.name}
|
||
|
|
{role.system && <Badge label="system" variant="outlined" />}
|
||
|
|
</div>
|
||
|
|
<div className={styles.entityMeta}>
|
||
|
|
{role.description || '—'} · {assignmentCount} assignment
|
||
|
|
{assignmentCount !== 1 ? 's' : ''}
|
||
|
|
</div>
|
||
|
|
{((role.assignedGroups?.length ?? 0) > 0 ||
|
||
|
|
(role.directUsers?.length ?? 0) > 0) && (
|
||
|
|
<div className={styles.entityTags}>
|
||
|
|
{(role.assignedGroups ?? []).map((g) => (
|
||
|
|
<Tag key={g.id} label={g.name} color="success" />
|
||
|
|
))}
|
||
|
|
{(role.directUsers ?? []).map((u) => (
|
||
|
|
<Tag key={u.userId} label={u.displayName} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Right pane — detail */}
|
||
|
|
<div className={styles.detailPane}>
|
||
|
|
{!selectedId ? (
|
||
|
|
<div className={styles.emptyDetail}>Select a role to view details</div>
|
||
|
|
) : detailLoading || !detail ? (
|
||
|
|
<Spinner />
|
||
|
|
) : (
|
||
|
|
<RoleDetailPanel
|
||
|
|
role={detail}
|
||
|
|
onDeleteRequest={() => setConfirmDelete(true)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{detail && (
|
||
|
|
<ConfirmDialog
|
||
|
|
open={confirmDelete}
|
||
|
|
onClose={() => setConfirmDelete(false)}
|
||
|
|
onConfirm={handleDelete}
|
||
|
|
title="Delete role"
|
||
|
|
message={`Delete role "${detail.name}"? This cannot be undone.`}
|
||
|
|
confirmText={detail.name}
|
||
|
|
confirmLabel="Delete"
|
||
|
|
variant="danger"
|
||
|
|
loading={deleteRole.isPending}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Detail panel ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface RoleDetailPanelProps {
|
||
|
|
role: RoleDetail;
|
||
|
|
onDeleteRequest: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
|
||
|
|
// Build a set of directly-assigned user IDs for distinguishing inherited principals
|
||
|
|
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
{/* Header */}
|
||
|
|
<div className={styles.detailHeader}>
|
||
|
|
<Avatar name={role.name} size="md" />
|
||
|
|
<div style={{ flex: 1 }}>
|
||
|
|
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
|
||
|
|
{role.description && (
|
||
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||
|
|
{role.description}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
variant="danger"
|
||
|
|
size="sm"
|
||
|
|
disabled={role.system}
|
||
|
|
onClick={onDeleteRequest}
|
||
|
|
>
|
||
|
|
Delete
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Metadata */}
|
||
|
|
<div className={styles.metaGrid}>
|
||
|
|
<span className={styles.metaLabel}>ID</span>
|
||
|
|
<MonoText size="xs">{role.id}</MonoText>
|
||
|
|
|
||
|
|
<span className={styles.metaLabel}>Scope</span>
|
||
|
|
<span>{role.scope || '—'}</span>
|
||
|
|
|
||
|
|
<span className={styles.metaLabel}>Type</span>
|
||
|
|
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Assigned to groups */}
|
||
|
|
<div className={styles.sectionTitle}>Assigned to groups</div>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{(role.assignedGroups ?? []).length === 0 ? (
|
||
|
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||
|
|
) : (
|
||
|
|
(role.assignedGroups ?? []).map((g) => (
|
||
|
|
<Tag key={g.id} label={g.name} color="success" />
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Assigned to users (direct) */}
|
||
|
|
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{(role.directUsers ?? []).length === 0 ? (
|
||
|
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||
|
|
) : (
|
||
|
|
(role.directUsers ?? []).map((u) => (
|
||
|
|
<Tag key={u.userId} label={u.displayName} />
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Effective principals */}
|
||
|
|
<div className={styles.sectionTitle}>Effective principals</div>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{(role.effectivePrincipals ?? []).length === 0 ? (
|
||
|
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||
|
|
) : (
|
||
|
|
(role.effectivePrincipals ?? []).map((u) => {
|
||
|
|
const isDirect = directUserIds.has(u.userId);
|
||
|
|
return isDirect ? (
|
||
|
|
<Badge key={u.userId} label={u.displayName} variant="filled" />
|
||
|
|
) : (
|
||
|
|
<Badge
|
||
|
|
key={u.userId}
|
||
|
|
label={`↑ ${u.displayName}`}
|
||
|
|
variant="dashed"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
|
||
|
|
<div className={styles.inheritedNote}>
|
||
|
|
Dashed entries inherit this role through group membership
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|