# 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 && ( )}
Timestamp User Category Action Target Result
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" ```