16-task plan covering InlineEdit, ConfirmDialog, MultiSelect components, admin layout/routing, AuditLog, OidcConfig, UserManagement pages, inventory demos, and integration verification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
104 KiB
Admin Pages + New Components Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add 3 new design system components (InlineEdit, ConfirmDialog, MultiSelect) and 3 admin example pages (AuditLog, OidcConfig, UserManagement) with Inventory demos and routing.
Architecture: New components follow existing patterns (CSS Modules, design tokens, co-located tests). Admin pages share an admin sub-nav and use AppShell layout. Mock data is static with useState for interactivity.
Tech Stack: React 18, TypeScript, CSS Modules, Vitest + React Testing Library, react-router-dom v6
Spec: docs/superpowers/specs/2026-03-18-admin-pages-design.md
File Structure
New components
src/design-system/primitives/InlineEdit/
InlineEdit.tsx — Click-to-edit text field (display ↔ edit modes)
InlineEdit.module.css — Styles using design tokens
InlineEdit.test.tsx — Vitest + RTL tests
src/design-system/composites/ConfirmDialog/
ConfirmDialog.tsx — Type-to-confirm modal dialog (wraps Modal)
ConfirmDialog.module.css
ConfirmDialog.test.tsx
src/design-system/composites/MultiSelect/
MultiSelect.tsx — Dropdown with searchable checkbox list + Apply
MultiSelect.module.css
MultiSelect.test.tsx
Admin pages
src/pages/Admin/
Admin.tsx — MODIFY: replace placeholder with admin sub-nav + outlet
Admin.module.css — MODIFY: add admin sub-nav styles
AuditLog/
AuditLog.tsx — Filterable audit event table
AuditLog.module.css
auditMocks.ts — ~30 mock audit events
OidcConfig/
OidcConfig.tsx — OIDC settings form
OidcConfig.module.css
UserManagement/
UserManagement.tsx — Tabbed container (Users | Groups | Roles)
UserManagement.module.css
UsersTab.tsx — Split-pane user list + detail
GroupsTab.tsx — Split-pane group list + detail
RolesTab.tsx — Split-pane role list + detail
rbacMocks.ts — Users, groups, roles mock data with relationships
Modified files
src/App.tsx — Add admin sub-routes
src/design-system/primitives/index.ts — Export InlineEdit
src/design-system/composites/index.ts — Export ConfirmDialog, MultiSelect
src/design-system/layout/Sidebar/Sidebar.tsx — Fix active state for /admin/* paths
src/pages/Inventory/sections/PrimitivesSection.tsx — Add InlineEdit demo
src/pages/Inventory/sections/CompositesSection.tsx — Add ConfirmDialog + MultiSelect demos
Task 1: InlineEdit — Tests
Files:
-
Create:
src/design-system/primitives/InlineEdit/InlineEdit.test.tsx -
Step 1: Write InlineEdit tests
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InlineEdit } from './InlineEdit'
describe('InlineEdit', () => {
it('renders value in display mode', () => {
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
expect(screen.getByText('Alice')).toBeInTheDocument()
})
it('shows placeholder when value is empty', () => {
render(<InlineEdit value="" onSave={vi.fn()} placeholder="Enter name" />)
expect(screen.getByText('Enter name')).toBeInTheDocument()
})
it('enters edit mode on click', async () => {
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
await user.click(screen.getByText('Alice'))
expect(screen.getByRole('textbox')).toHaveValue('Alice')
})
it('saves on Enter', async () => {
const onSave = vi.fn()
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={onSave} />)
await user.click(screen.getByText('Alice'))
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'Bob')
await user.keyboard('{Enter}')
expect(onSave).toHaveBeenCalledWith('Bob')
})
it('cancels on Escape', async () => {
const onSave = vi.fn()
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={onSave} />)
await user.click(screen.getByText('Alice'))
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'Bob')
await user.keyboard('{Escape}')
expect(onSave).not.toHaveBeenCalled()
expect(screen.getByText('Alice')).toBeInTheDocument()
})
it('cancels on blur', async () => {
const onSave = vi.fn()
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={onSave} />)
await user.click(screen.getByText('Alice'))
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'Bob')
await user.tab()
expect(onSave).not.toHaveBeenCalled()
})
it('does not enter edit mode when disabled', async () => {
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={vi.fn()} disabled />)
await user.click(screen.getByText('Alice'))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('shows edit icon button', () => {
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
})
it('enters edit mode when edit button is clicked', async () => {
const user = userEvent.setup()
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByRole('textbox')).toHaveValue('Alice')
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/design-system/primitives/InlineEdit
Expected: FAIL — module not found
Task 2: InlineEdit — Implementation
Files:
-
Create:
src/design-system/primitives/InlineEdit/InlineEdit.tsx -
Create:
src/design-system/primitives/InlineEdit/InlineEdit.module.css -
Step 1: Write InlineEdit component
import { useState, useRef, useEffect } from 'react'
import styles from './InlineEdit.module.css'
export interface InlineEditProps {
value: string
onSave: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
}
export function InlineEdit({ value, onSave, placeholder, disabled, className }: InlineEditProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(value)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editing) {
inputRef.current?.focus()
inputRef.current?.select()
}
}, [editing])
function startEdit() {
if (disabled) return
setDraft(value)
setEditing(true)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
setEditing(false)
onSave(draft)
} else if (e.key === 'Escape') {
setEditing(false)
}
}
function handleBlur() {
setEditing(false)
}
if (editing) {
return (
<input
ref={inputRef}
className={`${styles.input} ${className ?? ''}`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
)
}
const isEmpty = !value
return (
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
<span
className={isEmpty ? styles.placeholder : styles.value}
onClick={startEdit}
>
{isEmpty ? placeholder : value}
</span>
{!disabled && (
<button
className={styles.editBtn}
onClick={startEdit}
aria-label="Edit"
type="button"
>
✎
</button>
)}
</span>
)
}
- Step 2: Write InlineEdit CSS
/* InlineEdit.module.css */
.display {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.display:hover .editBtn {
opacity: 1;
}
.disabled {
cursor: default;
}
.value {
font-family: var(--font-body);
font-size: 14px;
color: var(--text-primary);
}
.placeholder {
font-family: var(--font-body);
font-size: 14px;
color: var(--text-faint);
font-style: italic;
}
.editBtn {
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
font-size: 13px;
padding: 0 2px;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
line-height: 1;
}
.editBtn:hover {
color: var(--text-primary);
}
.disabled .editBtn {
display: none;
}
.input {
font-family: var(--font-body);
font-size: 14px;
color: var(--text-primary);
background: var(--bg-raised);
border: 1px solid var(--amber);
border-radius: var(--radius-sm);
padding: 2px 8px;
outline: none;
box-shadow: 0 0 0 3px var(--amber-bg);
}
- Step 3: Run tests to verify they pass
Run: npx vitest run src/design-system/primitives/InlineEdit
Expected: All 8 tests PASS
- Step 4: Export InlineEdit from primitives barrel
Add to src/design-system/primitives/index.ts after the Input export:
export { InlineEdit } from './InlineEdit/InlineEdit'
export type { InlineEditProps } from './InlineEdit/InlineEdit'
- Step 5: Commit
git add src/design-system/primitives/InlineEdit/ src/design-system/primitives/index.ts
git commit -m "feat: add InlineEdit primitive component"
Task 3: ConfirmDialog — Tests
Files:
-
Create:
src/design-system/composites/ConfirmDialog/ConfirmDialog.test.tsx -
Step 1: Write ConfirmDialog tests
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ConfirmDialog } from './ConfirmDialog'
const defaultProps = {
open: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
message: 'Delete user "alice"? This cannot be undone.',
confirmText: 'alice',
}
describe('ConfirmDialog', () => {
it('renders title and message when open', () => {
render(<ConfirmDialog {...defaultProps} />)
expect(screen.getByText('Confirm Deletion')).toBeInTheDocument()
expect(screen.getByText('Delete user "alice"? This cannot be undone.')).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<ConfirmDialog {...defaultProps} open={false} />)
expect(screen.queryByText('Confirm Deletion')).not.toBeInTheDocument()
})
it('renders custom title', () => {
render(<ConfirmDialog {...defaultProps} title="Remove item" />)
expect(screen.getByText('Remove item')).toBeInTheDocument()
})
it('shows confirm instruction text', () => {
render(<ConfirmDialog {...defaultProps} />)
expect(screen.getByText(/Type "alice" to confirm/)).toBeInTheDocument()
})
it('disables confirm button until text matches', () => {
render(<ConfirmDialog {...defaultProps} />)
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
})
it('enables confirm button when text matches', async () => {
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} />)
await user.type(screen.getByRole('textbox'), 'alice')
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled()
})
it('calls onConfirm when confirm button is clicked after typing', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
await user.type(screen.getByRole('textbox'), 'alice')
await user.click(screen.getByRole('button', { name: 'Delete' }))
expect(onConfirm).toHaveBeenCalledOnce()
})
it('calls onClose when cancel button is clicked', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} onClose={onClose} />)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onConfirm on Enter when text matches', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
await user.type(screen.getByRole('textbox'), 'alice')
await user.keyboard('{Enter}')
expect(onConfirm).toHaveBeenCalledOnce()
})
it('does not call onConfirm on Enter when text does not match', async () => {
const onConfirm = vi.fn()
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
await user.type(screen.getByRole('textbox'), 'alic')
await user.keyboard('{Enter}')
expect(onConfirm).not.toHaveBeenCalled()
})
it('disables both buttons when loading', async () => {
const user = userEvent.setup()
render(<ConfirmDialog {...defaultProps} loading />)
await user.type(screen.getByRole('textbox'), 'alice')
const buttons = screen.getAllByRole('button')
for (const btn of buttons) {
expect(btn).toBeDisabled()
}
})
it('clears input when opened', async () => {
const { rerender } = render(<ConfirmDialog {...defaultProps} open={false} />)
rerender(<ConfirmDialog {...defaultProps} open={true} />)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
it('auto-focuses input on open', async () => {
render(<ConfirmDialog {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveFocus()
})
})
it('renders custom button labels', () => {
render(<ConfirmDialog {...defaultProps} confirmLabel="Remove" cancelLabel="Keep" />)
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument()
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/design-system/composites/ConfirmDialog
Expected: FAIL — module not found
Task 4: ConfirmDialog — Implementation
Files:
-
Create:
src/design-system/composites/ConfirmDialog/ConfirmDialog.tsx -
Create:
src/design-system/composites/ConfirmDialog/ConfirmDialog.module.css -
Step 1: Write ConfirmDialog component
import { useState, useEffect, useRef } from 'react'
import { Modal } from '../Modal/Modal'
import { Button } from '../../primitives/Button/Button'
import styles from './ConfirmDialog.module.css'
interface ConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
title?: string
message: string
confirmText: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
loading?: boolean
className?: string
}
export type { ConfirmDialogProps }
export function ConfirmDialog({
open,
onClose,
onConfirm,
title = 'Confirm Deletion',
message,
confirmText,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
variant = 'danger',
loading = false,
className,
}: ConfirmDialogProps) {
const [input, setInput] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const matches = input === confirmText
useEffect(() => {
if (open) {
setInput('')
const id = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(id)
}
}, [open])
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && matches && !loading) {
e.preventDefault()
onConfirm()
}
}
const confirmButtonVariant = variant === 'danger' ? 'danger' : 'primary'
return (
<Modal open={open} onClose={onClose} size="sm" className={className}>
<div className={styles.content}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.message}>{message}</p>
<div className={styles.inputGroup}>
<label className={styles.label} htmlFor="confirm-input">
Type "<strong>{confirmText}</strong>" to confirm
</label>
<input
ref={inputRef}
id="confirm-input"
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
</div>
<div className={styles.buttonRow}>
<Button
variant="secondary"
onClick={onClose}
disabled={loading}
type="button"
>
{cancelLabel}
</Button>
<Button
variant={confirmButtonVariant}
onClick={onConfirm}
loading={loading}
disabled={!matches || loading}
type="button"
>
{confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}
- Step 2: Write ConfirmDialog CSS
/* ConfirmDialog.module.css */
.content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
font-family: var(--font-body);
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
}
.message {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--text-secondary);
}
.input {
width: 100%;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.input:focus {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.buttonRow {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
}
- Step 3: Run tests to verify they pass
Run: npx vitest run src/design-system/composites/ConfirmDialog
Expected: All 12 tests PASS
- Step 4: Export ConfirmDialog from composites barrel
Add to src/design-system/composites/index.ts after the CommandPalette exports:
export { ConfirmDialog } from './ConfirmDialog/ConfirmDialog'
export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog'
- Step 5: Commit
git add src/design-system/composites/ConfirmDialog/ src/design-system/composites/index.ts
git commit -m "feat: add ConfirmDialog composite component"
Task 5: MultiSelect — Tests
Files:
-
Create:
src/design-system/composites/MultiSelect/MultiSelect.test.tsx -
Step 1: Write MultiSelect tests
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MultiSelect } from './MultiSelect'
const OPTIONS = [
{ value: 'admin', label: 'ADMIN' },
{ value: 'editor', label: 'EDITOR' },
{ value: 'viewer', label: 'VIEWER' },
{ value: 'operator', label: 'OPERATOR' },
]
describe('MultiSelect', () => {
it('renders trigger with placeholder', () => {
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
expect(screen.getByText('Select...')).toBeInTheDocument()
})
it('renders trigger with custom placeholder', () => {
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} placeholder="Add roles..." />)
expect(screen.getByText('Add roles...')).toBeInTheDocument()
})
it('shows selected count on trigger', () => {
render(<MultiSelect options={OPTIONS} value={['admin', 'editor']} onChange={vi.fn()} />)
expect(screen.getByText('2 selected')).toBeInTheDocument()
})
it('opens dropdown on trigger click', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
await user.click(screen.getByRole('combobox'))
expect(screen.getByText('ADMIN')).toBeInTheDocument()
expect(screen.getByText('EDITOR')).toBeInTheDocument()
})
it('shows checkboxes for pre-selected values', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
await user.click(screen.getByRole('combobox'))
const adminCheckbox = screen.getByRole('checkbox', { name: 'ADMIN' })
expect(adminCheckbox).toBeChecked()
})
it('filters options by search text', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
await user.click(screen.getByRole('combobox'))
await user.type(screen.getByPlaceholderText('Search...'), 'adm')
expect(screen.getByText('ADMIN')).toBeInTheDocument()
expect(screen.queryByText('EDITOR')).not.toBeInTheDocument()
})
it('calls onChange with selected values on Apply', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
await user.click(screen.getByRole('checkbox', { name: 'VIEWER' }))
await user.click(screen.getByRole('button', { name: /Apply/ }))
expect(onChange).toHaveBeenCalledWith(['admin', 'viewer'])
})
it('discards pending changes on Escape', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
await user.keyboard('{Escape}')
expect(onChange).not.toHaveBeenCalled()
})
it('closes dropdown on outside click without applying', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
const { container } = render(
<div>
<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />
<button>Outside</button>
</div>
)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
await user.click(screen.getByText('Outside'))
expect(onChange).not.toHaveBeenCalled()
})
it('disables trigger when disabled prop is set', () => {
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} disabled />)
expect(screen.getByRole('combobox')).toHaveAttribute('aria-disabled', 'true')
})
it('hides search input when searchable is false', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} searchable={false} />)
await user.click(screen.getByRole('combobox'))
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
})
it('shows Apply button with count of pending changes', async () => {
const user = userEvent.setup()
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('checkbox', { name: 'EDITOR' }))
expect(screen.getByRole('button', { name: /Apply \(2\)/ })).toBeInTheDocument()
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/design-system/composites/MultiSelect
Expected: FAIL — module not found
Task 6: MultiSelect — Implementation
Files:
-
Create:
src/design-system/composites/MultiSelect/MultiSelect.tsx -
Create:
src/design-system/composites/MultiSelect/MultiSelect.module.css -
Step 1: Write MultiSelect component
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import styles from './MultiSelect.module.css'
export interface MultiSelectOption {
value: string
label: string
}
interface MultiSelectProps {
options: MultiSelectOption[]
value: string[]
onChange: (value: string[]) => void
placeholder?: string
searchable?: boolean
disabled?: boolean
className?: string
}
export function MultiSelect({
options,
value,
onChange,
placeholder = 'Select...',
searchable = true,
disabled = false,
className,
}: MultiSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [pending, setPending] = useState<string[]>(value)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
// Sync pending with value when opening
useEffect(() => {
if (open) {
setPending(value)
setSearch('')
}
}, [open, value])
// Position the panel below the trigger
useEffect(() => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
setPos({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}
}, [open])
// Close on outside click
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (
panelRef.current && !panelRef.current.contains(e.target as Node) &&
triggerRef.current && !triggerRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open])
function toggleOption(optValue: string) {
setPending((prev) =>
prev.includes(optValue) ? prev.filter((v) => v !== optValue) : [...prev, optValue]
)
}
function handleApply() {
onChange(pending)
setOpen(false)
}
const filtered = options.filter((opt) =>
opt.label.toLowerCase().includes(search.toLowerCase())
)
const triggerLabel = value.length > 0 ? `${value.length} selected` : placeholder
return (
<div className={`${styles.wrap} ${className ?? ''}`}>
<button
ref={triggerRef}
className={styles.trigger}
onClick={() => !disabled && setOpen(!open)}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-disabled={disabled}
type="button"
>
<span className={value.length > 0 ? styles.triggerText : styles.triggerPlaceholder}>
{triggerLabel}
</span>
<span className={styles.chevron} aria-hidden="true">▾</span>
</button>
{open && createPortal(
<div
ref={panelRef}
className={styles.panel}
style={{ top: pos.top, left: pos.left, width: Math.max(pos.width, 200) }}
>
{searchable && (
<input
className={styles.search}
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
)}
<div className={styles.optionList} role="listbox">
{filtered.map((opt) => (
<label key={opt.value} className={styles.option} role="option" aria-selected={pending.includes(opt.value)}>
<input
type="checkbox"
className={styles.checkbox}
checked={pending.includes(opt.value)}
onChange={() => toggleOption(opt.value)}
aria-label={opt.label}
/>
<span className={styles.optionLabel}>{opt.label}</span>
</label>
))}
{filtered.length === 0 && (
<div className={styles.empty}>No matches</div>
)}
</div>
<div className={styles.footer}>
<button
className={styles.applyBtn}
onClick={handleApply}
type="button"
>
Apply ({pending.length})
</button>
</div>
</div>,
document.body,
)}
</div>
)
}
- Step 2: Write MultiSelect CSS
/* MultiSelect.module.css */
.wrap {
position: relative;
display: inline-block;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
gap: 8px;
min-width: 0;
}
.trigger:focus-visible {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.trigger[aria-disabled="true"] {
opacity: 0.6;
cursor: not-allowed;
}
.triggerText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.triggerPlaceholder {
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
}
/* Dropdown panel (portaled) */
.panel {
position: fixed;
z-index: 1000;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
animation: panelIn 0.12s ease-out;
}
@keyframes panelIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.search {
padding: 8px 12px;
border: none;
border-bottom: 1px solid var(--border-subtle);
background: transparent;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
}
.search::placeholder {
color: var(--text-faint);
}
.optionList {
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
}
.option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
font-family: var(--font-body);
color: var(--text-primary);
transition: background 0.1s;
}
.option:hover {
background: var(--bg-hover);
}
.checkbox {
accent-color: var(--amber);
cursor: pointer;
}
.optionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: 12px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}
.footer {
padding: 8px 12px;
border-top: 1px solid var(--border-subtle);
display: flex;
justify-content: flex-end;
}
.applyBtn {
padding: 4px 16px;
border: none;
border-radius: var(--radius-sm);
background: var(--amber);
color: var(--bg-base);
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.applyBtn:hover {
background: var(--amber-hover);
}
- Step 3: Run tests to verify they pass
Run: npx vitest run src/design-system/composites/MultiSelect
Expected: All 12 tests PASS
- Step 4: Export MultiSelect from composites barrel
Add to src/design-system/composites/index.ts after the Modal export:
export { MultiSelect } from './MultiSelect/MultiSelect'
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
- Step 5: Commit
git add src/design-system/composites/MultiSelect/ src/design-system/composites/index.ts
git commit -m "feat: add MultiSelect composite component"
Task 7: Inventory — Add New Component Demos
Files:
-
Modify:
src/pages/Inventory/sections/PrimitivesSection.tsx -
Modify:
src/pages/Inventory/sections/CompositesSection.tsx -
Step 1: Add InlineEdit demo to PrimitivesSection
Import InlineEdit from the primitives barrel. Add state:
const [inlineValue, setInlineValue] = useState('Alice Johnson')
Add DemoCard after the input card (find the existing Input DemoCard and place this after it):
<DemoCard id="inline-edit" title="InlineEdit" description="Click-to-edit text field. Enter saves, Escape/blur cancels.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<InlineEdit value={inlineValue} onSave={setInlineValue} />
<InlineEdit value="" onSave={() => {}} placeholder="Click to add name..." />
<InlineEdit value="Read only" onSave={() => {}} disabled />
</div>
</DemoCard>
- Step 2: Add ConfirmDialog and MultiSelect demos to CompositesSection
Import ConfirmDialog and MultiSelect from composites barrel. Add state:
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmDone, setConfirmDone] = useState(false)
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
Add ConfirmDialog DemoCard after the existing AlertDialog demo:
<DemoCard id="confirm-dialog" title="ConfirmDialog" description="Type-to-confirm destructive action dialog. Built on Modal.">
<Button size="sm" variant="danger" onClick={() => { setConfirmOpen(true); setConfirmDone(false) }}>
Delete project
</Button>
{confirmDone && <span style={{ color: 'var(--success)', fontSize: 12, marginLeft: 8 }}>Deleted!</span>}
<ConfirmDialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => { setConfirmOpen(false); setConfirmDone(true) }}
message={'Delete project "my-project"? This cannot be undone.'}
confirmText="my-project"
/>
</DemoCard>
Add MultiSelect DemoCard after the Modal demo:
<DemoCard id="multi-select" title="MultiSelect" description="Dropdown with searchable checkbox list and Apply action.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
<MultiSelect
options={[
{ value: 'admin', label: 'ADMIN' },
{ value: 'editor', label: 'EDITOR' },
{ value: 'viewer', label: 'VIEWER' },
{ value: 'operator', label: 'OPERATOR' },
{ value: 'auditor', label: 'AUDITOR' },
]}
value={multiValue}
onChange={setMultiValue}
placeholder="Add roles..."
/>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Selected: {multiValue.join(', ') || 'none'}
</span>
</div>
</DemoCard>
- Step 3: Update Inventory nav to include new component anchors
No change needed — the Inventory nav links to #primitives, #composites, #layout sections, not individual components. The DemoCards with their id attributes are auto-scrollable via anchor links from anywhere.
- Step 4: Verify app compiles
Run: npx vite build 2>&1 | head -20
Expected: Build succeeds without errors
- Step 5: Commit
git add src/pages/Inventory/sections/PrimitivesSection.tsx src/pages/Inventory/sections/CompositesSection.tsx
git commit -m "feat: add InlineEdit, ConfirmDialog, MultiSelect to Inventory demos"
Task 8: Admin Layout + Routing
Files:
-
Modify:
src/App.tsx -
Modify:
src/pages/Admin/Admin.tsx -
Modify:
src/pages/Admin/Admin.module.css(read first to see existing styles) -
Modify:
src/design-system/layout/Sidebar/Sidebar.tsx -
Step 1: Read existing Admin.module.css
Read src/pages/Admin/Admin.module.css to understand current styles before modifying.
- Step 2: Update Sidebar active state for /admin/ paths*
In src/design-system/layout/Sidebar/Sidebar.tsx around line 442, change:
location.pathname === '/admin' ? styles.bottomItemActive : '',
to:
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
- Step 3: Add ToastProvider to main.tsx
In src/main.tsx, import ToastProvider and wrap it around <App />:
import { ToastProvider } from './design-system/composites/Toast/Toast'
Add <ToastProvider> inside the <CommandPaletteProvider>:
<CommandPaletteProvider>
<ToastProvider>
<App />
</ToastProvider>
</CommandPaletteProvider>
This is needed because the OidcConfig page uses useToast().
- Step 4: Update App.tsx with admin sub-routes
Import the new page components at the top of src/App.tsx:
import { AuditLog } from './pages/Admin/AuditLog/AuditLog'
import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig'
import { UserManagement } from './pages/Admin/UserManagement/UserManagement'
Replace the single /admin route:
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
with:
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
<Route path="/admin/audit" element={<AuditLog />} />
<Route path="/admin/oidc" element={<OidcConfig />} />
<Route path="/admin/rbac" element={<UserManagement />} />
Remove the Admin import since the placeholder is no longer used.
- Step 5: Rewrite Admin.tsx as a shared admin layout wrapper
Replace src/pages/Admin/Admin.tsx with a shared layout component that the individual admin pages will use:
import { useNavigate, useLocation } from 'react-router-dom'
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
import styles from './Admin.module.css'
import type { ReactNode } from 'react'
const ADMIN_TABS = [
{ label: 'User Management', path: '/admin/rbac' },
{ label: 'Audit Log', path: '/admin/audit' },
{ label: 'OIDC', path: '/admin/oidc' },
]
interface AdminLayoutProps {
title: string
children: ReactNode
}
export function AdminLayout({ title, children }: AdminLayoutProps) {
const navigate = useNavigate()
const location = useLocation()
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[
{ label: 'Admin', href: '/admin' },
{ label: title },
]}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<nav className={styles.adminNav} aria-label="Admin sections">
{ADMIN_TABS.map((tab) => (
<button
key={tab.path}
className={`${styles.adminTab} ${location.pathname === tab.path ? styles.adminTabActive : ''}`}
onClick={() => navigate(tab.path)}
type="button"
>
{tab.label}
</button>
))}
</nav>
<div className={styles.adminContent}>
{children}
</div>
</AppShell>
)
}
- Step 6: Write Admin.module.css admin nav styles
Add/replace styles in src/pages/Admin/Admin.module.css:
.adminNav {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-subtle);
padding: 0 20px;
background: var(--bg-base);
}
.adminTab {
padding: 10px 16px;
border: none;
background: none;
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.adminTab:hover {
color: var(--text-primary);
}
.adminTabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.adminContent {
flex: 1;
overflow-y: auto;
padding: 20px;
}
- Step 7: Commit
git add src/App.tsx src/main.tsx src/pages/Admin/Admin.tsx src/pages/Admin/Admin.module.css src/design-system/layout/Sidebar/Sidebar.tsx
git commit -m "feat: add admin layout with sub-navigation and routing"
Note: This will not compile yet because the page components (AuditLog, OidcConfig, UserManagement) don't exist yet. That's expected — the next tasks create them.
Task 9: Audit Log Page — Mock Data
Files:
-
Create:
src/pages/Admin/AuditLog/auditMocks.ts -
Step 1: Write audit mock data
export interface AuditEvent {
id: number
timestamp: string
username: string
category: 'INFRA' | 'AUTH' | 'USER_MGMT' | 'CONFIG'
action: string
target: string
result: 'SUCCESS' | 'FAILURE'
detail: Record<string, unknown>
ipAddress: string
userAgent: string
}
const now = Date.now()
const hour = 3600_000
const day = 24 * hour
export const AUDIT_EVENTS: AuditEvent[] = [
{
id: 1, timestamp: new Date(now - 0.5 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
target: 'users/alice', result: 'SUCCESS',
detail: { displayName: 'Alice Johnson', roles: ['VIEWER'] },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 2, timestamp: new Date(now - 1.2 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
target: 'db/primary', result: 'SUCCESS',
detail: { oldSize: 10, newSize: 20, reason: 'auto-scale' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 3, timestamp: new Date(now - 2 * hour).toISOString(),
username: 'alice', category: 'AUTH', action: 'LOGIN',
target: 'sessions/abc123', result: 'SUCCESS',
detail: { method: 'OIDC', provider: 'keycloak' },
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
},
{
id: 4, timestamp: new Date(now - 2.5 * hour).toISOString(),
username: 'unknown', category: 'AUTH', action: 'LOGIN',
target: 'sessions', result: 'FAILURE',
detail: { method: 'local', reason: 'invalid_credentials' },
ipAddress: '203.0.113.50', userAgent: 'curl/8.1',
},
{
id: 5, timestamp: new Date(now - 3 * hour).toISOString(),
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
target: 'thresholds/pool-connections', result: 'SUCCESS',
detail: { field: 'maxConnections', oldValue: 50, newValue: 100 },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 6, timestamp: new Date(now - 4 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
target: 'users/bob', result: 'SUCCESS',
detail: { role: 'EDITOR', method: 'direct' },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 7, timestamp: new Date(now - 5 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
target: 'opensearch/exchanges', result: 'SUCCESS',
detail: { documents: 15420, duration: '12.3s' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 8, timestamp: new Date(now - 6 * hour).toISOString(),
username: 'bob', category: 'AUTH', action: 'LOGIN',
target: 'sessions/def456', result: 'SUCCESS',
detail: { method: 'local' },
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
},
{
id: 9, timestamp: new Date(now - 8 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_GROUP',
target: 'groups/developers', result: 'SUCCESS',
detail: { parent: null },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 10, timestamp: new Date(now - 10 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'BACKUP',
target: 'db/primary', result: 'SUCCESS',
detail: { sizeBytes: 524288000, duration: '45s' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 11, timestamp: new Date(now - 12 * hour).toISOString(),
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_OIDC',
target: 'config/oidc', result: 'SUCCESS',
detail: { field: 'autoSignup', oldValue: false, newValue: true },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 12, timestamp: new Date(now - 1 * day).toISOString(),
username: 'alice', category: 'AUTH', action: 'LOGOUT',
target: 'sessions/abc123', result: 'SUCCESS',
detail: { reason: 'user_initiated' },
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
},
{
id: 13, timestamp: new Date(now - 1 * day - 2 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'DELETE_USER',
target: 'users/temp-user', result: 'SUCCESS',
detail: { reason: 'cleanup' },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 14, timestamp: new Date(now - 1 * day - 4 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
target: 'db/primary', result: 'FAILURE',
detail: { oldSize: 20, newSize: 50, error: 'max_connections_exceeded' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 15, timestamp: new Date(now - 1 * day - 6 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'UPDATE_GROUP',
target: 'groups/admins', result: 'SUCCESS',
detail: { addedMembers: ['alice'], removedMembers: [] },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 16, timestamp: new Date(now - 2 * day).toISOString(),
username: 'bob', category: 'AUTH', action: 'PASSWORD_CHANGE',
target: 'users/bob', result: 'SUCCESS',
detail: { method: 'self_service' },
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
},
{
id: 17, timestamp: new Date(now - 2 * day - 3 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'VACUUM',
target: 'db/primary/exchanges', result: 'SUCCESS',
detail: { reclaimedBytes: 1048576, duration: '3.2s' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 18, timestamp: new Date(now - 2 * day - 5 * hour).toISOString(),
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
target: 'thresholds/latency-p99', result: 'SUCCESS',
detail: { field: 'warningMs', oldValue: 500, newValue: 300 },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 19, timestamp: new Date(now - 3 * day).toISOString(),
username: 'attacker', category: 'AUTH', action: 'LOGIN',
target: 'sessions', result: 'FAILURE',
detail: { method: 'local', reason: 'account_locked', attempts: 5 },
ipAddress: '198.51.100.23', userAgent: 'python-requests/2.31',
},
{
id: 20, timestamp: new Date(now - 3 * day - 2 * hour).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
target: 'groups/developers', result: 'SUCCESS',
detail: { role: 'EDITOR', method: 'group_assignment' },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 21, timestamp: new Date(now - 4 * day).toISOString(),
username: 'system', category: 'INFRA', action: 'BACKUP',
target: 'db/primary', result: 'FAILURE',
detail: { error: 'disk_full', sizeBytes: 0 },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 22, timestamp: new Date(now - 4 * day - 1 * hour).toISOString(),
username: 'alice', category: 'CONFIG', action: 'VIEW_CONFIG',
target: 'config/oidc', result: 'SUCCESS',
detail: { section: 'provider_settings' },
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
},
{
id: 23, timestamp: new Date(now - 5 * day).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_ROLE',
target: 'roles/OPERATOR', result: 'SUCCESS',
detail: { scope: 'custom', description: 'Pipeline operator' },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
{
id: 24, timestamp: new Date(now - 5 * day - 3 * hour).toISOString(),
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
target: 'opensearch/agents', result: 'SUCCESS',
detail: { documents: 230, duration: '1.1s' },
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
},
{
id: 25, timestamp: new Date(now - 6 * day).toISOString(),
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
target: 'users/bob', result: 'SUCCESS',
detail: { displayName: 'Bob Smith', roles: ['VIEWER'] },
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
},
]
- Step 2: Commit
git add src/pages/Admin/AuditLog/auditMocks.ts
git commit -m "feat: add audit log mock data"
Task 10: Audit Log Page — Component
Files:
-
Create:
src/pages/Admin/AuditLog/AuditLog.tsx -
Create:
src/pages/Admin/AuditLog/AuditLog.module.css -
Step 1: Write AuditLog page
import { useState, useMemo } from 'react'
import { AdminLayout } from '../Admin'
import { Badge } from '../../../design-system/primitives/Badge/Badge'
import { DateRangePicker } from '../../../design-system/primitives/DateRangePicker/DateRangePicker'
import { Input } from '../../../design-system/primitives/Input/Input'
import { Select } from '../../../design-system/primitives/Select/Select'
import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock'
import { Pagination } from '../../../design-system/primitives/Pagination/Pagination'
import type { DateRange } from '../../../design-system/utils/timePresets'
import { AUDIT_EVENTS, type AuditEvent } from './auditMocks'
import styles from './AuditLog.module.css'
const CATEGORIES = [
{ value: '', label: 'All categories' },
{ value: 'INFRA', label: 'INFRA' },
{ value: 'AUTH', label: 'AUTH' },
{ value: 'USER_MGMT', label: 'USER_MGMT' },
{ value: 'CONFIG', label: 'CONFIG' },
]
const PAGE_SIZE = 10
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
})
}
const now = Date.now()
const INITIAL_RANGE: DateRange = {
from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16),
to: new Date(now).toISOString().slice(0, 16),
}
export function AuditLog() {
const [dateRange, setDateRange] = useState<DateRange>(INITIAL_RANGE)
const [userFilter, setUserFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [searchFilter, setSearchFilter] = useState('')
const [page, setPage] = useState(1)
const [expandedId, setExpandedId] = useState<number | null>(null)
const filtered = useMemo(() => {
const from = new Date(dateRange.from).getTime()
const to = new Date(dateRange.to).getTime()
return AUDIT_EVENTS.filter((e) => {
const ts = new Date(e.timestamp).getTime()
if (ts < from || ts > to) return false
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
if (categoryFilter && e.category !== categoryFilter) return false
if (searchFilter) {
const q = searchFilter.toLowerCase()
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
}
return true
})
}, [dateRange, userFilter, categoryFilter, searchFilter])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pageEvents = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return (
<AdminLayout title="Audit Log">
<div className={styles.header}>
<h2 className={styles.title}>Audit Log</h2>
<Badge label={`${filtered.length} events`} color="primary" />
</div>
<div className={styles.filters}>
<DateRangePicker
value={dateRange}
onChange={(r) => { setDateRange(r); setPage(1) }}
/>
<Input
placeholder="Filter by user..."
value={userFilter}
onChange={(e) => { setUserFilter(e.target.value); setPage(1) }}
onClear={() => { setUserFilter(''); setPage(1) }}
className={styles.filterInput}
/>
<Select
options={CATEGORIES}
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }}
className={styles.filterSelect}
/>
<Input
placeholder="Search action or target..."
value={searchFilter}
onChange={(e) => { setSearchFilter(e.target.value); setPage(1) }}
onClear={() => { setSearchFilter(''); setPage(1) }}
className={styles.filterInput}
/>
</div>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th} style={{ width: 170 }}>Timestamp</th>
<th className={styles.th}>User</th>
<th className={styles.th} style={{ width: 100 }}>Category</th>
<th className={styles.th}>Action</th>
<th className={styles.th}>Target</th>
<th className={styles.th} style={{ width: 80 }}>Result</th>
</tr>
</thead>
<tbody>
{pageEvents.map((event) => (
<EventRow
key={event.id}
event={event}
expanded={expandedId === event.id}
onToggle={() => setExpandedId(expandedId === event.id ? null : event.id)}
/>
))}
{pageEvents.length === 0 && (
<tr>
<td colSpan={6} className={styles.empty}>No events match the current filters.</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</AdminLayout>
)
}
function EventRow({ event, expanded, onToggle }: { event: AuditEvent; expanded: boolean; onToggle: () => void }) {
return (
<>
<tr className={styles.row} onClick={onToggle}>
<td className={styles.td}>
<MonoText size="xs">{formatTimestamp(event.timestamp)}</MonoText>
</td>
<td className={`${styles.td} ${styles.userCell}`}>{event.username}</td>
<td className={styles.td}>
<Badge label={event.category} color="auto" />
</td>
<td className={styles.td}>{event.action}</td>
<td className={styles.td}>
<span className={styles.target}>{event.target}</span>
</td>
<td className={styles.td}>
<Badge
label={event.result}
color={event.result === 'SUCCESS' ? 'success' : 'error'}
/>
</td>
</tr>
{expanded && (
<tr className={styles.detailRow}>
<td colSpan={6} className={styles.detailCell}>
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<MonoText size="xs">{event.ipAddress}</MonoText>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{event.userAgent}</span>
</div>
</div>
<div className={styles.detailJson}>
<span className={styles.detailLabel}>Detail</span>
<CodeBlock content={JSON.stringify(event.detail, null, 2)} language="json" />
</div>
</td>
</tr>
)}
</>
)
}
- Step 2: Write AuditLog CSS
/* AuditLog.module.css */
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
font-family: var(--font-body);
}
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filterInput {
width: 200px;
}
.filterSelect {
width: 160px;
}
.tableWrap {
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-body);
font-size: 12px;
}
.th {
text-align: left;
padding: 10px 12px;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
background: var(--bg-raised);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 0;
z-index: 1;
}
.row {
cursor: pointer;
transition: background 0.1s;
}
.row:hover {
background: var(--bg-hover);
}
.td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
vertical-align: middle;
}
.userCell {
font-weight: 500;
}
.target {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: 32px;
text-align: center;
color: var(--text-faint);
}
.detailRow {
background: var(--bg-raised);
}
.detailCell {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.detailGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.detailField {
display: flex;
flex-direction: column;
gap: 4px;
}
.detailLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
font-family: var(--font-body);
}
.detailValue {
font-size: 12px;
color: var(--text-secondary);
}
.detailJson {
display: flex;
flex-direction: column;
gap: 6px;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 16px;
}
- Step 3: Verify compilation (may still fail due to missing pages — that's OK)
Run: npx vitest run src/pages/Admin/AuditLog --passWithNoTests 2>&1 | tail -5
Expected: No test files to run (passWithNoTests)
- Step 4: Commit
git add src/pages/Admin/AuditLog/
git commit -m "feat: add Audit Log admin page"
Task 11: OIDC Config Page
Files:
-
Create:
src/pages/Admin/OidcConfig/OidcConfig.tsx -
Create:
src/pages/Admin/OidcConfig/OidcConfig.module.css -
Step 1: Write OidcConfig page
import { useState } from 'react'
import { AdminLayout } from '../Admin'
import { Button } from '../../../design-system/primitives/Button/Button'
import { Input } from '../../../design-system/primitives/Input/Input'
import { Toggle } from '../../../design-system/primitives/Toggle/Toggle'
import { FormField } from '../../../design-system/primitives/FormField/FormField'
import { Tag } from '../../../design-system/primitives/Tag/Tag'
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
import { useToast } from '../../../design-system/composites/Toast/Toast'
import styles from './OidcConfig.module.css'
interface OidcFormData {
enabled: boolean
autoSignup: boolean
issuerUri: string
clientId: string
clientSecret: string
rolesClaim: string
displayNameClaim: string
defaultRoles: string[]
}
const INITIAL_DATA: OidcFormData = {
enabled: true,
autoSignup: true,
issuerUri: 'https://keycloak.example.com/realms/cameleer',
clientId: 'cameleer-app',
clientSecret: '••••••••••••',
rolesClaim: 'realm_access.roles',
displayNameClaim: 'name',
defaultRoles: ['USER', 'VIEWER'],
}
export function OidcConfig() {
const [form, setForm] = useState<OidcFormData>(INITIAL_DATA)
const [newRole, setNewRole] = useState('')
const [deleteOpen, setDeleteOpen] = useState(false)
const { toast } = useToast()
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
setForm((prev) => ({ ...prev, [key]: value }))
}
function addRole() {
const role = newRole.trim().toUpperCase()
if (role && !form.defaultRoles.includes(role)) {
update('defaultRoles', [...form.defaultRoles, role])
setNewRole('')
}
}
function removeRole(role: string) {
update('defaultRoles', form.defaultRoles.filter((r) => r !== role))
}
function handleSave() {
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' })
}
function handleTest() {
toast({ title: 'Connection test', description: 'OIDC provider responded successfully.', variant: 'info' })
}
function handleDelete() {
setDeleteOpen(false)
setForm({ ...INITIAL_DATA, enabled: false, issuerUri: '', clientId: '', clientSecret: '', defaultRoles: [] })
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' })
}
return (
<AdminLayout title="OIDC Configuration">
<div className={styles.page}>
<div className={styles.header}>
<h2 className={styles.title}>OIDC Configuration</h2>
<div className={styles.headerActions}>
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
Test Connection
</Button>
<Button size="sm" variant="primary" onClick={handleSave}>
Save
</Button>
</div>
</div>
<section className={styles.section}>
<SectionHeader>Behavior</SectionHeader>
<div className={styles.toggleRow}>
<Toggle
label="Enabled"
checked={form.enabled}
onChange={(e) => update('enabled', e.target.checked)}
/>
</div>
<div className={styles.toggleRow}>
<Toggle
label="Auto Sign-Up"
checked={form.autoSignup}
onChange={(e) => update('autoSignup', e.target.checked)}
/>
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
</div>
</section>
<section className={styles.section}>
<SectionHeader>Provider Settings</SectionHeader>
<FormField label="Issuer URI" htmlFor="issuer">
<Input
id="issuer"
type="url"
placeholder="https://idp.example.com/realms/my-realm"
value={form.issuerUri}
onChange={(e) => update('issuerUri', e.target.value)}
/>
</FormField>
<FormField label="Client ID" htmlFor="client-id">
<Input
id="client-id"
value={form.clientId}
onChange={(e) => update('clientId', e.target.value)}
/>
</FormField>
<FormField label="Client Secret" htmlFor="client-secret">
<Input
id="client-secret"
type="password"
value={form.clientSecret}
onChange={(e) => update('clientSecret', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Claim Mapping</SectionHeader>
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
<Input
id="roles-claim"
value={form.rolesClaim}
onChange={(e) => update('rolesClaim', e.target.value)}
/>
</FormField>
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
<Input
id="name-claim"
value={form.displayNameClaim}
onChange={(e) => update('displayNameClaim', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Default Roles</SectionHeader>
<div className={styles.tagList}>
{form.defaultRoles.map((role) => (
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
))}
{form.defaultRoles.length === 0 && (
<span className={styles.noRoles}>No default roles configured</span>
)}
</div>
<div className={styles.addRoleRow}>
<Input
placeholder="Add role..."
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole() } }}
className={styles.roleInput}
/>
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
Add
</Button>
</div>
</section>
<section className={styles.section}>
<SectionHeader>Danger Zone</SectionHeader>
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
Delete OIDC Configuration
</Button>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
confirmText="delete oidc"
/>
</section>
</div>
</AdminLayout>
)
}
- Step 2: Write OidcConfig CSS
/* OidcConfig.module.css */
.page {
max-width: 640px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
font-family: var(--font-body);
}
.headerActions {
display: flex;
gap: 8px;
}
.section {
margin-bottom: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.toggleRow {
display: flex;
align-items: center;
gap: 12px;
}
.hint {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-body);
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.noRoles {
font-size: 12px;
color: var(--text-faint);
font-style: italic;
font-family: var(--font-body);
}
.addRoleRow {
display: flex;
gap: 8px;
align-items: center;
}
.roleInput {
width: 200px;
}
- Step 3: Commit
git add src/pages/Admin/OidcConfig/
git commit -m "feat: add OIDC Config admin page"
Task 12: User Management — Mock Data
Files:
-
Create:
src/pages/Admin/UserManagement/rbacMocks.ts -
Step 1: Write RBAC mock data
export interface MockUser {
id: string
username: string
displayName: string
email: string
provider: 'local' | 'oidc'
createdAt: string
directRoles: string[]
directGroups: string[]
}
export interface MockGroup {
id: string
name: string
parentId: string | null
builtIn: boolean
directRoles: string[]
memberUserIds: string[]
}
export interface MockRole {
id: string
name: string
description: string
scope: 'system' | 'custom'
system: boolean
}
export const MOCK_ROLES: MockRole[] = [
{ id: 'role-1', name: 'ADMIN', description: 'Full system access', scope: 'system', system: true },
{ id: 'role-2', name: 'USER', description: 'Standard user access', scope: 'system', system: true },
{ id: 'role-3', name: 'EDITOR', description: 'Can modify routes and configurations', scope: 'custom', system: false },
{ id: 'role-4', name: 'VIEWER', description: 'Read-only access to all resources', scope: 'custom', system: false },
{ id: 'role-5', name: 'OPERATOR', description: 'Pipeline operator — start, stop, monitor', scope: 'custom', system: false },
{ id: 'role-6', name: 'AUDITOR', description: 'Access to audit logs and compliance data', scope: 'custom', system: false },
]
export const MOCK_GROUPS: MockGroup[] = [
{ id: 'grp-1', name: 'ADMINS', parentId: null, builtIn: true, directRoles: ['ADMIN'], memberUserIds: ['usr-1'] },
{ id: 'grp-2', name: 'Developers', parentId: null, builtIn: false, directRoles: ['EDITOR'], memberUserIds: ['usr-2', 'usr-3'] },
{ id: 'grp-3', name: 'Frontend', parentId: 'grp-2', builtIn: false, directRoles: ['VIEWER'], memberUserIds: ['usr-4'] },
{ id: 'grp-4', name: 'Operations', parentId: null, builtIn: false, directRoles: ['OPERATOR', 'VIEWER'], memberUserIds: ['usr-5', 'usr-6'] },
]
export const MOCK_USERS: MockUser[] = [
{
id: 'usr-1', username: 'hendrik', displayName: 'Hendrik Siegeln',
email: 'hendrik@example.com', provider: 'local', createdAt: '2025-01-15T10:00:00Z',
directRoles: ['ADMIN'], directGroups: ['grp-1'],
},
{
id: 'usr-2', username: 'alice', displayName: 'Alice Johnson',
email: 'alice@example.com', provider: 'oidc', createdAt: '2025-03-20T14:30:00Z',
directRoles: ['VIEWER'], directGroups: ['grp-2'],
},
{
id: 'usr-3', username: 'bob', displayName: 'Bob Smith',
email: 'bob@example.com', provider: 'local', createdAt: '2025-04-10T09:00:00Z',
directRoles: [], directGroups: ['grp-2'],
},
{
id: 'usr-4', username: 'carol', displayName: 'Carol Davis',
email: 'carol@example.com', provider: 'oidc', createdAt: '2025-06-01T11:15:00Z',
directRoles: [], directGroups: ['grp-3'],
},
{
id: 'usr-5', username: 'dave', displayName: 'Dave Wilson',
email: 'dave@example.com', provider: 'local', createdAt: '2025-07-22T16:45:00Z',
directRoles: ['AUDITOR'], directGroups: ['grp-4'],
},
{
id: 'usr-6', username: 'eve', displayName: 'Eve Martinez',
email: 'eve@example.com', provider: 'oidc', createdAt: '2025-09-05T08:20:00Z',
directRoles: [], directGroups: ['grp-4'],
},
{
id: 'usr-7', username: 'frank', displayName: 'Frank Brown',
email: 'frank@example.com', provider: 'local', createdAt: '2025-11-12T13:00:00Z',
directRoles: ['USER'], directGroups: [],
},
{
id: 'usr-8', username: 'grace', displayName: 'Grace Lee',
email: 'grace@example.com', provider: 'oidc', createdAt: '2026-01-08T10:30:00Z',
directRoles: ['VIEWER', 'AUDITOR'], directGroups: [],
},
]
/** Resolve all roles for a user, including those inherited from groups */
export function getEffectiveRoles(user: MockUser): Array<{ role: string; source: 'direct' | string }> {
const result: Array<{ role: string; source: 'direct' | string }> = []
const seen = new Set<string>()
// Direct roles
for (const role of user.directRoles) {
result.push({ role, source: 'direct' })
seen.add(role)
}
// Walk group chain for inherited roles
function walkGroup(groupId: string) {
const group = MOCK_GROUPS.find((g) => g.id === groupId)
if (!group) return
for (const role of group.directRoles) {
if (!seen.has(role)) {
result.push({ role, source: group.name })
seen.add(role)
}
}
// Walk parent group
if (group.parentId) walkGroup(group.parentId)
}
for (const groupId of user.directGroups) {
walkGroup(groupId)
}
return result
}
/** Get all groups in the chain (self + ancestors) for display */
export function getGroupChain(groupId: string): MockGroup[] {
const chain: MockGroup[] = []
let current = MOCK_GROUPS.find((g) => g.id === groupId)
while (current) {
chain.unshift(current)
current = current.parentId ? MOCK_GROUPS.find((g) => g.id === current!.parentId) : undefined
}
return chain
}
/** Get child groups of a given group */
export function getChildGroups(groupId: string): MockGroup[] {
return MOCK_GROUPS.filter((g) => g.parentId === groupId)
}
- Step 2: Commit
git add src/pages/Admin/UserManagement/rbacMocks.ts
git commit -m "feat: add RBAC mock data with users, groups, roles"
Task 13: User Management — Container + UsersTab
Files:
-
Create:
src/pages/Admin/UserManagement/UserManagement.tsx -
Create:
src/pages/Admin/UserManagement/UserManagement.module.css -
Create:
src/pages/Admin/UserManagement/UsersTab.tsx -
Step 1: Write UserManagement container
import { useState } from 'react'
import { AdminLayout } from '../Admin'
import { Tabs } from '../../../design-system/composites/Tabs/Tabs'
import { UsersTab } from './UsersTab'
import { GroupsTab } from './GroupsTab'
import { RolesTab } from './RolesTab'
const TABS = [
{ label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups' },
{ label: 'Roles', value: 'roles' },
]
export function UserManagement() {
const [tab, setTab] = useState('users')
return (
<AdminLayout title="User Management">
<Tabs tabs={TABS} active={tab} onChange={setTab} />
<div style={{ marginTop: 16 }}>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</div>
</AdminLayout>
)
}
- Step 2: Write shared UserManagement CSS
/* UserManagement.module.css */
.splitPane {
display: grid;
grid-template-columns: 52fr 48fr;
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
min-height: 500px;
}
.listPane {
background: var(--bg-base);
display: flex;
flex-direction: column;
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
.detailPane {
background: var(--bg-base);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeaderSearch {
flex: 1;
}
.entityList {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--bg-raised);
}
.entityInfo {
flex: 1;
min-width: 0;
}
.entityName {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-body);
}
.entityMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-body);
margin-top: 2px;
}
.entityTags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.detailHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.detailHeaderInfo {
flex: 1;
min-width: 0;
}
.detailName {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-body);
}
.detailEmail {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-body);
}
.metaGrid {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin-bottom: 16px;
font-size: 12px;
font-family: var(--font-body);
}
.metaLabel {
color: var(--text-muted);
font-weight: 500;
}
.metaValue {
color: var(--text-primary);
}
.sectionTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
margin-bottom: 8px;
}
.selectWrap {
margin-top: 8px;
max-width: 240px;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-faint);
font-size: 13px;
font-family: var(--font-body);
}
.createForm {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised);
display: flex;
flex-direction: column;
gap: 8px;
}
.createFormRow {
display: flex;
gap: 8px;
}
.createFormActions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.inheritedNote {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
font-family: var(--font-body);
margin-top: 4px;
}
- Step 3: Write UsersTab
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 { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit'
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks'
import styles from './UserManagement.module.css'
export function UsersTab() {
const [users, setUsers] = useState(MOCK_USERS)
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<MockUser | null>(null)
// Create form state
const [newUsername, setNewUsername] = useState('')
const [newDisplay, setNewDisplay] = useState('')
const [newEmail, setNewEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const filtered = useMemo(() => {
if (!search) return users
const q = search.toLowerCase()
return users.filter((u) =>
u.displayName.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.username.toLowerCase().includes(q)
)
}, [users, search])
const selected = users.find((u) => u.id === selectedId) ?? null
function handleCreate() {
if (!newUsername.trim()) return
const newUser: MockUser = {
id: `usr-${Date.now()}`,
username: newUsername.trim(),
displayName: newDisplay.trim() || newUsername.trim(),
email: newEmail.trim(),
provider: 'local',
createdAt: new Date().toISOString(),
directRoles: [],
directGroups: [],
}
setUsers((prev) => [...prev, newUser])
setCreating(false)
setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword('')
setSelectedId(newUser.id)
}
function handleDelete() {
if (!deleteTarget) return
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
if (selectedId === deleteTarget.id) setSelectedId(null)
setDeleteTarget(null)
}
function updateUser(id: string, patch: Partial<MockUser>) {
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, ...patch } : u))
}
const effectiveRoles = selected ? getEffectiveRoles(selected) : []
const availableGroups = MOCK_GROUPS.filter((g) => !selected?.directGroups.includes(g.id))
.map((g) => ({ value: g.id, label: g.name }))
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
.map((r) => ({ value: r.name, label: r.name }))
function getUserGroupPath(user: MockUser): string {
if (user.directGroups.length === 0) return 'no groups'
const group = MOCK_GROUPS.find((g) => g.id === user.directGroups[0])
if (!group) return 'no groups'
const parent = group.parentId ? MOCK_GROUPS.find((g) => g.id === group.parentId) : null
return parent ? `${parent.name} > ${group.name}` : group.name
}
return (
<>
<div className={styles.splitPane}>
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
className={styles.listHeaderSearch}
/>
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
+ Add user
</Button>
</div>
{creating && (
<div className={styles.createForm}>
<div className={styles.createFormRow}>
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
</div>
<div className={styles.createFormRow}>
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
<Input placeholder="Password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</div>
<div className={styles.createFormActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newUsername.trim()}>Create</Button>
</div>
</div>
)}
<div className={styles.entityList}>
{filtered.map((user) => (
<div
key={user.id}
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(user.id)}
>
<Avatar name={user.displayName} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{user.displayName}
{user.provider !== 'local' && (
<Badge label={user.provider} color="running" variant="outlined" className={styles.providerBadge} />
)}
</div>
<div className={styles.entityMeta}>
{user.email} · {getUserGroupPath(user)}
</div>
<div className={styles.entityTags}>
{user.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
{user.directGroups.map((gId) => {
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
return g ? <Badge key={gId} label={g.name} color="success" /> : null
})}
</div>
</div>
</div>
))}
</div>
</div>
<div className={styles.detailPane}>
{selected ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.displayName} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
<InlineEdit
value={selected.displayName}
onSave={(v) => updateUser(selected.id, { displayName: v })}
/>
</div>
<div className={styles.detailEmail}>{selected.email}</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
disabled={selected.username === 'hendrik'}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>Status</span>
<Badge label="Active" color="success" />
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.id}</MonoText>
<span className={styles.metaLabel}>Created</span>
<span className={styles.metaValue}>{new Date(selected.createdAt).toLocaleDateString()}</span>
<span className={styles.metaLabel}>Provider</span>
<span className={styles.metaValue}>{selected.provider}</span>
</div>
<SectionHeader>Group membership (direct only)</SectionHeader>
<div className={styles.sectionTags}>
{selected.directGroups.map((gId) => {
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
return g ? (
<Tag
key={gId}
label={g.name}
color="success"
onRemove={() => updateUser(selected.id, {
directGroups: selected.directGroups.filter((id) => id !== gId),
})}
/>
) : null
})}
{selected.directGroups.length === 0 && (
<span className={styles.inheritedNote}>(no groups)</span>
)}
</div>
<div className={styles.selectWrap}>
<MultiSelect
options={availableGroups}
value={[]}
onChange={(ids) => updateUser(selected.id, {
directGroups: [...selected.directGroups, ...ids],
})}
placeholder="Add groups..."
/>
</div>
<SectionHeader>Effective roles (direct + inherited)</SectionHeader>
<div className={styles.sectionTags}>
{effectiveRoles.map(({ role, source }) => (
<Tag
key={role}
label={source === 'direct' ? role : `${role} ↑ ${source}`}
color="warning"
onRemove={source === 'direct' ? () => updateUser(selected.id, {
directRoles: selected.directRoles.filter((r) => r !== role),
}) : undefined}
/>
))}
{effectiveRoles.length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
</div>
{effectiveRoles.some((r) => r.source !== 'direct') && (
<span className={styles.inheritedNote}>
Roles with ↑ are inherited through group membership
</span>
)}
<div className={styles.selectWrap}>
<MultiSelect
options={availableRoles}
value={[]}
onChange={(roles) => updateUser(selected.id, {
directRoles: [...selected.directRoles, ...roles],
})}
placeholder="Add roles..."
/>
</div>
</>
) : (
<div className={styles.emptyDetail}>Select a user to view details</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete user "${deleteTarget?.username}"? This cannot be undone.`}
confirmText={deleteTarget?.username ?? ''}
/>
</>
)
}
- Step 4: Commit
git add src/pages/Admin/UserManagement/UserManagement.tsx src/pages/Admin/UserManagement/UserManagement.module.css src/pages/Admin/UserManagement/UsersTab.tsx
git commit -m "feat: add UserManagement container and UsersTab"
Task 14: User Management — GroupsTab
Files:
-
Create:
src/pages/Admin/UserManagement/GroupsTab.tsx -
Step 1: Write GroupsTab
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 { Select } from '../../../design-system/primitives/Select/Select'
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 { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit'
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, getChildGroups, type MockGroup } from './rbacMocks'
import styles from './UserManagement.module.css'
export function GroupsTab() {
const [groups, setGroups] = useState(MOCK_GROUPS)
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<MockGroup | null>(null)
const [newName, setNewName] = useState('')
const [newParent, setNewParent] = useState('')
const filtered = useMemo(() => {
if (!search) return groups
const q = search.toLowerCase()
return groups.filter((g) => g.name.toLowerCase().includes(q))
}, [groups, search])
const selected = groups.find((g) => g.id === selectedId) ?? null
function handleCreate() {
if (!newName.trim()) return
const newGroup: MockGroup = {
id: `grp-${Date.now()}`,
name: newName.trim(),
parentId: newParent || null,
builtIn: false,
directRoles: [],
memberUserIds: [],
}
setGroups((prev) => [...prev, newGroup])
setCreating(false)
setNewName(''); setNewParent('')
setSelectedId(newGroup.id)
}
function handleDelete() {
if (!deleteTarget) return
setGroups((prev) => prev.filter((g) => g.id !== deleteTarget.id))
if (selectedId === deleteTarget.id) setSelectedId(null)
setDeleteTarget(null)
}
function updateGroup(id: string, patch: Partial<MockGroup>) {
setGroups((prev) => prev.map((g) => g.id === id ? { ...g, ...patch } : g))
}
const children = selected ? getChildGroups(selected.id) : []
const members = selected ? MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)) : []
const parent = selected?.parentId ? groups.find((g) => g.id === selected.parentId) : null
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
.map((r) => ({ value: r.name, label: r.name }))
const parentOptions = [
{ value: '', label: 'Top-level' },
...groups.filter((g) => g.id !== selectedId).map((g) => ({ value: g.id, label: g.name })),
]
return (
<>
<div className={styles.splitPane}>
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search groups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
className={styles.listHeaderSearch}
/>
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
+ Add group
</Button>
</div>
{creating && (
<div className={styles.createForm}>
<Input placeholder="Group name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
<Select
options={parentOptions}
value={newParent}
onChange={(e) => setNewParent(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((group) => {
const groupChildren = getChildGroups(group.id)
const groupMembers = MOCK_USERS.filter((u) => u.directGroups.includes(group.id))
const groupParent = group.parentId ? groups.find((g) => g.id === group.parentId) : null
return (
<div
key={group.id}
className={`${styles.entityItem} ${selectedId === group.id ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(group.id)}
>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{groupParent ? `Child of ${groupParent.name}` : 'Top-level'}
{' · '}{groupChildren.length} children · {groupMembers.length} members
</div>
<div className={styles.entityTags}>
{group.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
</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.builtIn ? selected.name : (
<InlineEdit
value={selected.name}
onSave={(v) => updateGroup(selected.id, { name: v })}
/>
)}
</div>
<div className={styles.detailEmail}>
{parent ? `${parent.name} > ${selected.name}` : 'Top-level group'}
{selected.builtIn && ' (built-in)'}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
disabled={selected.builtIn}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.id}</MonoText>
<span className={styles.metaLabel}>Parent</span>
<span className={styles.metaValue}>{parent?.name ?? '(none)'}</span>
</div>
<SectionHeader>Members (direct)</SectionHeader>
<div className={styles.sectionTags}>
{members.map((u) => (
<Tag key={u.id} label={u.displayName} color="auto" />
))}
{members.length === 0 && <span className={styles.inheritedNote}>(no members)</span>}
</div>
{children.length > 0 && (
<span className={styles.inheritedNote}>
+ all members of {children.map((c) => c.name).join(', ')}
</span>
)}
{children.length > 0 && (
<>
<SectionHeader>Child groups</SectionHeader>
<div className={styles.sectionTags}>
{children.map((c) => <Tag key={c.id} label={c.name} color="success" />)}
</div>
</>
)}
<SectionHeader>Assigned roles</SectionHeader>
<div className={styles.sectionTags}>
{selected.directRoles.map((r) => (
<Tag
key={r}
label={r}
color="warning"
onRemove={() => updateGroup(selected.id, {
directRoles: selected.directRoles.filter((role) => role !== r),
})}
/>
))}
{selected.directRoles.length === 0 && <span className={styles.inheritedNote}>(no roles)</span>}
</div>
<div className={styles.selectWrap}>
<MultiSelect
options={availableRoles}
value={[]}
onChange={(roles) => updateGroup(selected.id, {
directRoles: [...selected.directRoles, ...roles],
})}
placeholder="Add roles..."
/>
</div>
</>
) : (
<div className={styles.emptyDetail}>Select a group to view details</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
/>
</>
)
}
- Step 2: Commit
git add src/pages/Admin/UserManagement/GroupsTab.tsx
git commit -m "feat: add GroupsTab to User Management"
Task 15: User Management — RolesTab
Files:
-
Create:
src/pages/Admin/UserManagement/RolesTab.tsx -
Step 1: Write RolesTab
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 ?? ''}
/>
</>
)
}
- Step 2: Commit
git add src/pages/Admin/UserManagement/RolesTab.tsx
git commit -m "feat: add RolesTab to User Management"
Task 16: Final Integration + Build Verification
Files:
-
Verify all modified files compile and tests pass
-
Step 1: Run all component tests
Run: npx vitest run src/design-system/primitives/InlineEdit src/design-system/composites/ConfirmDialog src/design-system/composites/MultiSelect
Expected: All tests PASS
- Step 2: Run full test suite
Run: npx vitest run
Expected: All tests PASS
- Step 3: Build the project
Run: npx vite build
Expected: Build succeeds with no TypeScript errors
- Step 4: Fix any issues found
If build fails, fix TypeScript errors or import issues. Common issues:
-
Missing CSS module type declarations (should already be handled by existing
.d.ts) -
Import path mismatches
-
Step 5: Final commit (if any fixes were needed)
git add -A
git commit -m "fix: resolve build issues for admin pages"