feat: add RolesTab to User Management
All checks were successful
Build & Publish / publish (push) Successful in 44s
All checks were successful
Build & Publish / publish (push) Successful in 44s
This commit is contained in:
213
src/pages/Admin/UserManagement/RolesTab.tsx
Normal file
213
src/pages/Admin/UserManagement/RolesTab.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
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 { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
|
||||||
|
import styles from './UserManagement.module.css'
|
||||||
|
|
||||||
|
export function RolesTab() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setRoles((prev) => prev.filter((r) => r.id !== deleteTarget.id))
|
||||||
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<div className={styles.splitPane}>
|
||||||
|
<div className={styles.listPane}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search roles..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onClear={() => setSearch('')}
|
||||||
|
className={styles.listHeaderSearch}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
||||||
|
+ Add role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
|
<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()}>Create</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.entityList}>
|
||||||
|
{filtered.map((role) => (
|
||||||
|
<div
|
||||||
|
key={role.id}
|
||||||
|
className={`${styles.entityItem} ${selectedId === role.id ? styles.entityItemSelected : ''}`}
|
||||||
|
onClick={() => setSelectedId(role.id)}
|
||||||
|
>
|
||||||
|
<Avatar name={role.name} size="sm" />
|
||||||
|
<div className={styles.entityInfo}>
|
||||||
|
<div className={styles.entityName}>
|
||||||
|
{role.name}
|
||||||
|
{role.system && <span title="System role"> 🔒</span>}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyDetail}>Select a role to view details</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
|
||||||
|
confirmText={deleteTarget?.name ?? ''}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user