diff --git a/docs/superpowers/plans/2026-03-18-admin-pages.md b/docs/superpowers/plans/2026-03-18-admin-pages.md new file mode 100644 index 0000000..15e1da4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-admin-pages.md @@ -0,0 +1,3419 @@ +# 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** + +```tsx +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() + expect(screen.getByText('Alice')).toBeInTheDocument() + }) + + it('shows placeholder when value is empty', () => { + render() + expect(screen.getByText('Enter name')).toBeInTheDocument() + }) + + it('enters edit mode on click', async () => { + const user = userEvent.setup() + render() + 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() + 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() + 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() + 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() + await user.click(screen.getByText('Alice')) + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('shows edit icon button', () => { + render() + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + }) + + it('enters edit mode when edit button is clicked', async () => { + const user = userEvent.setup() + render() + 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** + +```tsx +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(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 ( + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ) + } + + const isEmpty = !value + return ( + + + {isEmpty ? placeholder : value} + + {!disabled && ( + + )} + + ) +} +``` + +- [ ] **Step 2: Write InlineEdit CSS** + +```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: +```ts +export { InlineEdit } from './InlineEdit/InlineEdit' +export type { InlineEditProps } from './InlineEdit/InlineEdit' +``` + +- [ ] **Step 5: Commit** + +```bash +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** + +```tsx +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() + expect(screen.getByText('Confirm Deletion')).toBeInTheDocument() + expect(screen.getByText('Delete user "alice"? This cannot be undone.')).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByText('Confirm Deletion')).not.toBeInTheDocument() + }) + + it('renders custom title', () => { + render() + expect(screen.getByText('Remove item')).toBeInTheDocument() + }) + + it('shows confirm instruction text', () => { + render() + expect(screen.getByText(/Type "alice" to confirm/)).toBeInTheDocument() + }) + + it('disables confirm button until text matches', () => { + render() + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled() + }) + + it('enables confirm button when text matches', async () => { + const user = userEvent.setup() + render() + 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() + 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() + 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() + 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() + 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() + 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() + rerender() + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('') + }) + }) + + it('auto-focuses input on open', async () => { + render() + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveFocus() + }) + }) + + it('renders custom button labels', () => { + render() + 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** + +```tsx +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(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 ( + +
+

{title}

+

{message}

