3420 lines
104 KiB
Markdown
3420 lines
104 KiB
Markdown
|
|
# 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(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||
|
|
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows placeholder when value is empty', () => {
|
||
|
|
render(<InlineEdit value="" onSave={vi.fn()} placeholder="Enter name" />)
|
||
|
|
expect(screen.getByText('Enter name')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('enters edit mode on click', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||
|
|
await user.click(screen.getByText('Alice'))
|
||
|
|
expect(screen.getByRole('textbox')).toHaveValue('Alice')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('saves on Enter', async () => {
|
||
|
|
const onSave = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||
|
|
await user.click(screen.getByText('Alice'))
|
||
|
|
await user.clear(screen.getByRole('textbox'))
|
||
|
|
await user.type(screen.getByRole('textbox'), 'Bob')
|
||
|
|
await user.keyboard('{Enter}')
|
||
|
|
expect(onSave).toHaveBeenCalledWith('Bob')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('cancels on Escape', async () => {
|
||
|
|
const onSave = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||
|
|
await user.click(screen.getByText('Alice'))
|
||
|
|
await user.clear(screen.getByRole('textbox'))
|
||
|
|
await user.type(screen.getByRole('textbox'), 'Bob')
|
||
|
|
await user.keyboard('{Escape}')
|
||
|
|
expect(onSave).not.toHaveBeenCalled()
|
||
|
|
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('cancels on blur', async () => {
|
||
|
|
const onSave = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<InlineEdit value="Alice" onSave={onSave} />)
|
||
|
|
await user.click(screen.getByText('Alice'))
|
||
|
|
await user.clear(screen.getByRole('textbox'))
|
||
|
|
await user.type(screen.getByRole('textbox'), 'Bob')
|
||
|
|
await user.tab()
|
||
|
|
expect(onSave).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not enter edit mode when disabled', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<InlineEdit value="Alice" onSave={vi.fn()} disabled />)
|
||
|
|
await user.click(screen.getByText('Alice'))
|
||
|
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows edit icon button', () => {
|
||
|
|
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||
|
|
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('enters edit mode when edit button is clicked', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<InlineEdit value="Alice" onSave={vi.fn()} />)
|
||
|
|
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||
|
|
expect(screen.getByRole('textbox')).toHaveValue('Alice')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run src/design-system/primitives/InlineEdit`
|
||
|
|
Expected: FAIL — module not found
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: InlineEdit — Implementation
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/design-system/primitives/InlineEdit/InlineEdit.tsx`
|
||
|
|
- Create: `src/design-system/primitives/InlineEdit/InlineEdit.module.css`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write InlineEdit component**
|
||
|
|
|
||
|
|
```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<HTMLInputElement>(null)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (editing) {
|
||
|
|
inputRef.current?.focus()
|
||
|
|
inputRef.current?.select()
|
||
|
|
}
|
||
|
|
}, [editing])
|
||
|
|
|
||
|
|
function startEdit() {
|
||
|
|
if (disabled) return
|
||
|
|
setDraft(value)
|
||
|
|
setEditing(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||
|
|
if (e.key === 'Enter') {
|
||
|
|
e.preventDefault()
|
||
|
|
setEditing(false)
|
||
|
|
onSave(draft)
|
||
|
|
} else if (e.key === 'Escape') {
|
||
|
|
setEditing(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleBlur() {
|
||
|
|
setEditing(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (editing) {
|
||
|
|
return (
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
className={`${styles.input} ${className ?? ''}`}
|
||
|
|
value={draft}
|
||
|
|
onChange={(e) => setDraft(e.target.value)}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
onBlur={handleBlur}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const isEmpty = !value
|
||
|
|
return (
|
||
|
|
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
||
|
|
<span
|
||
|
|
className={isEmpty ? styles.placeholder : styles.value}
|
||
|
|
onClick={startEdit}
|
||
|
|
>
|
||
|
|
{isEmpty ? placeholder : value}
|
||
|
|
</span>
|
||
|
|
{!disabled && (
|
||
|
|
<button
|
||
|
|
className={styles.editBtn}
|
||
|
|
onClick={startEdit}
|
||
|
|
aria-label="Edit"
|
||
|
|
type="button"
|
||
|
|
>
|
||
|
|
✎
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write InlineEdit CSS**
|
||
|
|
|
||
|
|
```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(<ConfirmDialog {...defaultProps} />)
|
||
|
|
expect(screen.getByText('Confirm Deletion')).toBeInTheDocument()
|
||
|
|
expect(screen.getByText('Delete user "alice"? This cannot be undone.')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not render when closed', () => {
|
||
|
|
render(<ConfirmDialog {...defaultProps} open={false} />)
|
||
|
|
expect(screen.queryByText('Confirm Deletion')).not.toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('renders custom title', () => {
|
||
|
|
render(<ConfirmDialog {...defaultProps} title="Remove item" />)
|
||
|
|
expect(screen.getByText('Remove item')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows confirm instruction text', () => {
|
||
|
|
render(<ConfirmDialog {...defaultProps} />)
|
||
|
|
expect(screen.getByText(/Type "alice" to confirm/)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('disables confirm button until text matches', () => {
|
||
|
|
render(<ConfirmDialog {...defaultProps} />)
|
||
|
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('enables confirm button when text matches', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<ConfirmDialog {...defaultProps} />)
|
||
|
|
await user.type(screen.getByRole('textbox'), 'alice')
|
||
|
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('calls onConfirm when confirm button is clicked after typing', async () => {
|
||
|
|
const onConfirm = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||
|
|
await user.type(screen.getByRole('textbox'), 'alice')
|
||
|
|
await user.click(screen.getByRole('button', { name: 'Delete' }))
|
||
|
|
expect(onConfirm).toHaveBeenCalledOnce()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('calls onClose when cancel button is clicked', async () => {
|
||
|
|
const onClose = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<ConfirmDialog {...defaultProps} onClose={onClose} />)
|
||
|
|
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||
|
|
expect(onClose).toHaveBeenCalledOnce()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('calls onConfirm on Enter when text matches', async () => {
|
||
|
|
const onConfirm = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||
|
|
await user.type(screen.getByRole('textbox'), 'alice')
|
||
|
|
await user.keyboard('{Enter}')
|
||
|
|
expect(onConfirm).toHaveBeenCalledOnce()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not call onConfirm on Enter when text does not match', async () => {
|
||
|
|
const onConfirm = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />)
|
||
|
|
await user.type(screen.getByRole('textbox'), 'alic')
|
||
|
|
await user.keyboard('{Enter}')
|
||
|
|
expect(onConfirm).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('disables both buttons when loading', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<ConfirmDialog {...defaultProps} loading />)
|
||
|
|
await user.type(screen.getByRole('textbox'), 'alice')
|
||
|
|
const buttons = screen.getAllByRole('button')
|
||
|
|
for (const btn of buttons) {
|
||
|
|
expect(btn).toBeDisabled()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
it('clears input when opened', async () => {
|
||
|
|
const { rerender } = render(<ConfirmDialog {...defaultProps} open={false} />)
|
||
|
|
rerender(<ConfirmDialog {...defaultProps} open={true} />)
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByRole('textbox')).toHaveValue('')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('auto-focuses input on open', async () => {
|
||
|
|
render(<ConfirmDialog {...defaultProps} />)
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByRole('textbox')).toHaveFocus()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('renders custom button labels', () => {
|
||
|
|
render(<ConfirmDialog {...defaultProps} confirmLabel="Remove" cancelLabel="Keep" />)
|
||
|
|
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
|
||
|
|
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run src/design-system/composites/ConfirmDialog`
|
||
|
|
Expected: FAIL — module not found
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: ConfirmDialog — Implementation
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/design-system/composites/ConfirmDialog/ConfirmDialog.tsx`
|
||
|
|
- Create: `src/design-system/composites/ConfirmDialog/ConfirmDialog.module.css`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write ConfirmDialog component**
|
||
|
|
|
||
|
|
```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<HTMLInputElement>(null)
|
||
|
|
const matches = input === confirmText
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (open) {
|
||
|
|
setInput('')
|
||
|
|
const id = setTimeout(() => inputRef.current?.focus(), 0)
|
||
|
|
return () => clearTimeout(id)
|
||
|
|
}
|
||
|
|
}, [open])
|
||
|
|
|
||
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||
|
|
if (e.key === 'Enter' && matches && !loading) {
|
||
|
|
e.preventDefault()
|
||
|
|
onConfirm()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const confirmButtonVariant = variant === 'danger' ? 'danger' : 'primary'
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||
|
|
<div className={styles.content}>
|
||
|
|
<h2 className={styles.title}>{title}</h2>
|
||
|
|
<p className={styles.message}>{message}</p>
|
||
|
|
|
||
|
|
<div className={styles.inputGroup}>
|
||
|
|
<label className={styles.label} htmlFor="confirm-input">
|
||
|
|
Type "<strong>{confirmText}</strong>" to confirm
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
id="confirm-input"
|
||
|
|
className={styles.input}
|
||
|
|
value={input}
|
||
|
|
onChange={(e) => setInput(e.target.value)}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
autoComplete="off"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.buttonRow}>
|
||
|
|
<Button
|
||
|
|
variant="secondary"
|
||
|
|
onClick={onClose}
|
||
|
|
disabled={loading}
|
||
|
|
type="button"
|
||
|
|
>
|
||
|
|
{cancelLabel}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant={confirmButtonVariant}
|
||
|
|
onClick={onConfirm}
|
||
|
|
loading={loading}
|
||
|
|
disabled={!matches || loading}
|
||
|
|
type="button"
|
||
|
|
>
|
||
|
|
{confirmLabel}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write ConfirmDialog CSS**
|
||
|
|
|
||
|
|
```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(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||
|
|
expect(screen.getByText('Select...')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('renders trigger with custom placeholder', () => {
|
||
|
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} placeholder="Add roles..." />)
|
||
|
|
expect(screen.getByText('Add roles...')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows selected count on trigger', () => {
|
||
|
|
render(<MultiSelect options={OPTIONS} value={['admin', 'editor']} onChange={vi.fn()} />)
|
||
|
|
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('opens dropdown on trigger click', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||
|
|
await user.click(screen.getByRole('combobox'))
|
||
|
|
expect(screen.getByText('ADMIN')).toBeInTheDocument()
|
||
|
|
expect(screen.getByText('EDITOR')).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows checkboxes for pre-selected values', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
|
||
|
|
await user.click(screen.getByRole('combobox'))
|
||
|
|
const adminCheckbox = screen.getByRole('checkbox', { name: 'ADMIN' })
|
||
|
|
expect(adminCheckbox).toBeChecked()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('filters options by search text', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} />)
|
||
|
|
await user.click(screen.getByRole('combobox'))
|
||
|
|
await user.type(screen.getByPlaceholderText('Search...'), 'adm')
|
||
|
|
expect(screen.getByText('ADMIN')).toBeInTheDocument()
|
||
|
|
expect(screen.queryByText('EDITOR')).not.toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('calls onChange with selected values on Apply', async () => {
|
||
|
|
const onChange = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
|
||
|
|
await user.click(screen.getByRole('combobox'))
|
||
|
|
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||
|
|
await user.click(screen.getByRole('checkbox', { name: 'VIEWER' }))
|
||
|
|
await user.click(screen.getByRole('button', { name: /Apply/ }))
|
||
|
|
expect(onChange).toHaveBeenCalledWith(['admin', 'viewer'])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('discards pending changes on Escape', async () => {
|
||
|
|
const onChange = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />)
|
||
|
|
await user.click(screen.getByRole('combobox'))
|
||
|
|
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||
|
|
await user.keyboard('{Escape}')
|
||
|
|
expect(onChange).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('closes dropdown on outside click without applying', async () => {
|
||
|
|
const onChange = vi.fn()
|
||
|
|
const user = userEvent.setup()
|
||
|
|
const { container } = render(
|
||
|
|
<div>
|
||
|
|
<MultiSelect options={OPTIONS} value={[]} onChange={onChange} />
|
||
|
|
<button>Outside</button>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
await user.click(screen.getByRole('combobox'))
|
||
|
|
await user.click(screen.getByRole('checkbox', { name: 'ADMIN' }))
|
||
|
|
await user.click(screen.getByText('Outside'))
|
||
|
|
expect(onChange).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('disables trigger when disabled prop is set', () => {
|
||
|
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} disabled />)
|
||
|
|
expect(screen.getByRole('combobox')).toHaveAttribute('aria-disabled', 'true')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('hides search input when searchable is false', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<MultiSelect options={OPTIONS} value={[]} onChange={vi.fn()} searchable={false} />)
|
||
|
|
await user.click(screen.getByRole('combobox'))
|
||
|
|
expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows Apply button with count of pending changes', async () => {
|
||
|
|
const user = userEvent.setup()
|
||
|
|
render(<MultiSelect options={OPTIONS} value={['admin']} onChange={vi.fn()} />)
|
||
|
|
await user.click(screen.getByRole('combobox'))
|
||
|
|
await user.click(screen.getByRole('checkbox', { name: 'EDITOR' }))
|
||
|
|
expect(screen.getByRole('button', { name: /Apply \(2\)/ })).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run src/design-system/composites/MultiSelect`
|
||
|
|
Expected: FAIL — module not found
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: MultiSelect — Implementation
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/design-system/composites/MultiSelect/MultiSelect.tsx`
|
||
|
|
- Create: `src/design-system/composites/MultiSelect/MultiSelect.module.css`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write MultiSelect component**
|
||
|
|
|
||
|
|
```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<string[]>(value)
|
||
|
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
||
|
|
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||
|
|
|
||
|
|
// Sync pending with value when opening
|
||
|
|
useEffect(() => {
|
||
|
|
if (open) {
|
||
|
|
setPending(value)
|
||
|
|
setSearch('')
|
||
|
|
}
|
||
|
|
}, [open, value])
|
||
|
|
|
||
|
|
// Position the panel below the trigger
|
||
|
|
useEffect(() => {
|
||
|
|
if (open && triggerRef.current) {
|
||
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
||
|
|
setPos({
|
||
|
|
top: rect.bottom + 4,
|
||
|
|
left: rect.left,
|
||
|
|
width: rect.width,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}, [open])
|
||
|
|
|
||
|
|
// Close on outside click
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return
|
||
|
|
function handleClick(e: MouseEvent) {
|
||
|
|
if (
|
||
|
|
panelRef.current && !panelRef.current.contains(e.target as Node) &&
|
||
|
|
triggerRef.current && !triggerRef.current.contains(e.target as Node)
|
||
|
|
) {
|
||
|
|
setOpen(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
document.addEventListener('mousedown', handleClick)
|
||
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
||
|
|
}, [open])
|
||
|
|
|
||
|
|
// Close on Escape
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return
|
||
|
|
function handleKey(e: KeyboardEvent) {
|
||
|
|
if (e.key === 'Escape') setOpen(false)
|
||
|
|
}
|
||
|
|
document.addEventListener('keydown', handleKey)
|
||
|
|
return () => document.removeEventListener('keydown', handleKey)
|
||
|
|
}, [open])
|
||
|
|
|
||
|
|
function toggleOption(optValue: string) {
|
||
|
|
setPending((prev) =>
|
||
|
|
prev.includes(optValue) ? prev.filter((v) => v !== optValue) : [...prev, optValue]
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleApply() {
|
||
|
|
onChange(pending)
|
||
|
|
setOpen(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
const filtered = options.filter((opt) =>
|
||
|
|
opt.label.toLowerCase().includes(search.toLowerCase())
|
||
|
|
)
|
||
|
|
|
||
|
|
const triggerLabel = value.length > 0 ? `${value.length} selected` : placeholder
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={`${styles.wrap} ${className ?? ''}`}>
|
||
|
|
<button
|
||
|
|
ref={triggerRef}
|
||
|
|
className={styles.trigger}
|
||
|
|
onClick={() => !disabled && setOpen(!open)}
|
||
|
|
role="combobox"
|
||
|
|
aria-expanded={open}
|
||
|
|
aria-haspopup="listbox"
|
||
|
|
aria-disabled={disabled}
|
||
|
|
type="button"
|
||
|
|
>
|
||
|
|
<span className={value.length > 0 ? styles.triggerText : styles.triggerPlaceholder}>
|
||
|
|
{triggerLabel}
|
||
|
|
</span>
|
||
|
|
<span className={styles.chevron} aria-hidden="true">▾</span>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{open && createPortal(
|
||
|
|
<div
|
||
|
|
ref={panelRef}
|
||
|
|
className={styles.panel}
|
||
|
|
style={{ top: pos.top, left: pos.left, width: Math.max(pos.width, 200) }}
|
||
|
|
>
|
||
|
|
{searchable && (
|
||
|
|
<input
|
||
|
|
className={styles.search}
|
||
|
|
placeholder="Search..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
autoFocus
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
<div className={styles.optionList} role="listbox">
|
||
|
|
{filtered.map((opt) => (
|
||
|
|
<label key={opt.value} className={styles.option} role="option" aria-selected={pending.includes(opt.value)}>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
className={styles.checkbox}
|
||
|
|
checked={pending.includes(opt.value)}
|
||
|
|
onChange={() => toggleOption(opt.value)}
|
||
|
|
aria-label={opt.label}
|
||
|
|
/>
|
||
|
|
<span className={styles.optionLabel}>{opt.label}</span>
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
{filtered.length === 0 && (
|
||
|
|
<div className={styles.empty}>No matches</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className={styles.footer}>
|
||
|
|
<button
|
||
|
|
className={styles.applyBtn}
|
||
|
|
onClick={handleApply}
|
||
|
|
type="button"
|
||
|
|
>
|
||
|
|
Apply ({pending.length})
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>,
|
||
|
|
document.body,
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write MultiSelect CSS**
|
||
|
|
|
||
|
|
```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
|
||
|
|
<DemoCard id="inline-edit" title="InlineEdit" description="Click-to-edit text field. Enter saves, Escape/blur cancels.">
|
||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
|
|
<InlineEdit value={inlineValue} onSave={setInlineValue} />
|
||
|
|
<InlineEdit value="" onSave={() => {}} placeholder="Click to add name..." />
|
||
|
|
<InlineEdit value="Read only" onSave={() => {}} disabled />
|
||
|
|
</div>
|
||
|
|
</DemoCard>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add ConfirmDialog and MultiSelect demos to CompositesSection**
|
||
|
|
|
||
|
|
Import `ConfirmDialog` and `MultiSelect` from composites barrel. Add state:
|
||
|
|
```tsx
|
||
|
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||
|
|
const [confirmDone, setConfirmDone] = useState(false)
|
||
|
|
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
|
||
|
|
```
|
||
|
|
|
||
|
|
Add ConfirmDialog DemoCard after the existing AlertDialog demo:
|
||
|
|
```tsx
|
||
|
|
<DemoCard id="confirm-dialog" title="ConfirmDialog" description="Type-to-confirm destructive action dialog. Built on Modal.">
|
||
|
|
<Button size="sm" variant="danger" onClick={() => { setConfirmOpen(true); setConfirmDone(false) }}>
|
||
|
|
Delete project
|
||
|
|
</Button>
|
||
|
|
{confirmDone && <span style={{ color: 'var(--success)', fontSize: 12, marginLeft: 8 }}>Deleted!</span>}
|
||
|
|
<ConfirmDialog
|
||
|
|
open={confirmOpen}
|
||
|
|
onClose={() => setConfirmOpen(false)}
|
||
|
|
onConfirm={() => { setConfirmOpen(false); setConfirmDone(true) }}
|
||
|
|
message={'Delete project "my-project"? This cannot be undone.'}
|
||
|
|
confirmText="my-project"
|
||
|
|
/>
|
||
|
|
</DemoCard>
|
||
|
|
```
|
||
|
|
|
||
|
|
Add MultiSelect DemoCard after the Modal demo:
|
||
|
|
```tsx
|
||
|
|
<DemoCard id="multi-select" title="MultiSelect" description="Dropdown with searchable checkbox list and Apply action.">
|
||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
|
||
|
|
<MultiSelect
|
||
|
|
options={[
|
||
|
|
{ value: 'admin', label: 'ADMIN' },
|
||
|
|
{ value: 'editor', label: 'EDITOR' },
|
||
|
|
{ value: 'viewer', label: 'VIEWER' },
|
||
|
|
{ value: 'operator', label: 'OPERATOR' },
|
||
|
|
{ value: 'auditor', label: 'AUDITOR' },
|
||
|
|
]}
|
||
|
|
value={multiValue}
|
||
|
|
onChange={setMultiValue}
|
||
|
|
placeholder="Add roles..."
|
||
|
|
/>
|
||
|
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||
|
|
Selected: {multiValue.join(', ') || 'none'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</DemoCard>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Update Inventory nav to include new component anchors**
|
||
|
|
|
||
|
|
No change needed — the Inventory nav links to `#primitives`, `#composites`, `#layout` sections, not individual components. The DemoCards with their `id` attributes are auto-scrollable via anchor links from anywhere.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Verify app compiles**
|
||
|
|
|
||
|
|
Run: `npx vite build 2>&1 | head -20`
|
||
|
|
Expected: Build succeeds without errors
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```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 `<App />`:
|
||
|
|
```tsx
|
||
|
|
import { ToastProvider } from './design-system/composites/Toast/Toast'
|
||
|
|
```
|
||
|
|
|
||
|
|
Add `<ToastProvider>` inside the `<CommandPaletteProvider>`:
|
||
|
|
```tsx
|
||
|
|
<CommandPaletteProvider>
|
||
|
|
<ToastProvider>
|
||
|
|
<App />
|
||
|
|
</ToastProvider>
|
||
|
|
</CommandPaletteProvider>
|
||
|
|
```
|
||
|
|
|
||
|
|
This is needed because the OidcConfig page uses `useToast()`.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update App.tsx with admin sub-routes**
|
||
|
|
|
||
|
|
Import the new page components at the top of `src/App.tsx`:
|
||
|
|
```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
|
||
|
|
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||
|
|
```
|
||
|
|
with:
|
||
|
|
```tsx
|
||
|
|
<Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
|
||
|
|
<Route path="/admin/audit" element={<AuditLog />} />
|
||
|
|
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||
|
|
<Route path="/admin/rbac" element={<UserManagement />} />
|
||
|
|
```
|
||
|
|
|
||
|
|
Remove the `Admin` import since the placeholder is no longer used.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Rewrite Admin.tsx as a shared admin layout wrapper**
|
||
|
|
|
||
|
|
Replace `src/pages/Admin/Admin.tsx` with a shared layout component that the individual admin pages will use:
|
||
|
|
|
||
|
|
```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 (
|
||
|
|
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||
|
|
<TopBar
|
||
|
|
breadcrumb={[
|
||
|
|
{ label: 'Admin', href: '/admin' },
|
||
|
|
{ label: title },
|
||
|
|
]}
|
||
|
|
environment="PRODUCTION"
|
||
|
|
user={{ name: 'hendrik' }}
|
||
|
|
/>
|
||
|
|
<nav className={styles.adminNav} aria-label="Admin sections">
|
||
|
|
{ADMIN_TABS.map((tab) => (
|
||
|
|
<button
|
||
|
|
key={tab.path}
|
||
|
|
className={`${styles.adminTab} ${location.pathname === tab.path ? styles.adminTabActive : ''}`}
|
||
|
|
onClick={() => navigate(tab.path)}
|
||
|
|
type="button"
|
||
|
|
>
|
||
|
|
{tab.label}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</nav>
|
||
|
|
<div className={styles.adminContent}>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
</AppShell>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Write Admin.module.css admin nav styles**
|
||
|
|
|
||
|
|
Add/replace styles in `src/pages/Admin/Admin.module.css`:
|
||
|
|
|
||
|
|
```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<string, unknown>
|
||
|
|
ipAddress: string
|
||
|
|
userAgent: string
|
||
|
|
}
|
||
|
|
|
||
|
|
const now = Date.now()
|
||
|
|
const hour = 3600_000
|
||
|
|
const day = 24 * hour
|
||
|
|
|
||
|
|
export const AUDIT_EVENTS: AuditEvent[] = [
|
||
|
|
{
|
||
|
|
id: 1, timestamp: new Date(now - 0.5 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
|
||
|
|
target: 'users/alice', result: 'SUCCESS',
|
||
|
|
detail: { displayName: 'Alice Johnson', roles: ['VIEWER'] },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 2, timestamp: new Date(now - 1.2 * hour).toISOString(),
|
||
|
|
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
|
||
|
|
target: 'db/primary', result: 'SUCCESS',
|
||
|
|
detail: { oldSize: 10, newSize: 20, reason: 'auto-scale' },
|
||
|
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 3, timestamp: new Date(now - 2 * hour).toISOString(),
|
||
|
|
username: 'alice', category: 'AUTH', action: 'LOGIN',
|
||
|
|
target: 'sessions/abc123', result: 'SUCCESS',
|
||
|
|
detail: { method: 'OIDC', provider: 'keycloak' },
|
||
|
|
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 4, timestamp: new Date(now - 2.5 * hour).toISOString(),
|
||
|
|
username: 'unknown', category: 'AUTH', action: 'LOGIN',
|
||
|
|
target: 'sessions', result: 'FAILURE',
|
||
|
|
detail: { method: 'local', reason: 'invalid_credentials' },
|
||
|
|
ipAddress: '203.0.113.50', userAgent: 'curl/8.1',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 5, timestamp: new Date(now - 3 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
|
||
|
|
target: 'thresholds/pool-connections', result: 'SUCCESS',
|
||
|
|
detail: { field: 'maxConnections', oldValue: 50, newValue: 100 },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 6, timestamp: new Date(now - 4 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
|
||
|
|
target: 'users/bob', result: 'SUCCESS',
|
||
|
|
detail: { role: 'EDITOR', method: 'direct' },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 7, timestamp: new Date(now - 5 * hour).toISOString(),
|
||
|
|
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
|
||
|
|
target: 'opensearch/exchanges', result: 'SUCCESS',
|
||
|
|
detail: { documents: 15420, duration: '12.3s' },
|
||
|
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 8, timestamp: new Date(now - 6 * hour).toISOString(),
|
||
|
|
username: 'bob', category: 'AUTH', action: 'LOGIN',
|
||
|
|
target: 'sessions/def456', result: 'SUCCESS',
|
||
|
|
detail: { method: 'local' },
|
||
|
|
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 9, timestamp: new Date(now - 8 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_GROUP',
|
||
|
|
target: 'groups/developers', result: 'SUCCESS',
|
||
|
|
detail: { parent: null },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 10, timestamp: new Date(now - 10 * hour).toISOString(),
|
||
|
|
username: 'system', category: 'INFRA', action: 'BACKUP',
|
||
|
|
target: 'db/primary', result: 'SUCCESS',
|
||
|
|
detail: { sizeBytes: 524288000, duration: '45s' },
|
||
|
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 11, timestamp: new Date(now - 12 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_OIDC',
|
||
|
|
target: 'config/oidc', result: 'SUCCESS',
|
||
|
|
detail: { field: 'autoSignup', oldValue: false, newValue: true },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 12, timestamp: new Date(now - 1 * day).toISOString(),
|
||
|
|
username: 'alice', category: 'AUTH', action: 'LOGOUT',
|
||
|
|
target: 'sessions/abc123', result: 'SUCCESS',
|
||
|
|
detail: { reason: 'user_initiated' },
|
||
|
|
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 13, timestamp: new Date(now - 1 * day - 2 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'USER_MGMT', action: 'DELETE_USER',
|
||
|
|
target: 'users/temp-user', result: 'SUCCESS',
|
||
|
|
detail: { reason: 'cleanup' },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 14, timestamp: new Date(now - 1 * day - 4 * hour).toISOString(),
|
||
|
|
username: 'system', category: 'INFRA', action: 'POOL_RESIZE',
|
||
|
|
target: 'db/primary', result: 'FAILURE',
|
||
|
|
detail: { oldSize: 20, newSize: 50, error: 'max_connections_exceeded' },
|
||
|
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 15, timestamp: new Date(now - 1 * day - 6 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'USER_MGMT', action: 'UPDATE_GROUP',
|
||
|
|
target: 'groups/admins', result: 'SUCCESS',
|
||
|
|
detail: { addedMembers: ['alice'], removedMembers: [] },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 16, timestamp: new Date(now - 2 * day).toISOString(),
|
||
|
|
username: 'bob', category: 'AUTH', action: 'PASSWORD_CHANGE',
|
||
|
|
target: 'users/bob', result: 'SUCCESS',
|
||
|
|
detail: { method: 'self_service' },
|
||
|
|
ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 17, timestamp: new Date(now - 2 * day - 3 * hour).toISOString(),
|
||
|
|
username: 'system', category: 'INFRA', action: 'VACUUM',
|
||
|
|
target: 'db/primary/exchanges', result: 'SUCCESS',
|
||
|
|
detail: { reclaimedBytes: 1048576, duration: '3.2s' },
|
||
|
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 18, timestamp: new Date(now - 2 * day - 5 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD',
|
||
|
|
target: 'thresholds/latency-p99', result: 'SUCCESS',
|
||
|
|
detail: { field: 'warningMs', oldValue: 500, newValue: 300 },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 19, timestamp: new Date(now - 3 * day).toISOString(),
|
||
|
|
username: 'attacker', category: 'AUTH', action: 'LOGIN',
|
||
|
|
target: 'sessions', result: 'FAILURE',
|
||
|
|
detail: { method: 'local', reason: 'account_locked', attempts: 5 },
|
||
|
|
ipAddress: '198.51.100.23', userAgent: 'python-requests/2.31',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 20, timestamp: new Date(now - 3 * day - 2 * hour).toISOString(),
|
||
|
|
username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE',
|
||
|
|
target: 'groups/developers', result: 'SUCCESS',
|
||
|
|
detail: { role: 'EDITOR', method: 'group_assignment' },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 21, timestamp: new Date(now - 4 * day).toISOString(),
|
||
|
|
username: 'system', category: 'INFRA', action: 'BACKUP',
|
||
|
|
target: 'db/primary', result: 'FAILURE',
|
||
|
|
detail: { error: 'disk_full', sizeBytes: 0 },
|
||
|
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 22, timestamp: new Date(now - 4 * day - 1 * hour).toISOString(),
|
||
|
|
username: 'alice', category: 'CONFIG', action: 'VIEW_CONFIG',
|
||
|
|
target: 'config/oidc', result: 'SUCCESS',
|
||
|
|
detail: { section: 'provider_settings' },
|
||
|
|
ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 23, timestamp: new Date(now - 5 * day).toISOString(),
|
||
|
|
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_ROLE',
|
||
|
|
target: 'roles/OPERATOR', result: 'SUCCESS',
|
||
|
|
detail: { scope: 'custom', description: 'Pipeline operator' },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 24, timestamp: new Date(now - 5 * day - 3 * hour).toISOString(),
|
||
|
|
username: 'system', category: 'INFRA', action: 'INDEX_REBUILD',
|
||
|
|
target: 'opensearch/agents', result: 'SUCCESS',
|
||
|
|
detail: { documents: 230, duration: '1.1s' },
|
||
|
|
ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 25, timestamp: new Date(now - 6 * day).toISOString(),
|
||
|
|
username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER',
|
||
|
|
target: 'users/bob', result: 'SUCCESS',
|
||
|
|
detail: { displayName: 'Bob Smith', roles: ['VIEWER'] },
|
||
|
|
ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125',
|
||
|
|
},
|
||
|
|
]
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```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<DateRange>(INITIAL_RANGE)
|
||
|
|
const [userFilter, setUserFilter] = useState('')
|
||
|
|
const [categoryFilter, setCategoryFilter] = useState('')
|
||
|
|
const [searchFilter, setSearchFilter] = useState('')
|
||
|
|
const [page, setPage] = useState(1)
|
||
|
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||
|
|
|
||
|
|
const filtered = useMemo(() => {
|
||
|
|
const from = new Date(dateRange.from).getTime()
|
||
|
|
const to = new Date(dateRange.to).getTime()
|
||
|
|
return AUDIT_EVENTS.filter((e) => {
|
||
|
|
const ts = new Date(e.timestamp).getTime()
|
||
|
|
if (ts < from || ts > to) return false
|
||
|
|
if (userFilter && !e.username.toLowerCase().includes(userFilter.toLowerCase())) return false
|
||
|
|
if (categoryFilter && e.category !== categoryFilter) return false
|
||
|
|
if (searchFilter) {
|
||
|
|
const q = searchFilter.toLowerCase()
|
||
|
|
if (!e.action.toLowerCase().includes(q) && !e.target.toLowerCase().includes(q)) return false
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
})
|
||
|
|
}, [dateRange, userFilter, categoryFilter, searchFilter])
|
||
|
|
|
||
|
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||
|
|
const pageEvents = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AdminLayout title="Audit Log">
|
||
|
|
<div className={styles.header}>
|
||
|
|
<h2 className={styles.title}>Audit Log</h2>
|
||
|
|
<Badge label={`${filtered.length} events`} color="primary" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.filters}>
|
||
|
|
<DateRangePicker
|
||
|
|
value={dateRange}
|
||
|
|
onChange={(r) => { setDateRange(r); setPage(1) }}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
placeholder="Filter by user..."
|
||
|
|
value={userFilter}
|
||
|
|
onChange={(e) => { setUserFilter(e.target.value); setPage(1) }}
|
||
|
|
onClear={() => { setUserFilter(''); setPage(1) }}
|
||
|
|
className={styles.filterInput}
|
||
|
|
/>
|
||
|
|
<Select
|
||
|
|
options={CATEGORIES}
|
||
|
|
value={categoryFilter}
|
||
|
|
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }}
|
||
|
|
className={styles.filterSelect}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
placeholder="Search action or target..."
|
||
|
|
value={searchFilter}
|
||
|
|
onChange={(e) => { setSearchFilter(e.target.value); setPage(1) }}
|
||
|
|
onClear={() => { setSearchFilter(''); setPage(1) }}
|
||
|
|
className={styles.filterInput}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.tableWrap}>
|
||
|
|
<table className={styles.table}>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th className={styles.th} style={{ width: 170 }}>Timestamp</th>
|
||
|
|
<th className={styles.th}>User</th>
|
||
|
|
<th className={styles.th} style={{ width: 100 }}>Category</th>
|
||
|
|
<th className={styles.th}>Action</th>
|
||
|
|
<th className={styles.th}>Target</th>
|
||
|
|
<th className={styles.th} style={{ width: 80 }}>Result</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{pageEvents.map((event) => (
|
||
|
|
<EventRow
|
||
|
|
key={event.id}
|
||
|
|
event={event}
|
||
|
|
expanded={expandedId === event.id}
|
||
|
|
onToggle={() => setExpandedId(expandedId === event.id ? null : event.id)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
{pageEvents.length === 0 && (
|
||
|
|
<tr>
|
||
|
|
<td colSpan={6} className={styles.empty}>No events match the current filters.</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{totalPages > 1 && (
|
||
|
|
<div className={styles.pagination}>
|
||
|
|
<Pagination
|
||
|
|
page={page}
|
||
|
|
totalPages={totalPages}
|
||
|
|
onPageChange={setPage}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</AdminLayout>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function EventRow({ event, expanded, onToggle }: { event: AuditEvent; expanded: boolean; onToggle: () => void }) {
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<tr className={styles.row} onClick={onToggle}>
|
||
|
|
<td className={styles.td}>
|
||
|
|
<MonoText size="xs">{formatTimestamp(event.timestamp)}</MonoText>
|
||
|
|
</td>
|
||
|
|
<td className={`${styles.td} ${styles.userCell}`}>{event.username}</td>
|
||
|
|
<td className={styles.td}>
|
||
|
|
<Badge label={event.category} color="auto" />
|
||
|
|
</td>
|
||
|
|
<td className={styles.td}>{event.action}</td>
|
||
|
|
<td className={styles.td}>
|
||
|
|
<span className={styles.target}>{event.target}</span>
|
||
|
|
</td>
|
||
|
|
<td className={styles.td}>
|
||
|
|
<Badge
|
||
|
|
label={event.result}
|
||
|
|
color={event.result === 'SUCCESS' ? 'success' : 'error'}
|
||
|
|
/>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{expanded && (
|
||
|
|
<tr className={styles.detailRow}>
|
||
|
|
<td colSpan={6} className={styles.detailCell}>
|
||
|
|
<div className={styles.detailGrid}>
|
||
|
|
<div className={styles.detailField}>
|
||
|
|
<span className={styles.detailLabel}>IP Address</span>
|
||
|
|
<MonoText size="xs">{event.ipAddress}</MonoText>
|
||
|
|
</div>
|
||
|
|
<div className={styles.detailField}>
|
||
|
|
<span className={styles.detailLabel}>User Agent</span>
|
||
|
|
<span className={styles.detailValue}>{event.userAgent}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className={styles.detailJson}>
|
||
|
|
<span className={styles.detailLabel}>Detail</span>
|
||
|
|
<CodeBlock content={JSON.stringify(event.detail, null, 2)} language="json" />
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write AuditLog CSS**
|
||
|
|
|
||
|
|
```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<OidcFormData>(INITIAL_DATA)
|
||
|
|
const [newRole, setNewRole] = useState('')
|
||
|
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||
|
|
const { toast } = useToast()
|
||
|
|
|
||
|
|
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||
|
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||
|
|
}
|
||
|
|
|
||
|
|
function addRole() {
|
||
|
|
const role = newRole.trim().toUpperCase()
|
||
|
|
if (role && !form.defaultRoles.includes(role)) {
|
||
|
|
update('defaultRoles', [...form.defaultRoles, role])
|
||
|
|
setNewRole('')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function removeRole(role: string) {
|
||
|
|
update('defaultRoles', form.defaultRoles.filter((r) => r !== role))
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleSave() {
|
||
|
|
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' })
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleTest() {
|
||
|
|
toast({ title: 'Connection test', description: 'OIDC provider responded successfully.', variant: 'info' })
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDelete() {
|
||
|
|
setDeleteOpen(false)
|
||
|
|
setForm({ ...INITIAL_DATA, enabled: false, issuerUri: '', clientId: '', clientSecret: '', defaultRoles: [] })
|
||
|
|
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' })
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AdminLayout title="OIDC Configuration">
|
||
|
|
<div className={styles.page}>
|
||
|
|
<div className={styles.header}>
|
||
|
|
<h2 className={styles.title}>OIDC Configuration</h2>
|
||
|
|
<div className={styles.headerActions}>
|
||
|
|
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
|
||
|
|
Test Connection
|
||
|
|
</Button>
|
||
|
|
<Button size="sm" variant="primary" onClick={handleSave}>
|
||
|
|
Save
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<section className={styles.section}>
|
||
|
|
<SectionHeader>Behavior</SectionHeader>
|
||
|
|
<div className={styles.toggleRow}>
|
||
|
|
<Toggle
|
||
|
|
label="Enabled"
|
||
|
|
checked={form.enabled}
|
||
|
|
onChange={(e) => update('enabled', e.target.checked)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className={styles.toggleRow}>
|
||
|
|
<Toggle
|
||
|
|
label="Auto Sign-Up"
|
||
|
|
checked={form.autoSignup}
|
||
|
|
onChange={(e) => update('autoSignup', e.target.checked)}
|
||
|
|
/>
|
||
|
|
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className={styles.section}>
|
||
|
|
<SectionHeader>Provider Settings</SectionHeader>
|
||
|
|
<FormField label="Issuer URI" htmlFor="issuer">
|
||
|
|
<Input
|
||
|
|
id="issuer"
|
||
|
|
type="url"
|
||
|
|
placeholder="https://idp.example.com/realms/my-realm"
|
||
|
|
value={form.issuerUri}
|
||
|
|
onChange={(e) => update('issuerUri', e.target.value)}
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
<FormField label="Client ID" htmlFor="client-id">
|
||
|
|
<Input
|
||
|
|
id="client-id"
|
||
|
|
value={form.clientId}
|
||
|
|
onChange={(e) => update('clientId', e.target.value)}
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
<FormField label="Client Secret" htmlFor="client-secret">
|
||
|
|
<Input
|
||
|
|
id="client-secret"
|
||
|
|
type="password"
|
||
|
|
value={form.clientSecret}
|
||
|
|
onChange={(e) => update('clientSecret', e.target.value)}
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className={styles.section}>
|
||
|
|
<SectionHeader>Claim Mapping</SectionHeader>
|
||
|
|
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
|
||
|
|
<Input
|
||
|
|
id="roles-claim"
|
||
|
|
value={form.rolesClaim}
|
||
|
|
onChange={(e) => update('rolesClaim', e.target.value)}
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
|
||
|
|
<Input
|
||
|
|
id="name-claim"
|
||
|
|
value={form.displayNameClaim}
|
||
|
|
onChange={(e) => update('displayNameClaim', e.target.value)}
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className={styles.section}>
|
||
|
|
<SectionHeader>Default Roles</SectionHeader>
|
||
|
|
<div className={styles.tagList}>
|
||
|
|
{form.defaultRoles.map((role) => (
|
||
|
|
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
|
||
|
|
))}
|
||
|
|
{form.defaultRoles.length === 0 && (
|
||
|
|
<span className={styles.noRoles}>No default roles configured</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className={styles.addRoleRow}>
|
||
|
|
<Input
|
||
|
|
placeholder="Add role..."
|
||
|
|
value={newRole}
|
||
|
|
onChange={(e) => setNewRole(e.target.value)}
|
||
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole() } }}
|
||
|
|
className={styles.roleInput}
|
||
|
|
/>
|
||
|
|
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
|
||
|
|
Add
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className={styles.section}>
|
||
|
|
<SectionHeader>Danger Zone</SectionHeader>
|
||
|
|
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
|
||
|
|
Delete OIDC Configuration
|
||
|
|
</Button>
|
||
|
|
<ConfirmDialog
|
||
|
|
open={deleteOpen}
|
||
|
|
onClose={() => setDeleteOpen(false)}
|
||
|
|
onConfirm={handleDelete}
|
||
|
|
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
|
||
|
|
confirmText="delete oidc"
|
||
|
|
/>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
</AdminLayout>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write OidcConfig CSS**
|
||
|
|
|
||
|
|
```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<string>()
|
||
|
|
|
||
|
|
// Direct roles
|
||
|
|
for (const role of user.directRoles) {
|
||
|
|
result.push({ role, source: 'direct' })
|
||
|
|
seen.add(role)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Walk group chain for inherited roles
|
||
|
|
function walkGroup(groupId: string) {
|
||
|
|
const group = MOCK_GROUPS.find((g) => g.id === groupId)
|
||
|
|
if (!group) return
|
||
|
|
for (const role of group.directRoles) {
|
||
|
|
if (!seen.has(role)) {
|
||
|
|
result.push({ role, source: group.name })
|
||
|
|
seen.add(role)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Walk parent group
|
||
|
|
if (group.parentId) walkGroup(group.parentId)
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const groupId of user.directGroups) {
|
||
|
|
walkGroup(groupId)
|
||
|
|
}
|
||
|
|
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Get all groups in the chain (self + ancestors) for display */
|
||
|
|
export function getGroupChain(groupId: string): MockGroup[] {
|
||
|
|
const chain: MockGroup[] = []
|
||
|
|
let current = MOCK_GROUPS.find((g) => g.id === groupId)
|
||
|
|
while (current) {
|
||
|
|
chain.unshift(current)
|
||
|
|
current = current.parentId ? MOCK_GROUPS.find((g) => g.id === current!.parentId) : undefined
|
||
|
|
}
|
||
|
|
return chain
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Get child groups of a given group */
|
||
|
|
export function getChildGroups(groupId: string): MockGroup[] {
|
||
|
|
return MOCK_GROUPS.filter((g) => g.parentId === groupId)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```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 (
|
||
|
|
<AdminLayout title="User Management">
|
||
|
|
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||
|
|
<div style={{ marginTop: 16 }}>
|
||
|
|
{tab === 'users' && <UsersTab />}
|
||
|
|
{tab === 'groups' && <GroupsTab />}
|
||
|
|
{tab === 'roles' && <RolesTab />}
|
||
|
|
</div>
|
||
|
|
</AdminLayout>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write shared UserManagement CSS**
|
||
|
|
|
||
|
|
```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<string | null>(null)
|
||
|
|
const [creating, setCreating] = useState(false)
|
||
|
|
const [deleteTarget, setDeleteTarget] = useState<MockUser | null>(null)
|
||
|
|
|
||
|
|
// Create form state
|
||
|
|
const [newUsername, setNewUsername] = useState('')
|
||
|
|
const [newDisplay, setNewDisplay] = useState('')
|
||
|
|
const [newEmail, setNewEmail] = useState('')
|
||
|
|
const [newPassword, setNewPassword] = useState('')
|
||
|
|
|
||
|
|
const filtered = useMemo(() => {
|
||
|
|
if (!search) return users
|
||
|
|
const q = search.toLowerCase()
|
||
|
|
return users.filter((u) =>
|
||
|
|
u.displayName.toLowerCase().includes(q) ||
|
||
|
|
u.email.toLowerCase().includes(q) ||
|
||
|
|
u.username.toLowerCase().includes(q)
|
||
|
|
)
|
||
|
|
}, [users, search])
|
||
|
|
|
||
|
|
const selected = users.find((u) => u.id === selectedId) ?? null
|
||
|
|
|
||
|
|
function handleCreate() {
|
||
|
|
if (!newUsername.trim()) return
|
||
|
|
const newUser: MockUser = {
|
||
|
|
id: `usr-${Date.now()}`,
|
||
|
|
username: newUsername.trim(),
|
||
|
|
displayName: newDisplay.trim() || newUsername.trim(),
|
||
|
|
email: newEmail.trim(),
|
||
|
|
provider: 'local',
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
directRoles: [],
|
||
|
|
directGroups: [],
|
||
|
|
}
|
||
|
|
setUsers((prev) => [...prev, newUser])
|
||
|
|
setCreating(false)
|
||
|
|
setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword('')
|
||
|
|
setSelectedId(newUser.id)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDelete() {
|
||
|
|
if (!deleteTarget) return
|
||
|
|
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
|
||
|
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||
|
|
setDeleteTarget(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateUser(id: string, patch: Partial<MockUser>) {
|
||
|
|
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, ...patch } : u))
|
||
|
|
}
|
||
|
|
|
||
|
|
const effectiveRoles = selected ? getEffectiveRoles(selected) : []
|
||
|
|
const availableGroups = MOCK_GROUPS.filter((g) => !selected?.directGroups.includes(g.id))
|
||
|
|
.map((g) => ({ value: g.id, label: g.name }))
|
||
|
|
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
|
||
|
|
.map((r) => ({ value: r.name, label: r.name }))
|
||
|
|
|
||
|
|
function getUserGroupPath(user: MockUser): string {
|
||
|
|
if (user.directGroups.length === 0) return 'no groups'
|
||
|
|
const group = MOCK_GROUPS.find((g) => g.id === user.directGroups[0])
|
||
|
|
if (!group) return 'no groups'
|
||
|
|
const parent = group.parentId ? MOCK_GROUPS.find((g) => g.id === group.parentId) : null
|
||
|
|
return parent ? `${parent.name} > ${group.name}` : group.name
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div className={styles.splitPane}>
|
||
|
|
<div className={styles.listPane}>
|
||
|
|
<div className={styles.listHeader}>
|
||
|
|
<Input
|
||
|
|
placeholder="Search users..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
onClear={() => setSearch('')}
|
||
|
|
className={styles.listHeaderSearch}
|
||
|
|
/>
|
||
|
|
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
||
|
|
+ Add user
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{creating && (
|
||
|
|
<div className={styles.createForm}>
|
||
|
|
<div className={styles.createFormRow}>
|
||
|
|
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
||
|
|
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
||
|
|
</div>
|
||
|
|
<div className={styles.createFormRow}>
|
||
|
|
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
||
|
|
<Input placeholder="Password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||
|
|
</div>
|
||
|
|
<div className={styles.createFormActions}>
|
||
|
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||
|
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newUsername.trim()}>Create</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className={styles.entityList}>
|
||
|
|
{filtered.map((user) => (
|
||
|
|
<div
|
||
|
|
key={user.id}
|
||
|
|
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
|
||
|
|
onClick={() => setSelectedId(user.id)}
|
||
|
|
>
|
||
|
|
<Avatar name={user.displayName} size="sm" />
|
||
|
|
<div className={styles.entityInfo}>
|
||
|
|
<div className={styles.entityName}>
|
||
|
|
{user.displayName}
|
||
|
|
{user.provider !== 'local' && (
|
||
|
|
<Badge label={user.provider} color="running" variant="outlined" className={styles.providerBadge} />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className={styles.entityMeta}>
|
||
|
|
{user.email} · {getUserGroupPath(user)}
|
||
|
|
</div>
|
||
|
|
<div className={styles.entityTags}>
|
||
|
|
{user.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||
|
|
{user.directGroups.map((gId) => {
|
||
|
|
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||
|
|
return g ? <Badge key={gId} label={g.name} color="success" /> : null
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.detailPane}>
|
||
|
|
{selected ? (
|
||
|
|
<>
|
||
|
|
<div className={styles.detailHeader}>
|
||
|
|
<Avatar name={selected.displayName} size="lg" />
|
||
|
|
<div className={styles.detailHeaderInfo}>
|
||
|
|
<div className={styles.detailName}>
|
||
|
|
<InlineEdit
|
||
|
|
value={selected.displayName}
|
||
|
|
onSave={(v) => updateUser(selected.id, { displayName: v })}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className={styles.detailEmail}>{selected.email}</div>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="danger"
|
||
|
|
onClick={() => setDeleteTarget(selected)}
|
||
|
|
disabled={selected.username === 'hendrik'}
|
||
|
|
>
|
||
|
|
Delete
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.metaGrid}>
|
||
|
|
<span className={styles.metaLabel}>Status</span>
|
||
|
|
<Badge label="Active" color="success" />
|
||
|
|
<span className={styles.metaLabel}>ID</span>
|
||
|
|
<MonoText size="xs">{selected.id}</MonoText>
|
||
|
|
<span className={styles.metaLabel}>Created</span>
|
||
|
|
<span className={styles.metaValue}>{new Date(selected.createdAt).toLocaleDateString()}</span>
|
||
|
|
<span className={styles.metaLabel}>Provider</span>
|
||
|
|
<span className={styles.metaValue}>{selected.provider}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<SectionHeader>Group membership (direct only)</SectionHeader>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{selected.directGroups.map((gId) => {
|
||
|
|
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||
|
|
return g ? (
|
||
|
|
<Tag
|
||
|
|
key={gId}
|
||
|
|
label={g.name}
|
||
|
|
color="success"
|
||
|
|
onRemove={() => updateUser(selected.id, {
|
||
|
|
directGroups: selected.directGroups.filter((id) => id !== gId),
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
) : null
|
||
|
|
})}
|
||
|
|
{selected.directGroups.length === 0 && (
|
||
|
|
<span className={styles.inheritedNote}>(no groups)</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className={styles.selectWrap}>
|
||
|
|
<MultiSelect
|
||
|
|
options={availableGroups}
|
||
|
|
value={[]}
|
||
|
|
onChange={(ids) => updateUser(selected.id, {
|
||
|
|
directGroups: [...selected.directGroups, ...ids],
|
||
|
|
})}
|
||
|
|
placeholder="Add groups..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<SectionHeader>Effective roles (direct + inherited)</SectionHeader>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{effectiveRoles.map(({ role, source }) => (
|
||
|
|
<Tag
|
||
|
|
key={role}
|
||
|
|
label={source === 'direct' ? role : `${role} ↑ ${source}`}
|
||
|
|
color="warning"
|
||
|
|
onRemove={source === 'direct' ? () => updateUser(selected.id, {
|
||
|
|
directRoles: selected.directRoles.filter((r) => r !== role),
|
||
|
|
}) : undefined}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
{effectiveRoles.length === 0 && (
|
||
|
|
<span className={styles.inheritedNote}>(no roles)</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{effectiveRoles.some((r) => r.source !== 'direct') && (
|
||
|
|
<span className={styles.inheritedNote}>
|
||
|
|
Roles with ↑ are inherited through group membership
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<div className={styles.selectWrap}>
|
||
|
|
<MultiSelect
|
||
|
|
options={availableRoles}
|
||
|
|
value={[]}
|
||
|
|
onChange={(roles) => updateUser(selected.id, {
|
||
|
|
directRoles: [...selected.directRoles, ...roles],
|
||
|
|
})}
|
||
|
|
placeholder="Add roles..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<div className={styles.emptyDetail}>Select a user to view details</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ConfirmDialog
|
||
|
|
open={deleteTarget !== null}
|
||
|
|
onClose={() => setDeleteTarget(null)}
|
||
|
|
onConfirm={handleDelete}
|
||
|
|
message={`Delete user "${deleteTarget?.username}"? This cannot be undone.`}
|
||
|
|
confirmText={deleteTarget?.username ?? ''}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```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<string | null>(null)
|
||
|
|
const [creating, setCreating] = useState(false)
|
||
|
|
const [deleteTarget, setDeleteTarget] = useState<MockGroup | null>(null)
|
||
|
|
|
||
|
|
const [newName, setNewName] = useState('')
|
||
|
|
const [newParent, setNewParent] = useState('')
|
||
|
|
|
||
|
|
const filtered = useMemo(() => {
|
||
|
|
if (!search) return groups
|
||
|
|
const q = search.toLowerCase()
|
||
|
|
return groups.filter((g) => g.name.toLowerCase().includes(q))
|
||
|
|
}, [groups, search])
|
||
|
|
|
||
|
|
const selected = groups.find((g) => g.id === selectedId) ?? null
|
||
|
|
|
||
|
|
function handleCreate() {
|
||
|
|
if (!newName.trim()) return
|
||
|
|
const newGroup: MockGroup = {
|
||
|
|
id: `grp-${Date.now()}`,
|
||
|
|
name: newName.trim(),
|
||
|
|
parentId: newParent || null,
|
||
|
|
builtIn: false,
|
||
|
|
directRoles: [],
|
||
|
|
memberUserIds: [],
|
||
|
|
}
|
||
|
|
setGroups((prev) => [...prev, newGroup])
|
||
|
|
setCreating(false)
|
||
|
|
setNewName(''); setNewParent('')
|
||
|
|
setSelectedId(newGroup.id)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDelete() {
|
||
|
|
if (!deleteTarget) return
|
||
|
|
setGroups((prev) => prev.filter((g) => g.id !== deleteTarget.id))
|
||
|
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||
|
|
setDeleteTarget(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateGroup(id: string, patch: Partial<MockGroup>) {
|
||
|
|
setGroups((prev) => prev.map((g) => g.id === id ? { ...g, ...patch } : g))
|
||
|
|
}
|
||
|
|
|
||
|
|
const children = selected ? getChildGroups(selected.id) : []
|
||
|
|
const members = selected ? MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)) : []
|
||
|
|
const parent = selected?.parentId ? groups.find((g) => g.id === selected.parentId) : null
|
||
|
|
const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name))
|
||
|
|
.map((r) => ({ value: r.name, label: r.name }))
|
||
|
|
|
||
|
|
const parentOptions = [
|
||
|
|
{ value: '', label: 'Top-level' },
|
||
|
|
...groups.filter((g) => g.id !== selectedId).map((g) => ({ value: g.id, label: g.name })),
|
||
|
|
]
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div className={styles.splitPane}>
|
||
|
|
<div className={styles.listPane}>
|
||
|
|
<div className={styles.listHeader}>
|
||
|
|
<Input
|
||
|
|
placeholder="Search groups..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
onClear={() => setSearch('')}
|
||
|
|
className={styles.listHeaderSearch}
|
||
|
|
/>
|
||
|
|
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
||
|
|
+ Add group
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{creating && (
|
||
|
|
<div className={styles.createForm}>
|
||
|
|
<Input placeholder="Group name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||
|
|
<Select
|
||
|
|
options={parentOptions}
|
||
|
|
value={newParent}
|
||
|
|
onChange={(e) => setNewParent(e.target.value)}
|
||
|
|
/>
|
||
|
|
<div className={styles.createFormActions}>
|
||
|
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||
|
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim()}>Create</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className={styles.entityList}>
|
||
|
|
{filtered.map((group) => {
|
||
|
|
const groupChildren = getChildGroups(group.id)
|
||
|
|
const groupMembers = MOCK_USERS.filter((u) => u.directGroups.includes(group.id))
|
||
|
|
const groupParent = group.parentId ? groups.find((g) => g.id === group.parentId) : null
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={group.id}
|
||
|
|
className={`${styles.entityItem} ${selectedId === group.id ? styles.entityItemSelected : ''}`}
|
||
|
|
onClick={() => setSelectedId(group.id)}
|
||
|
|
>
|
||
|
|
<Avatar name={group.name} size="sm" />
|
||
|
|
<div className={styles.entityInfo}>
|
||
|
|
<div className={styles.entityName}>{group.name}</div>
|
||
|
|
<div className={styles.entityMeta}>
|
||
|
|
{groupParent ? `Child of ${groupParent.name}` : 'Top-level'}
|
||
|
|
{' · '}{groupChildren.length} children · {groupMembers.length} members
|
||
|
|
</div>
|
||
|
|
<div className={styles.entityTags}>
|
||
|
|
{group.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.detailPane}>
|
||
|
|
{selected ? (
|
||
|
|
<>
|
||
|
|
<div className={styles.detailHeader}>
|
||
|
|
<Avatar name={selected.name} size="lg" />
|
||
|
|
<div className={styles.detailHeaderInfo}>
|
||
|
|
<div className={styles.detailName}>
|
||
|
|
{selected.builtIn ? selected.name : (
|
||
|
|
<InlineEdit
|
||
|
|
value={selected.name}
|
||
|
|
onSave={(v) => updateGroup(selected.id, { name: v })}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className={styles.detailEmail}>
|
||
|
|
{parent ? `${parent.name} > ${selected.name}` : 'Top-level group'}
|
||
|
|
{selected.builtIn && ' (built-in)'}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="danger"
|
||
|
|
onClick={() => setDeleteTarget(selected)}
|
||
|
|
disabled={selected.builtIn}
|
||
|
|
>
|
||
|
|
Delete
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.metaGrid}>
|
||
|
|
<span className={styles.metaLabel}>ID</span>
|
||
|
|
<MonoText size="xs">{selected.id}</MonoText>
|
||
|
|
<span className={styles.metaLabel}>Parent</span>
|
||
|
|
<span className={styles.metaValue}>{parent?.name ?? '(none)'}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<SectionHeader>Members (direct)</SectionHeader>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{members.map((u) => (
|
||
|
|
<Tag key={u.id} label={u.displayName} color="auto" />
|
||
|
|
))}
|
||
|
|
{members.length === 0 && <span className={styles.inheritedNote}>(no members)</span>}
|
||
|
|
</div>
|
||
|
|
{children.length > 0 && (
|
||
|
|
<span className={styles.inheritedNote}>
|
||
|
|
+ all members of {children.map((c) => c.name).join(', ')}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{children.length > 0 && (
|
||
|
|
<>
|
||
|
|
<SectionHeader>Child groups</SectionHeader>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{children.map((c) => <Tag key={c.id} label={c.name} color="success" />)}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<SectionHeader>Assigned roles</SectionHeader>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{selected.directRoles.map((r) => (
|
||
|
|
<Tag
|
||
|
|
key={r}
|
||
|
|
label={r}
|
||
|
|
color="warning"
|
||
|
|
onRemove={() => updateGroup(selected.id, {
|
||
|
|
directRoles: selected.directRoles.filter((role) => role !== r),
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
{selected.directRoles.length === 0 && <span className={styles.inheritedNote}>(no roles)</span>}
|
||
|
|
</div>
|
||
|
|
<div className={styles.selectWrap}>
|
||
|
|
<MultiSelect
|
||
|
|
options={availableRoles}
|
||
|
|
value={[]}
|
||
|
|
onChange={(roles) => updateGroup(selected.id, {
|
||
|
|
directRoles: [...selected.directRoles, ...roles],
|
||
|
|
})}
|
||
|
|
placeholder="Add roles..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<div className={styles.emptyDetail}>Select a group to view details</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ConfirmDialog
|
||
|
|
open={deleteTarget !== null}
|
||
|
|
onClose={() => setDeleteTarget(null)}
|
||
|
|
onConfirm={handleDelete}
|
||
|
|
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
|
||
|
|
confirmText={deleteTarget?.name ?? ''}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/pages/Admin/UserManagement/GroupsTab.tsx
|
||
|
|
git commit -m "feat: add GroupsTab to User Management"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 15: User Management — RolesTab
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/pages/Admin/UserManagement/RolesTab.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write RolesTab**
|
||
|
|
|
||
|
|
```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 { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||
|
|
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
|
||
|
|
import styles from './UserManagement.module.css'
|
||
|
|
|
||
|
|
export function RolesTab() {
|
||
|
|
const [roles, setRoles] = useState(MOCK_ROLES)
|
||
|
|
const [search, setSearch] = useState('')
|
||
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
|
|
const [creating, setCreating] = useState(false)
|
||
|
|
const [deleteTarget, setDeleteTarget] = useState<MockRole | null>(null)
|
||
|
|
|
||
|
|
const [newName, setNewName] = useState('')
|
||
|
|
const [newDesc, setNewDesc] = useState('')
|
||
|
|
|
||
|
|
const filtered = useMemo(() => {
|
||
|
|
if (!search) return roles
|
||
|
|
const q = search.toLowerCase()
|
||
|
|
return roles.filter((r) =>
|
||
|
|
r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)
|
||
|
|
)
|
||
|
|
}, [roles, search])
|
||
|
|
|
||
|
|
const selected = roles.find((r) => r.id === selectedId) ?? null
|
||
|
|
|
||
|
|
function handleCreate() {
|
||
|
|
if (!newName.trim()) return
|
||
|
|
const newRole: MockRole = {
|
||
|
|
id: `role-${Date.now()}`,
|
||
|
|
name: newName.trim().toUpperCase(),
|
||
|
|
description: newDesc.trim(),
|
||
|
|
scope: 'custom',
|
||
|
|
system: false,
|
||
|
|
}
|
||
|
|
setRoles((prev) => [...prev, newRole])
|
||
|
|
setCreating(false)
|
||
|
|
setNewName(''); setNewDesc('')
|
||
|
|
setSelectedId(newRole.id)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDelete() {
|
||
|
|
if (!deleteTarget) return
|
||
|
|
setRoles((prev) => prev.filter((r) => r.id !== deleteTarget.id))
|
||
|
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||
|
|
setDeleteTarget(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Role assignments
|
||
|
|
const assignedGroups = selected
|
||
|
|
? MOCK_GROUPS.filter((g) => g.directRoles.includes(selected.name))
|
||
|
|
: []
|
||
|
|
|
||
|
|
const directUsers = selected
|
||
|
|
? MOCK_USERS.filter((u) => u.directRoles.includes(selected.name))
|
||
|
|
: []
|
||
|
|
|
||
|
|
const effectivePrincipals = selected
|
||
|
|
? MOCK_USERS.filter((u) => getEffectiveRoles(u).some((r) => r.role === selected.name))
|
||
|
|
: []
|
||
|
|
|
||
|
|
function getAssignmentCount(role: MockRole): number {
|
||
|
|
const groups = MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name)).length
|
||
|
|
const users = MOCK_USERS.filter((u) => u.directRoles.includes(role.name)).length
|
||
|
|
return groups + users
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div className={styles.splitPane}>
|
||
|
|
<div className={styles.listPane}>
|
||
|
|
<div className={styles.listHeader}>
|
||
|
|
<Input
|
||
|
|
placeholder="Search roles..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
onClear={() => setSearch('')}
|
||
|
|
className={styles.listHeaderSearch}
|
||
|
|
/>
|
||
|
|
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
||
|
|
+ Add role
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{creating && (
|
||
|
|
<div className={styles.createForm}>
|
||
|
|
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||
|
|
<Input placeholder="Description" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
|
||
|
|
<div className={styles.createFormActions}>
|
||
|
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||
|
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim()}>Create</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className={styles.entityList}>
|
||
|
|
{filtered.map((role) => (
|
||
|
|
<div
|
||
|
|
key={role.id}
|
||
|
|
className={`${styles.entityItem} ${selectedId === role.id ? styles.entityItemSelected : ''}`}
|
||
|
|
onClick={() => setSelectedId(role.id)}
|
||
|
|
>
|
||
|
|
<Avatar name={role.name} size="sm" />
|
||
|
|
<div className={styles.entityInfo}>
|
||
|
|
<div className={styles.entityName}>
|
||
|
|
{role.name}
|
||
|
|
{role.system && <span title="System role"> 🔒</span>}
|
||
|
|
</div>
|
||
|
|
<div className={styles.entityMeta}>
|
||
|
|
{role.description} · {getAssignmentCount(role)} assignments
|
||
|
|
</div>
|
||
|
|
<div className={styles.entityTags}>
|
||
|
|
{MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name))
|
||
|
|
.map((g) => <Badge key={g.id} label={g.name} color="success" />)}
|
||
|
|
{MOCK_USERS.filter((u) => u.directRoles.includes(role.name))
|
||
|
|
.map((u) => <Badge key={u.id} label={u.username} color="auto" />)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.detailPane}>
|
||
|
|
{selected ? (
|
||
|
|
<>
|
||
|
|
<div className={styles.detailHeader}>
|
||
|
|
<Avatar name={selected.name} size="lg" />
|
||
|
|
<div className={styles.detailHeaderInfo}>
|
||
|
|
<div className={styles.detailName}>{selected.name}</div>
|
||
|
|
{selected.description && (
|
||
|
|
<div className={styles.detailEmail}>{selected.description}</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{!selected.system && (
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="danger"
|
||
|
|
onClick={() => setDeleteTarget(selected)}
|
||
|
|
>
|
||
|
|
Delete
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className={styles.metaGrid}>
|
||
|
|
<span className={styles.metaLabel}>ID</span>
|
||
|
|
<MonoText size="xs">{selected.id}</MonoText>
|
||
|
|
<span className={styles.metaLabel}>Scope</span>
|
||
|
|
<span className={styles.metaValue}>{selected.scope}</span>
|
||
|
|
{selected.system && (
|
||
|
|
<>
|
||
|
|
<span className={styles.metaLabel}>Type</span>
|
||
|
|
<span className={styles.metaValue}>System role (read-only)</span>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<SectionHeader>Assigned to groups</SectionHeader>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{assignedGroups.map((g) => <Tag key={g.id} label={g.name} color="success" />)}
|
||
|
|
{assignedGroups.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{directUsers.map((u) => <Tag key={u.id} label={u.displayName} color="auto" />)}
|
||
|
|
{directUsers.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<SectionHeader>Effective principals</SectionHeader>
|
||
|
|
<div className={styles.sectionTags}>
|
||
|
|
{effectivePrincipals.map((u) => {
|
||
|
|
const isDirect = u.directRoles.includes(selected.name)
|
||
|
|
return (
|
||
|
|
<Badge
|
||
|
|
key={u.id}
|
||
|
|
label={u.displayName}
|
||
|
|
color="auto"
|
||
|
|
variant={isDirect ? 'filled' : 'dashed'}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
{effectivePrincipals.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||
|
|
</div>
|
||
|
|
{effectivePrincipals.some((u) => !u.directRoles.includes(selected.name)) && (
|
||
|
|
<span className={styles.inheritedNote}>
|
||
|
|
Dashed entries inherit this role through group membership
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<div className={styles.emptyDetail}>Select a role to view details</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ConfirmDialog
|
||
|
|
open={deleteTarget !== null}
|
||
|
|
onClose={() => setDeleteTarget(null)}
|
||
|
|
onConfirm={handleDelete}
|
||
|
|
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
|
||
|
|
confirmText={deleteTarget?.name ?? ''}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```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"
|
||
|
|
```
|