Files
design-system/docs/superpowers/plans/2026-03-18-admin-pages.md
hsiegeln fc835ef3f9 docs: add admin pages implementation plan
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>
2026-03-18 22:59:10 +01:00

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} &middot; {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"