213 lines
8.7 KiB
TypeScript
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 ?? ''}
|
|
/>
|
|
</>
|
|
)
|
|
}
|