+ +
+ + setInput(e.target.value)} + onKeyDown={handleKeyDown} + autoComplete="off" + /> +
+ +
+ + +
+
+
+ ) +} +``` + +- [ ] **Step 2: Write ConfirmDialog CSS** + +```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: +```ts +export { ConfirmDialog } from './ConfirmDialog/ConfirmDialog' +export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog' +``` + +- [ ] **Step 5: Commit** + +```bash +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** + +```tsx +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() + expect(screen.getByText('Select...')).toBeInTheDocument() + }) + + it('renders trigger with custom placeholder', () => { + render() + expect(screen.getByText('Add roles...')).toBeInTheDocument() + }) + + it('shows selected count on trigger', () => { + render() + expect(screen.getByText('2 selected')).toBeInTheDocument() + }) + + it('opens dropdown on trigger click', async () => { + const user = userEvent.setup() + render() + 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() + 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() + 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() + 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() + 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( +
+ + +
+ ) + 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() + expect(screen.getByRole('combobox')).toHaveAttribute('aria-disabled', 'true') + }) + + it('hides search input when searchable is false', async () => { + const user = userEvent.setup() + render() + 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() + 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** + +```tsx +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(value) + const triggerRef = useRef(null) + const panelRef = useRef(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 ( +
+ + + {open && createPortal( +
+ {searchable && ( + setSearch(e.target.value)} + autoFocus + /> + )} +
+ {filtered.map((opt) => ( + + ))} + {filtered.length === 0 && ( +
No matches
+ )} +
+
+ +
+
, + document.body, + )} +
+ ) +} +``` + +- [ ] **Step 2: Write MultiSelect CSS** + +```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: +```ts +export { MultiSelect } from './MultiSelect/MultiSelect' +export type { MultiSelectOption } from './MultiSelect/MultiSelect' +``` + +- [ ] **Step 5: Commit** + +```bash +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: +```tsx +const [inlineValue, setInlineValue] = useState('Alice Johnson') +``` + +Add DemoCard after the `input` card (find the existing Input DemoCard and place this after it): +```tsx + +
+ + {}} placeholder="Click to add name..." /> + {}} disabled /> +
+
+``` + +- [ ] **Step 2: Add ConfirmDialog and MultiSelect demos to CompositesSection** + +Import `ConfirmDialog` and `MultiSelect` from composites barrel. Add state: +```tsx +const [confirmOpen, setConfirmOpen] = useState(false) +const [confirmDone, setConfirmDone] = useState(false) +const [multiValue, setMultiValue] = useState(['admin']) +``` + +Add ConfirmDialog DemoCard after the existing AlertDialog demo: +```tsx + + + {confirmDone && Deleted!} + setConfirmOpen(false)} + onConfirm={() => { setConfirmOpen(false); setConfirmDone(true) }} + message={'Delete project "my-project"? This cannot be undone.'} + confirmText="my-project" + /> + +``` + +Add MultiSelect DemoCard after the Modal demo: +```tsx + +
+ + + Selected: {multiValue.join(', ') || 'none'} + +
+
+``` + +- [ ] **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** + +```bash +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: +```tsx +location.pathname === '/admin' ? styles.bottomItemActive : '', +``` +to: +```tsx +location.pathname.startsWith('/admin') ? styles.bottomItemActive : '', +``` + +- [ ] **Step 3: Add ToastProvider to main.tsx** + +In `src/main.tsx`, import `ToastProvider` and wrap it around ``: +```tsx +import { ToastProvider } from './design-system/composites/Toast/Toast' +``` + +Add `` inside the ``: +```tsx + + + + + +``` + +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`: +```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: +```tsx +} /> +``` +with: +```tsx +} /> +} /> +} /> +} /> +``` + +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: + +```tsx +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 ( + }> + + +
+ {children} +
+
+ ) +} +``` + +- [ ] **Step 6: Write Admin.module.css admin nav styles** + +Add/replace styles in `src/pages/Admin/Admin.module.css`: + +```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** + +```bash +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** + +```ts +export interface AuditEvent { + id: number + timestamp: string + username: string + category: 'INFRA' | 'AUTH' | 'USER_MGMT' | 'CONFIG' + action: string + target: string + result: 'SUCCESS' | 'FAILURE' + detail: Record + 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** + +```bash +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** + +```tsx +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(INITIAL_RANGE) + const [userFilter, setUserFilter] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [searchFilter, setSearchFilter] = useState('') + const [page, setPage] = useState(1) + const [expandedId, setExpandedId] = useState(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 ( + +
+

Audit Log

+ +
+ +
+ { setDateRange(r); setPage(1) }} + /> + { setUserFilter(e.target.value); setPage(1) }} + onClear={() => { setUserFilter(''); setPage(1) }} + className={styles.filterInput} + /> + { setSearchFilter(e.target.value); setPage(1) }} + onClear={() => { setSearchFilter(''); setPage(1) }} + className={styles.filterInput} + /> +
+ +
+ + + + + + + + + + + + + {pageEvents.map((event) => ( + setExpandedId(expandedId === event.id ? null : event.id)} + /> + ))} + {pageEvents.length === 0 && ( + + + + )} + +
TimestampUserCategoryActionTargetResult
No events match the current filters.
+
+ + {totalPages > 1 && ( +
+ +
+ )} +
+ ) +} + +function EventRow({ event, expanded, onToggle }: { event: AuditEvent; expanded: boolean; onToggle: () => void }) { + return ( + <> + + + {formatTimestamp(event.timestamp)} + + {event.username} + + + + {event.action} + + {event.target} + + + + + + {expanded && ( + + +
+
+ IP Address + {event.ipAddress} +
+
+ User Agent + {event.userAgent} +
+
+
+ Detail + +
+ + + )} + + ) +} +``` + +- [ ] **Step 2: Write AuditLog CSS** + +```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** + +```bash +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** + +```tsx +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(INITIAL_DATA) + const [newRole, setNewRole] = useState('') + const [deleteOpen, setDeleteOpen] = useState(false) + const { toast } = useToast() + + function update(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 ( + +
+
+

OIDC Configuration

+
+ + +
+
+ +
+ Behavior +
+ update('enabled', e.target.checked)} + /> +
+
+ update('autoSignup', e.target.checked)} + /> + Automatically create accounts for new OIDC users +
+
+ +
+ Provider Settings + + update('issuerUri', e.target.value)} + /> + + + update('clientId', e.target.value)} + /> + + + update('clientSecret', e.target.value)} + /> + +
+ +
+ Claim Mapping + + update('rolesClaim', e.target.value)} + /> + + + update('displayNameClaim', e.target.value)} + /> + +
+ +
+ Default Roles +
+ {form.defaultRoles.map((role) => ( + removeRole(role)} /> + ))} + {form.defaultRoles.length === 0 && ( + No default roles configured + )} +
+
+ setNewRole(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole() } }} + className={styles.roleInput} + /> + +
+
+ +
+ Danger Zone + + setDeleteOpen(false)} + onConfirm={handleDelete} + message="Delete OIDC configuration? All users signed in via OIDC will lose access." + confirmText="delete oidc" + /> +
+
+
+ ) +} +``` + +- [ ] **Step 2: Write OidcConfig CSS** + +```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** + +```bash +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** + +```ts +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() + + // 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** + +```bash +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** + +```tsx +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 ( + + +
+ {tab === 'users' && } + {tab === 'groups' && } + {tab === 'roles' && } +
+
+ ) +} +``` + +- [ ] **Step 2: Write shared UserManagement CSS** + +```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** + +```tsx +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(null) + const [creating, setCreating] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(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) { + 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 ( + <> +
+
+
+ setSearch(e.target.value)} + onClear={() => setSearch('')} + className={styles.listHeaderSearch} + /> + +
+ + {creating && ( +
+
+ setNewUsername(e.target.value)} /> + setNewDisplay(e.target.value)} /> +
+
+ setNewEmail(e.target.value)} /> + setNewPassword(e.target.value)} /> +
+
+ + +
+
+ )} + +
+ {filtered.map((user) => ( +
setSelectedId(user.id)} + > + +
+
+ {user.displayName} + {user.provider !== 'local' && ( + + )} +
+
+ {user.email} · {getUserGroupPath(user)} +
+
+ {user.directRoles.map((r) => )} + {user.directGroups.map((gId) => { + const g = MOCK_GROUPS.find((gr) => gr.id === gId) + return g ? : null + })} +
+
+
+ ))} +
+
+ +
+ {selected ? ( + <> +
+ +
+
+ updateUser(selected.id, { displayName: v })} + /> +
+
{selected.email}
+
+ +
+ +
+ Status + + ID + {selected.id} + Created + {new Date(selected.createdAt).toLocaleDateString()} + Provider + {selected.provider} +
+ + Group membership (direct only) +
+ {selected.directGroups.map((gId) => { + const g = MOCK_GROUPS.find((gr) => gr.id === gId) + return g ? ( + updateUser(selected.id, { + directGroups: selected.directGroups.filter((id) => id !== gId), + })} + /> + ) : null + })} + {selected.directGroups.length === 0 && ( + (no groups) + )} +
+
+ updateUser(selected.id, { + directGroups: [...selected.directGroups, ...ids], + })} + placeholder="Add groups..." + /> +
+ + Effective roles (direct + inherited) +
+ {effectiveRoles.map(({ role, source }) => ( + updateUser(selected.id, { + directRoles: selected.directRoles.filter((r) => r !== role), + }) : undefined} + /> + ))} + {effectiveRoles.length === 0 && ( + (no roles) + )} +
+ {effectiveRoles.some((r) => r.source !== 'direct') && ( + + Roles with ↑ are inherited through group membership + + )} +
+ updateUser(selected.id, { + directRoles: [...selected.directRoles, ...roles], + })} + placeholder="Add roles..." + /> +
+ + ) : ( +
Select a user to view details
+ )} +
+
+ + setDeleteTarget(null)} + onConfirm={handleDelete} + message={`Delete user "${deleteTarget?.username}"? This cannot be undone.`} + confirmText={deleteTarget?.username ?? ''} + /> + + ) +} +``` + +- [ ] **Step 4: Commit** + +```bash +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** + +```tsx +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(null) + const [creating, setCreating] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(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) { + 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 ( + <> +
+
+
+ setSearch(e.target.value)} + onClear={() => setSearch('')} + className={styles.listHeaderSearch} + /> + +
+ + {creating && ( +
+ setNewName(e.target.value)} /> + setSearch(e.target.value)} + onClear={() => setSearch('')} + className={styles.listHeaderSearch} + /> + +
+ + {creating && ( +
+ setNewName(e.target.value)} /> + setNewDesc(e.target.value)} /> +
+ + +
+
+ )} + +
+ {filtered.map((role) => ( +
setSelectedId(role.id)} + > + +
+
+ {role.name} + {role.system && 🔒} +
+
+ {role.description} · {getAssignmentCount(role)} assignments +
+
+ {MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name)) + .map((g) => )} + {MOCK_USERS.filter((u) => u.directRoles.includes(role.name)) + .map((u) => )} +
+
+
+ ))} +
+
+ +
+ {selected ? ( + <> +
+ +
+
{selected.name}
+ {selected.description && ( +
{selected.description}
+ )} +
+ {!selected.system && ( + + )} +
+ +
+ ID + {selected.id} + Scope + {selected.scope} + {selected.system && ( + <> + Type + System role (read-only) + + )} +
+ + Assigned to groups +
+ {assignedGroups.map((g) => )} + {assignedGroups.length === 0 && (none)} +
+ + Assigned to users (direct) +
+ {directUsers.map((u) => )} + {directUsers.length === 0 && (none)} +
+ + Effective principals +
+ {effectivePrincipals.map((u) => { + const isDirect = u.directRoles.includes(selected.name) + return ( + + ) + })} + {effectivePrincipals.length === 0 && (none)} +
+ {effectivePrincipals.some((u) => !u.directRoles.includes(selected.name)) && ( + + Dashed entries inherit this role through group membership + + )} + + ) : ( +
Select a role to view details
+ )} +
+
+ + setDeleteTarget(null)} + onConfirm={handleDelete} + message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`} + confirmText={deleteTarget?.name ?? ''} + /> + + ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +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)** + +```bash +git add -A +git commit -m "fix: resolve build issues for admin pages" +```