Files
design-system/src/pages/Admin/UserManagement/RolesTab.tsx
hsiegeln bd4e22eafb refactor: use SplitPane and EntityList in Admin RBAC tabs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:22:56 +01:00

213 lines
8.7 KiB
TypeScript

import { useState, useMemo } from 'react'
import { Avatar } from '../../../design-system/primitives/Avatar/Avatar'
import { Badge } from '../../../design-system/primitives/Badge/Badge'
import { Button } from '../../../design-system/primitives/Button/Button'
import { Input } from '../../../design-system/primitives/Input/Input'
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
import { Tag } from '../../../design-system/primitives/Tag/Tag'
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
import { SplitPane } from '../../../design-system/composites/SplitPane/SplitPane'
import { EntityList } from '../../../design-system/composites/EntityList/EntityList'
import { useToast } from '../../../design-system/composites/Toast/Toast'
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
import styles from './UserManagement.module.css'
export function RolesTab() {
const { toast } = useToast()
const [roles, setRoles] = useState(MOCK_ROLES)
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<MockRole | null>(null)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const filtered = useMemo(() => {
if (!search) return roles
const q = search.toLowerCase()
return roles.filter((r) =>
r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)
)
}, [roles, search])
const selected = roles.find((r) => r.id === selectedId) ?? null
function handleCreate() {
if (!newName.trim()) return
const newRole: MockRole = {
id: `role-${Date.now()}`,
name: newName.trim().toUpperCase(),
description: newDesc.trim(),
scope: 'custom',
system: false,
}
setRoles((prev) => [...prev, newRole])
setCreating(false)
setNewName(''); setNewDesc('')
setSelectedId(newRole.id)
toast({ title: 'Role created', description: newRole.name, variant: 'success' })
}
function handleDelete() {
if (!deleteTarget) return
setRoles((prev) => prev.filter((r) => r.id !== deleteTarget.id))
if (selectedId === deleteTarget.id) setSelectedId(null)
setDeleteTarget(null)
toast({ title: 'Role deleted', description: deleteTarget.name, variant: 'warning' })
}
const duplicateRoleName = newName.trim() !== '' && roles.some((r) => r.name === newName.trim().toUpperCase())
// Role assignments
const assignedGroups = selected
? MOCK_GROUPS.filter((g) => g.directRoles.includes(selected.name))
: []
const directUsers = selected
? MOCK_USERS.filter((u) => u.directRoles.includes(selected.name))
: []
const effectivePrincipals = selected
? MOCK_USERS.filter((u) => getEffectiveRoles(u).some((r) => r.role === selected.name))
: []
function getAssignmentCount(role: MockRole): number {
const groups = MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name)).length
const users = MOCK_USERS.filter((u) => u.directRoles.includes(role.name)).length
return groups + users
}
return (
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
{duplicateRoleName && <span style={{ color: 'var(--error)', fontSize: 11 }}>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} 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} · {getAssignmentCount(role)} assignments
</div>
<div className={styles.entityTags}>
{MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name))
.map((g) => <Badge key={g.id} label={g.name} color="success" />)}
{MOCK_USERS.filter((u) => u.directRoles.includes(role.name))
.map((u) => <Badge key={u.id} label={u.username} 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={selected ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>{selected.name}</div>
{selected.description && (
<div className={styles.detailEmail}>{selected.description}</div>
)}
</div>
{!selected.system && (
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
>
Delete
</Button>
)}
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.id}</MonoText>
<span className={styles.metaLabel}>Scope</span>
<span className={styles.metaValue}>{selected.scope}</span>
{selected.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.id} 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 = u.directRoles.includes(selected.name)
return (
<Badge
key={u.id}
label={u.displayName}
color="auto"
variant={isDirect ? 'filled' : 'dashed'}
/>
)
})}
{effectivePrincipals.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
</div>
{effectivePrincipals.some((u) => !u.directRoles.includes(selected.name)) && (
<span className={styles.inheritedNote}>
Dashed entries inherit this role through group membership
</span>
)}
</>
) : 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 ?? ''}
/>
</>
)
}