diff --git a/CHANGELOG.md b/CHANGELOG.md index 554bf64..d27197d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Added — Sprint 11 (Spectrum UX port: 4 primitives + compact density global) + +**Frontend only** (233 vitest passing — baseline 212 + 21 new tests across 4 new specs) + +- `frontend/src/hooks/useHashTab.ts` — NEW. Pure-TS hook that reads `window.location.hash`, falls back to a default tab id, and listens to `hashchange` (cleanup on unmount). No style dependency. +- `frontend/src/components/Tabs.tsx` — NEW. `` component. Underline variant: `tab-underline` / `tab-underline-active` CSS recipes. Count pill: `tab-count-pill` / `tab-count-pill-active` (`rounded-pill` exception per DESIGN.md). `role="tablist"` + `aria-selected` on tabs. Zero transitions. +- `frontend/src/components/AlertBanner.tsx` — NEW. ``. Border-l-4 semantic strip, `bg-paper`, Lucide icon at `size={16}`. ARIA: `role="alert"` for error/warn, `role="status"` for success/info. No shadow, no radius, no transition. +- `frontend/src/components/BackLink.tsx` — NEW. `` renders `ArrowLeft` (Lucide, 14px) + text with `caption-md text-graphite hover:text-primary`. Replaces 3 hand-rolled back-link instances. +- `frontend/src/styles/index.css` — Added recipes: `.tab-underline`, `.tab-underline-active`, `.tab-count-pill`, `.tab-count-pill-active`, `.alert-error`, `.alert-warn`, `.alert-success`, `.alert-info`, `.table-compact` (32px row density, global). +- `frontend/src/pages/EngagementDetailPage.tsx` — Refactored to 3-tab layout (Schedule / Description / Simulations) wired via `useHashTab('schedule')`. Count pill on Simulations tab. BackLink replaces hand-rolled link. +- `frontend/src/pages/SimulationFormPage.tsx` — Done banner and SOC-blocked banner migrated to ``. Both back-link instances replaced with ``. +- `frontend/src/pages/TemplateFormPage.tsx` — Back-link replaced with ``. +- `frontend/src/pages/EngagementsListPage.tsx` — Table marked `table-compact`, inline `px-xl py-md` padding removed from th/td (recipe handles it). +- `frontend/src/pages/TemplatesListPage.tsx` — Same compact table treatment. +- `frontend/src/pages/UsersAdminPage.tsx` — Same compact table treatment. +- `frontend/src/components/SimulationList.tsx` — Same compact table treatment. +- `DESIGN.md` — Added `### Navigation › Sub-page tabs` subsection; updated `### Data Tables` row min-height 44px → 32px with WCAG SC 2.5.5 rationale; added `### Inline Banners` subsection under `### Toast Notifications`. + +**Tests added** (21 new assertions across 4 new spec files): +- `tests/hooks/useHashTab.test.tsx` — 4 tests (default, hash read on mount, navigate(), empty-hash fallback) +- `tests/components/Tabs.test.tsx` — 7 tests (render, aria-selected, active class, count pill, count-pill-active, onChange, brutalism) +- `tests/components/AlertBanner.test.tsx` — 6 tests (4 variants × role + class, title prop, brutalism) +- `tests/components/BackLink.test.tsx` — 4 tests (render, href, brutalism, svg icon) + +**No backend changes.** No DB schema change. No migration. + ### Changed — Sprint 7 (Terminal-SOC design refresh) **Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names) diff --git a/DESIGN.md b/DESIGN.md index a4cd1d0..bb99a46 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -216,11 +216,23 @@ Shadows (`box-shadow`) are not used anywhere. - Text `{colors.slab-text}`, caption-md, `rounded-none` - Active state: 2px primary bottom border +#### Sub-page tabs + +Used inside pages that need content partitioning without a URL change (e.g. EngagementDetailPage: Schedule / Description / Simulations). + +- **Container**: `flex items-end border-b border-hairline gap-xs` — sits on the `{colors.hairline}` bottom line. +- **`.tab-underline`**: `text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs`. Hover is **instant** — no `transition-*`. +- **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text. +- **Count pill** (optional, e.g. simulation count): `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception. +- **Active count pill**: `.tab-count-pill-active` — `bg-primary-soft text-primary`. +- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` on each button. + ### Data Tables - `table-layout: fixed`, `word-break: break-word` - Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border -- Data cells: 44px min-height, 1px `{colors.hairline}` bottom border +- Data cells: **32px min-height** (compact density, all surfaces). Class: `.table-compact`. + - **WCAG SC 2.5.5 tradeoff**: the recommended 44×44px touch target is intentionally not met on data rows. Mimic is a BAS operator console used at desktop on mouse/keyboard — density and scanability take priority over touch target size. Action buttons within rows retain icon ≥ 24px with adequate padding to preserve a usable hit area. - ID / date / technique columns: `font-mono` - Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide @@ -229,6 +241,23 @@ Shadows (`box-shadow`) are not used anywhere. - Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary) - Background `{colors.paper}`, 1px `{colors.hairline}` border +#### Inline Banners + +For persistent in-page status messages (not ephemeral toasts). Use `` component. + +Four variants — all share: `bg-paper border border-hairline border-l-4 rounded-none px-md py-sm flex items-start gap-sm`. + +| Variant | Left strip color | ARIA role | Lucide icon | +|---------|-----------------|-----------|-------------| +| `error` | `border-l-bloom-deep` | `role="alert"` | `AlertCircle` | +| `warn` | `border-l-warn` | `role="alert"` | `AlertTriangle` | +| `success` | `border-l-success` | `role="status"` | `CheckCircle` | +| `info` | `border-l-primary` | `role="status"` | `Info` | + +- Icon: Lucide at `size={16}`, `aria-hidden`, `mt-[2px] shrink-0` for baseline alignment. +- No shadow, no rounded corners, no transition. +- Optional `title` prop renders as `font-medium text-ink` above the message body. + --- ## Do's and Don'ts diff --git a/frontend/src/components/AlertBanner.tsx b/frontend/src/components/AlertBanner.tsx new file mode 100644 index 0000000..14af0d7 --- /dev/null +++ b/frontend/src/components/AlertBanner.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; +import { AlertCircle, AlertTriangle, CheckCircle, Info } from 'lucide-react'; + +type AlertVariant = 'error' | 'warn' | 'success' | 'info'; + +interface AlertBannerProps { + variant: AlertVariant; + title?: string; + children: ReactNode; +} + +const CONFIG: Record = { + error: { cls: 'alert-error', Icon: AlertCircle, role: 'alert' }, + warn: { cls: 'alert-warn', Icon: AlertTriangle, role: 'alert' }, + success: { cls: 'alert-success', Icon: CheckCircle, role: 'status' }, + info: { cls: 'alert-info', Icon: Info, role: 'status' }, +}; + +export function AlertBanner({ variant, title, children }: AlertBannerProps): JSX.Element { + const { cls, Icon, role } = CONFIG[variant]; + return ( +
+ +
+ {title ? {title} : null} + {children} +
+
+ ); +} diff --git a/frontend/src/components/BackLink.tsx b/frontend/src/components/BackLink.tsx new file mode 100644 index 0000000..f410709 --- /dev/null +++ b/frontend/src/components/BackLink.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router-dom'; +import { ArrowLeft } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface BackLinkProps { + to: string; + children: ReactNode; +} + +export function BackLink({ to, children }: BackLinkProps): JSX.Element { + return ( + + + {children} + + ); +} diff --git a/frontend/src/components/SimulationList.tsx b/frontend/src/components/SimulationList.tsx index 46d3284..f20b194 100644 --- a/frontend/src/components/SimulationList.tsx +++ b/frontend/src/components/SimulationList.tsx @@ -172,25 +172,25 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
- +
- - - - - + + + + + {data.map((sim) => ( navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`) } > - - - - diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx new file mode 100644 index 0000000..55d70f8 --- /dev/null +++ b/frontend/src/components/Tabs.tsx @@ -0,0 +1,58 @@ +import type { KeyboardEvent } from 'react'; + +interface TabItem { + id: string; + label: string; + count?: number; +} + +interface TabsProps { + items: TabItem[]; + activeId: string; + onChange: (id: string) => void; +} + +export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element { + function handleKeyDown(e: KeyboardEvent, index: number) { + if (e.key === 'ArrowRight') { + e.preventDefault(); + onChange(items[(index + 1) % items.length].id); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + onChange(items[(index - 1 + items.length) % items.length].id); + } + } + + return ( +
+ {items.map((item, index) => { + const isActive = item.id === activeId; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/hooks/useHashTab.ts b/frontend/src/hooks/useHashTab.ts new file mode 100644 index 0000000..106b161 --- /dev/null +++ b/frontend/src/hooks/useHashTab.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useState } from 'react'; + +function readHash(defaultId: string): string { + const hash = window.location.hash.slice(1); // strip leading '#' + return hash || defaultId; +} + +export function useHashTab(defaultId: string): [string, (id: string) => void] { + const [activeId, setActiveId] = useState(() => readHash(defaultId)); + + useEffect(() => { + function onHashChange() { + setActiveId(readHash(defaultId)); + } + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, [defaultId]); + + const navigate = useCallback((id: string) => { + // replaceState: no history entry (Back button unaffected), no anchor-jump scroll + history.replaceState(null, '', '#' + id); + setActiveId(id); + }, []); + + return [activeId, navigate]; +} diff --git a/frontend/src/pages/EngagementDetailPage.tsx b/frontend/src/pages/EngagementDetailPage.tsx index 97b2741..47ca46c 100644 --- a/frontend/src/pages/EngagementDetailPage.tsx +++ b/frontend/src/pages/EngagementDetailPage.tsx @@ -1,12 +1,19 @@ -import { Link, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { extractApiError } from '@/api/client'; import { useAuth } from '@/hooks/useAuth'; import { useEngagement } from '@/hooks/useEngagements'; +import { useHashTab } from '@/hooks/useHashTab'; +import { useEngagementSimulations } from '@/hooks/useSimulations'; import { LoadingState } from '@/components/LoadingState'; import { ErrorState } from '@/components/ErrorState'; import { StatusBadge } from '@/components/StatusBadge'; import { SimulationList } from '@/components/SimulationList'; import { ExportEngagementButton } from '@/components/ExportEngagementButton'; +import { BackLink } from '@/components/BackLink'; +import { Tabs } from '@/components/Tabs'; +import { Link } from 'react-router-dom'; + +type TabId = 'schedule' | 'description' | 'simulations'; export function EngagementDetailPage(): JSX.Element { const { id } = useParams<{ id: string }>(); @@ -14,6 +21,10 @@ export function EngagementDetailPage(): JSX.Element { const { canEditEngagements } = useAuth(); const detail = useEngagement(numericId); + const simsQuery = useEngagementSimulations(numericId); + + const [activeTabRaw, setActiveTab] = useHashTab('schedule'); + const activeTab = activeTabRaw as TabId; if (detail.isLoading) return ; if (detail.isError) { @@ -27,14 +38,19 @@ export function EngagementDetailPage(): JSX.Element { if (!detail.data) return ; const eng = detail.data; + const simCount = simsQuery.data?.length; + + const tabs = [ + { id: 'schedule', label: 'Schedule' }, + { id: 'description', label: 'Description' }, + { id: 'simulations', label: 'Simulations', count: simCount }, + ]; return (
- - ← Back to engagements - + Back to engagements

{eng.name}

@@ -53,32 +69,42 @@ export function EngagementDetailPage(): JSX.Element { ) : null}
-
-
-

Schedule

-
-
Start date
-
{eng.start_date}
-
End date
-
{eng.end_date ?? '—'}
-
Status
-
{eng.status}
-
Created at
-
{eng.created_at}
-
-
+ -
-

Description

-

- {eng.description?.trim() ? eng.description : 'No description provided.'} -

-
-
+
+ {activeTab === 'schedule' && ( +
+

Schedule

+
+
Start date
+
{eng.start_date}
+
End date
+
{eng.end_date ?? '—'}
+
Status
+
{eng.status}
+
Created at
+
{eng.created_at}
+
+
+ )} -
- -
+ {activeTab === 'description' && ( +
+

Description

+

+ {eng.description?.trim() ? eng.description : 'No description provided.'} +

+
+ )} + + {activeTab === 'simulations' && ( + + )} +
); } diff --git a/frontend/src/pages/EngagementsListPage.tsx b/frontend/src/pages/EngagementsListPage.tsx index fc47ea6..93d83b1 100644 --- a/frontend/src/pages/EngagementsListPage.tsx +++ b/frontend/src/pages/EngagementsListPage.tsx @@ -69,32 +69,32 @@ export function EngagementsListPage(): JSX.Element { {!isLoading && !isError && data && data.length > 0 ? (
-
NameMITREStatusExecuted at
NameMITREStatusExecuted at
+ + {(() => { const items = [ ...(sim.tactics ?? []).map((t) => t.id), @@ -210,10 +210,10 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme return `${items[0]} +${items.length - 1}`; })()} + + {formatDate(sim.executed_at)}
+
- - - - - - - + + + + + + + {data.map((eng) => ( - - + - - - - - + + +
NameStatusStartEndCreated byActions
NameStatusStartEndCreated byActions
+
{eng.name} + {formatDate(eng.start_date)}{formatDate(eng.end_date)}{eng.created_by.username} + {formatDate(eng.start_date)}{formatDate(eng.end_date)}{eng.created_by.username}
View diff --git a/frontend/src/pages/SimulationFormPage.tsx b/frontend/src/pages/SimulationFormPage.tsx index ae9ac3a..663234b 100644 --- a/frontend/src/pages/SimulationFormPage.tsx +++ b/frontend/src/pages/SimulationFormPage.tsx @@ -22,6 +22,8 @@ import { MitreTechniquesField } from '@/components/MitreTechniquesField'; import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal'; import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal'; import { C2TasksPanel } from '@/components/C2TasksPanel'; +import { AlertBanner } from '@/components/AlertBanner'; +import { BackLink } from '@/components/BackLink'; interface RedteamFormState { name: string; @@ -238,9 +240,7 @@ export function SimulationFormPage(): JSX.Element { return (
- - ← Back to engagement - + Back to engagement

New simulation

@@ -279,9 +279,7 @@ export function SimulationFormPage(): JSX.Element {
- - ← Back to engagement - + Back to engagement

{rt.name || simulation?.name}

{status ? (
@@ -298,22 +296,17 @@ export function SimulationFormPage(): JSX.Element { {/* Done banner */} {isDone && ( -
+ This simulation is done and read-only. Use Reopen to make changes. -
+ )} {/* SOC banner */} {socBlocked && ( -
- Simulation not yet ready for review — the red team must mark it as "Review required" before you can fill in the SOC section. +
+ + Simulation not yet ready for review — the red team must mark it as "Review required" before you can fill in the SOC section. +
)} diff --git a/frontend/src/pages/TemplateFormPage.tsx b/frontend/src/pages/TemplateFormPage.tsx index 8300a54..566d7b6 100644 --- a/frontend/src/pages/TemplateFormPage.tsx +++ b/frontend/src/pages/TemplateFormPage.tsx @@ -9,6 +9,7 @@ import { FormField, TextArea, TextInput } from '@/components/FormField'; import { LoadingState } from '@/components/LoadingState'; import { ErrorState } from '@/components/ErrorState'; import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { BackLink } from '@/components/BackLink'; import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag'; import { MitreTechniquePicker } from '@/components/MitreTechniquePicker'; import { MitreMatrixModal } from '@/components/MitreMatrixModal'; @@ -127,9 +128,7 @@ export function TemplateFormPage(): JSX.Element {
- - ← Back to templates - + Back to templates

{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}

diff --git a/frontend/src/pages/TemplatesListPage.tsx b/frontend/src/pages/TemplatesListPage.tsx index 288d4b0..10455b3 100644 --- a/frontend/src/pages/TemplatesListPage.tsx +++ b/frontend/src/pages/TemplatesListPage.tsx @@ -69,20 +69,20 @@ export function TemplatesListPage(): JSX.Element { {!isLoading && !isError && data && data.length > 0 ? (
- +
- - - - - - + + + + + + {data.map((t) => ( - - + - - - - + +
NameMITRECreated byUpdatedActions
NameMITRECreated byUpdatedActions
+
+ {mitreCount(t) === 0 ? '—' : mitreCount(t)} {t.created_by.username}{formatDate(t.updated_at)} + {t.created_by.username}{formatDate(t.updated_at)}
Edit diff --git a/frontend/src/pages/UsersAdminPage.tsx b/frontend/src/pages/UsersAdminPage.tsx index 301a17c..46e9eb1 100644 --- a/frontend/src/pages/UsersAdminPage.tsx +++ b/frontend/src/pages/UsersAdminPage.tsx @@ -188,13 +188,13 @@ export function UsersAdminPage(): JSX.Element { {!list.isLoading && !list.isError && list.data && list.data.length > 0 ? (
- +
- - - - - + + + + + @@ -204,8 +204,8 @@ export function UsersAdminPage(): JSX.Element { // Fragment must carry the key — `<>` cannot, which broke // per-row reconciliation (reset-password state leaked across rows). - - + - - - + + {/* Aerated row — inline form needs room; compact-density tradeoff intentional */}
UsernameRoleCreatedActions
UsernameRoleCreatedActions
+
{u.username} {isSelf ? ( @@ -213,7 +213,7 @@ export function UsersAdminPage(): JSX.Element { ) : null} + {u.created_at} + {u.created_at}
onResetPassword(u, e)} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 14e3350..c6116e9 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -147,4 +147,48 @@ .tag-mitre { @apply inline-flex items-center bg-cloud text-ink border border-hairline rounded-none px-2 py-[2px] font-mono text-[12px] leading-[1.33]; } + + /* ─── Sub-page tabs (L1) ─────────────────────────────────────────────── */ + .tab-underline { + @apply text-graphite text-caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs; + } + .tab-underline-active { + @apply text-primary border-primary; + } + /* Count pill — rounded-pill is the allowed exception per DESIGN.md */ + .tab-count-pill { + @apply inline-flex items-center rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono; + } + .tab-count-pill-active { + @apply bg-primary-soft text-primary; + } + + /* ─── Inline alert banners (L2) ─────────────────────────────────────── */ + .alert-error { + @apply bg-paper border border-hairline border-l-4 border-l-bloom-deep rounded-none px-md py-sm flex items-start gap-sm; + } + .alert-warn { + @apply bg-paper border border-hairline border-l-4 border-l-warn rounded-none px-md py-sm flex items-start gap-sm; + } + .alert-success { + @apply bg-paper border border-hairline border-l-4 border-l-success rounded-none px-md py-sm flex items-start gap-sm; + } + .alert-info { + @apply bg-paper border border-hairline border-l-4 border-l-primary rounded-none px-md py-sm flex items-start gap-sm; + } + + /* ─── Compact table density (L4) — 32px row height, global ──────────── */ + .table-compact thead tr { + @apply text-[12px] uppercase tracking-[0.5px] text-graphite; + } + .table-compact th { + @apply px-md py-xxs; + } + .table-compact td { + @apply px-md py-xxs text-caption-md; + height: 32px; + } + .table-compact tbody tr { + @apply border-b border-hairline last:border-0; + } } diff --git a/frontend/tests/components/AlertBanner.test.tsx b/frontend/tests/components/AlertBanner.test.tsx new file mode 100644 index 0000000..281259b --- /dev/null +++ b/frontend/tests/components/AlertBanner.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AlertBanner } from '@/components/AlertBanner'; + +describe('AlertBanner', () => { + it('renders error variant with alert role and alert-error class', () => { + const { container } = render(Error message); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute('role', 'alert'); + expect(el).toHaveClass('alert-error'); + expect(screen.getByText('Error message')).toBeInTheDocument(); + }); + + it('renders warn variant with alert role and alert-warn class', () => { + const { container } = render(Warning message); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute('role', 'alert'); + expect(el).toHaveClass('alert-warn'); + }); + + it('renders success variant with status role and alert-success class', () => { + const { container } = render(Done!); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute('role', 'status'); + expect(el).toHaveClass('alert-success'); + }); + + it('renders info variant with status role and alert-info class', () => { + const { container } = render(FYI); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute('role', 'status'); + expect(el).toHaveClass('alert-info'); + }); + + it('renders optional title when provided', () => { + render(Detail text); + expect(screen.getByText('Heads up')).toBeInTheDocument(); + expect(screen.getByText('Detail text')).toBeInTheDocument(); + }); + + it('has no rounded-md, transition-*, or shadow-* (brutalism)', () => { + const { container } = render(Test); + const el = container.firstChild as HTMLElement; + expect(el).not.toHaveClass('rounded-md'); + expect(el.className).not.toMatch(/transition-/); + expect(el.className).not.toMatch(/shadow-/); + }); +}); diff --git a/frontend/tests/components/BackLink.test.tsx b/frontend/tests/components/BackLink.test.tsx new file mode 100644 index 0000000..0f922a7 --- /dev/null +++ b/frontend/tests/components/BackLink.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { BackLink } from '@/components/BackLink'; + +function renderBackLink(to: string, label: string) { + return render( + + {label} + , + ); +} + +describe('BackLink', () => { + it('renders children as link text', () => { + renderBackLink('/engagements', 'Back to engagements'); + expect(screen.getByText('Back to engagements')).toBeInTheDocument(); + }); + + it('renders as an anchor with the correct href', () => { + renderBackLink('/engagements', 'Back to engagements'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/engagements'); + }); + + it('has no rounded-md, transition-*, or shadow-* (brutalism)', () => { + renderBackLink('/engagements', 'Back'); + const link = screen.getByRole('link'); + expect(link).not.toHaveClass('rounded-md'); + expect(link.className).not.toMatch(/transition-/); + expect(link.className).not.toMatch(/shadow-/); + }); + + it('contains the ArrowLeft icon (svg element)', () => { + const { container } = renderBackLink('/engagements', 'Back'); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/components/Tabs.test.tsx b/frontend/tests/components/Tabs.test.tsx new file mode 100644 index 0000000..927e107 --- /dev/null +++ b/frontend/tests/components/Tabs.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Tabs } from '@/components/Tabs'; + +const ITEMS = [ + { id: 'schedule', label: 'Schedule' }, + { id: 'description', label: 'Description' }, + { id: 'simulations', label: 'Simulations', count: 5 }, +]; + +describe('Tabs', () => { + it('renders all tab labels', () => { + render(); + expect(screen.getByText('Schedule')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Simulations')).toBeInTheDocument(); + }); + + it('marks the active tab with aria-selected=true', () => { + render(); + const descBtn = screen.getByRole('tab', { name: /Description/i }); + expect(descBtn).toHaveAttribute('aria-selected', 'true'); + const schedBtn = screen.getByRole('tab', { name: /Schedule/i }); + expect(schedBtn).toHaveAttribute('aria-selected', 'false'); + }); + + it('applies tab-underline-active class to the active tab', () => { + render(); + const activeBtn = screen.getByRole('tab', { name: /Schedule/i }); + expect(activeBtn).toHaveClass('tab-underline-active'); + }); + + it('renders count pill for the simulations tab', () => { + render(); + const pill = screen.getByText('5'); + expect(pill).toHaveClass('tab-count-pill'); + }); + + it('applies tab-count-pill-active when the tab with count is active', () => { + render(); + const pill = screen.getByText('5'); + expect(pill).toHaveClass('tab-count-pill-active'); + }); + + it('calls onChange with the tab id when clicked', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', { name: /Description/i })); + expect(onChange).toHaveBeenCalledWith('description'); + }); + + it('sets aria-controls and id on each tab button', () => { + render(); + const schedBtn = screen.getByRole('tab', { name: /Schedule/i }); + expect(schedBtn).toHaveAttribute('id', 'tab-schedule'); + expect(schedBtn).toHaveAttribute('aria-controls', 'tabpanel-schedule'); + const simsBtn = screen.getByRole('tab', { name: /Simulations/i }); + expect(simsBtn).toHaveAttribute('id', 'tab-simulations'); + expect(simsBtn).toHaveAttribute('aria-controls', 'tabpanel-simulations'); + }); + + it('ArrowRight moves focus to the next tab', () => { + const onChange = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('tab', { name: /Schedule/i }), { key: 'ArrowRight' }); + expect(onChange).toHaveBeenCalledWith('description'); + }); + + it('ArrowLeft wraps around to the last tab', () => { + const onChange = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('tab', { name: /Schedule/i }), { key: 'ArrowLeft' }); + expect(onChange).toHaveBeenCalledWith('simulations'); + }); + + it('has no rounded-md, transition-*, or shadow-* on tab buttons (brutalism)', () => { + render(); + for (const btn of screen.getAllByRole('tab')) { + expect(btn).not.toHaveClass('rounded-md'); + expect(btn.className).not.toMatch(/transition-/); + expect(btn.className).not.toMatch(/shadow-/); + } + }); +}); diff --git a/frontend/tests/hooks/useHashTab.test.tsx b/frontend/tests/hooks/useHashTab.test.tsx new file mode 100644 index 0000000..21a37e2 --- /dev/null +++ b/frontend/tests/hooks/useHashTab.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useHashTab } from '@/hooks/useHashTab'; + +beforeEach(() => { + window.location.hash = ''; +}); + +afterEach(() => { + window.location.hash = ''; +}); + +describe('useHashTab', () => { + it('returns the default id when hash is empty', () => { + const { result } = renderHook(() => useHashTab('schedule')); + expect(result.current[0]).toBe('schedule'); + }); + + it('reads the hash on mount', () => { + window.location.hash = 'simulations'; + const { result } = renderHook(() => useHashTab('schedule')); + expect(result.current[0]).toBe('simulations'); + }); + + it('navigate() updates activeId and sets hash via replaceState (no history entry)', () => { + const { result } = renderHook(() => useHashTab('schedule')); + act(() => { + result.current[1]('description'); + }); + expect(result.current[0]).toBe('description'); + expect(window.location.hash).toBe('#description'); + }); + + it('falls back to defaultId when hash becomes empty', () => { + window.location.hash = 'simulations'; + const { result } = renderHook(() => useHashTab('schedule')); + act(() => { + window.location.hash = ''; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(result.current[0]).toBe('schedule'); + }); +}); diff --git a/tasks/todo.md b/tasks/todo.md index 887b4d5..02b3964 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,115 +1,164 @@ -# Sprint 10 — C2 TLS verify: redteam-friendly defaults + warning suppression +# Sprint 11 — Spectrum UX port : 4 primitives + compact density global -**Base**: `origin/main` (PR #11 merged — sprint 8 + 9 are in). -**Branch**: `sprint/10-c2-tls-default`. -**Scope**: tiny correctness sprint. No new feature. Flip a security-relevant default to match the actual operator usage of this tool. +**Base** : `origin/main` (PR #12 mergée — sprint 8 + 9 + 10 en). +**Branch** : `sprint/11-spectrum-ux`. +**Scope** : frontend-only sprint, 5 livrables coordonnés. Pas de backend, pas de schéma, pas de nouvelle API. Pas de modif tokens couleurs/spacing/font dans tailwind.config. --- -## Symptom (user report) +## Contexte -> "Impossible de se connecter à mon C2 à cause de la vérification du certificat SSL (self signed)" +Analyse Spectrum (`/home/user/Documents/01_Projects/spectrum`) faite via workflow. Résultat : Spectrum n'a **pas de design system** (Tailwind raw + violet ramp + recipes inlinées 30× partout). Mimic est structurellement meilleur. On ne porte pas la philosophie Spectrum — on extrait **5 patterns isolés** qui survivent au brutalisme Mimic. -Operator opens C2 config card, fills URL + token, hits **Test connection** without noticing the checkbox → SSL VERIFY FAILED against self-signed Mythic. +## Constraints absolues -## Root cause (workflow diagnosis confirmed) +1. **Primary `#024ad8` (Electric Blue) reste**. Pas de violet, pas de spectrum ramp. +2. **Brutalisme reste** : `rounded-none` sur containers (sauf status pills + tab count pills + avatars), zero `transition-*`, zero `shadow-*`, hairline 1px borders. +3. **Pas de modif `tailwind.config.*`** sauf si une nouvelle classe utility est strictement nécessaire (justifier dans le PR). +4. **DESIGN.md amendments additives uniquement** — pas de modif des tokens existants. +5. **Mono uniquement pour data** (IDs MITRE, dates ISO, commands, etc.) — rule sprint 7. -The `verify_tls` chain is **fully intact** end-to-end : -React `verifyTls` state → `C2ConfigInput.verify_tls` → PUT body → `C2Config.verify_tls` column → `cfg.verify_tls` in API loader → `get_adapter(verify_tls=)` → `MythicAdapter._verify` → `requests.post(verify=self._verify)`. +## Décisions binding (lockées par user) -The bug is the **default** at every layer is `True` : -- `backend/app/models/c2_config.py:22` — `default=True` -- `backend/migrations/versions/0006_c2_layer.py:29` — `server_default=sa.true()` -- `backend/app/api/c2.py:87` — `data.get("verify_tls", True)` -- `backend/app/services/c2/mythic.py:116` — `verify_tls: bool = True` -- `backend/app/services/c2/factory.py:9` — `verify_tls=True` -- `frontend/src/components/C2ConfigCard.tsx:27` — `useState(true)` -- `frontend/src/components/C2ConfigCard.tsx:74` — reset on delete `setVerifyTls(true)` - -Mimic is a BAS / red-team lab tool ; the dominant case is a **self-signed Mythic instance**, not a publicly-trusted cert chain. Defaulting `verify=True` is hostile to the actual workflow. - -Secondary defect : `urllib3.exceptions.InsecureRequestWarning` is never suppressed (zero hits for `disable_warnings` / `urllib3` in `backend/`). When operators correctly uncheck verify, stderr gets spammed once per HTTP call. +- **Q1 → B** : Tabs primitive **+ consumer EngagementDetailPage** (3 tabs : Schedule / Description / Simulations au lieu du stack vertical actuel). +- **Q2 → B** : **Compact density GLOBAL** — toutes les tables passent à 32px row, pas opt-in. User accepte le tradeoff WCAG (touch target) sur Engagements list. +- **Q3 → A** : AlertBanner + refactor `SimulationFormPage` (Done banner + SOC-blocked banner). --- -## Decisions (locked) +## Livrables -1. **Flip default to `False` at every layer** — model, API fallback, adapter, factory, React state, delete reset. -2. **New migration 0008** : flip `server_default` to `sa.false()`. Existing rows are NOT mutated (their stored boolean is preserved). -3. **Suppress `urllib3.InsecureRequestWarning` ONLY when `verify_tls=False`** — gated inside `MythicAdapter.__init__`. Keeps the warning live for any future code that legitimately verifies. -4. **Add helper text under checkbox** : "Leave unchecked for lab Mythic with self-signed certificates." Operators see why the box matters. -5. **No API contract change** — `C2ConfigInput.verify_tls: boolean` stays required. The fallback in `data.get("verify_tls", False)` only matters for hand-crafted requests. - -## Out of scope - -- Don't touch existing C2 endpoints behavior (route paths, payload shapes). -- Don't change the `verify_tls` field type or remove the column. -- Don't change the FakeAdapter (it makes no HTTP calls). - ---- - -## Task A — Backend (backend-builder) +### Livrable 1 — `Tabs` primitive + `useHashTab` hook + consumer **Files** : -- `backend/app/models/c2_config.py` — line 22 : `default=True` → `default=False` -- `backend/app/api/c2.py` — line 87 : `data.get("verify_tls", True)` → `data.get("verify_tls", False)` -- `backend/app/services/c2/factory.py` — line 9 : `verify_tls: bool = True` → `verify_tls: bool = False` -- `backend/app/services/c2/mythic.py` — line 116 : `verify_tls: bool = True` → `verify_tls: bool = False`, AND add at top of file `import urllib3` + `from urllib3.exceptions import InsecureRequestWarning`, AND inside `__init__` after `self._verify = verify_tls`: - ```python - if not verify_tls: - urllib3.disable_warnings(InsecureRequestWarning) - ``` -- **NEW migration** `backend/migrations/versions/0008_c2_verify_tls_default_false.py` — flip `server_default` to `sa.false()`. Use `op.batch_alter_table("c2_config")` for SQLite compatibility (Mimic uses SQLite per sprint 1 SPEC). Down-migration restores `sa.true()`. -- **Tests** : grep `backend/tests/` for `verify_tls` and flip every assertion that presupposed the old `True` default (likely in `test_c2_config*.py` PUT-without-verify-tls tests and GET-fresh-row tests). Don't add new tests — adapt existing ones. +- `frontend/src/hooks/useHashTab.ts` (NEW) — ~30 LoC. Reads `window.location.hash`, falls back to default ID, listens to `hashchange`, updates URL without reload. TypeScript pur, zéro style. +- `frontend/src/components/Tabs.tsx` (NEW) — composant ``. Underline variant. +- `frontend/src/styles/index.css` — nouvelles recipes : + - `.tab-underline` — `text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink` (instantané, pas de transition) + - `.tab-underline-active` — `text-primary border-primary` (override) + - `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono` + - `.tab-count-pill-active` — `bg-primary-soft text-primary` (override) +- `frontend/src/pages/EngagementDetailPage.tsx` — refactor en 3 tabs : + - Tab 1 "Schedule" (id `schedule`, default) — dates + statut + bouton "Edit engagement" + - Tab 2 "Description" (id `description`) — texte description + - Tab 3 "Simulations" (id `simulations`) — la liste actuelle de simulations + - Count pills sur tab 3 = nombre de simulations + - Tabs branchées sur `useHashTab('schedule')` +- `DESIGN.md` — nouvelle subsection `### Navigation › Sub-page tabs` (additive) -**Constraints** : -- `pytest` baseline 468/468 must hold (or grow ; never shrink). -- `ruff` + `mypy --strict` clean. -- Migration 0008 must be reversible — round-trip `alembic upgrade head` then `alembic downgrade -1` then `alembic upgrade head` must work on a fresh SQLite DB. -- Don't restructure or refactor anything else. Minimum surface. +**Brutalism check** : +- Tab : `border-b-2` only, pas de bg, pas de rounded, pas de transition +- Pill : `rounded-pill` autorisé (exception status pill DESIGN.md L125) +- Hover : `hover:text-ink` instantané -## Task B — Frontend (frontend-builder) +### Livrable 2 — `AlertBanner` component + 4 recipes **Files** : -- `frontend/src/components/C2ConfigCard.tsx` : - - Line 27 : `useState(true)` → `useState(false)` - - Line 74 (delete handler) : `setVerifyTls(true)` → `setVerifyTls(false)` - - Under the checkbox JSX (around lines 169-182) : add a `

` with helper text : - ```tsx -

- Leave unchecked for lab Mythic with self-signed certificates. -

- ``` - (Use the existing DESIGN.md tokens — `text-[12px] text-charcoal` matches the `hint` style on `FormField`. Confirm token name by reading neighboring components first.) -- **Vitest** : if `C2ConfigCard.test.tsx` exists, flip any "starts checked" assertion to "starts unchecked". +- `frontend/src/components/AlertBanner.tsx` (NEW) — ``. Brutalist : border-l-4 strip semantic, fill `bg-paper`, border `border-hairline` partout sauf le strip gauche, `rounded-none`. Icon Lucide à gauche (`AlertCircle` error, `AlertTriangle` warn, `CheckCircle` success, `Info` info) à `size={16}`. +- `frontend/src/styles/index.css` — 4 nouvelles recipes : + - `.alert-error` — `bg-paper border border-hairline border-l-4 border-l-bloom-deep px-md py-sm flex items-start gap-sm` + - `.alert-warn` — same + `border-l-warn` + - `.alert-success` — same + `border-l-success` + - `.alert-info` — same + `border-l-primary` +- `frontend/src/pages/SimulationFormPage.tsx` — refactor 2 banners hand-rollés (~L300 Done banner + ~L310 SOC-blocked banner) → `` / ``. +- `DESIGN.md` — extend `### Toast Notifications` avec subsection `### Inline Banners` (additive, mêmes 4 variants). -**Constraints** : -- `vitest` baseline 212/212 must hold. +**Note** : `bloom-deep`, `warn`, `success`, `primary` sont les tokens existants — pas de nouveau token couleur. + +### Livrable 3 — `BackLink` component (dédup) + +**Files** : +- `frontend/src/components/BackLink.tsx` (NEW) — `Back to engagements`. Renders `← {children}` (ArrowLeft Lucide size=14, gap-xxs). Style : `text-graphite hover:text-primary caption-md` instantané. +- Refactor consumers (3 instances hand-rollées) : + - `frontend/src/pages/EngagementDetailPage.tsx` (~L35) + - `frontend/src/pages/SimulationFormPage.tsx` (back-link en edit mode) + - `frontend/src/pages/TemplateFormPage.tsx` (back-link en edit mode) + +### Livrable 4 — `.table-compact` recipe + apply GLOBAL + +**Files** : +- `frontend/src/styles/index.css` — nouvelle recipe (mais appliquée comme default, pas opt-in cette sprint per user decision Q2-B) : + - Soit : modifier la recipe `.card-product table` existante pour passer à 32px row, `caption-md` text, `py-xxs px-xs` cells, divider `border-hairline`. + - Soit : ajouter `.table-compact` puis l'appliquer à TOUTES les list pages (Engagements, Templates, Users, etc.). + - **Choix builder** : pick the cleaner path. Préférer modifier le default si toutes les tables doivent suivre, sinon `.table-compact` + apply partout. +- Apply sur : + - `frontend/src/pages/EngagementsListPage.tsx` + - `frontend/src/pages/TemplatesListPage.tsx` + - `frontend/src/pages/UsersAdminPage.tsx` + - `frontend/src/components/SimulationList.tsx` (utilisé dans EngagementDetailPage) + - Toute autre table dans le code (grep `` hint below the checkbox-label row. Same brutalist treatment, no transition. +- Brutalism unit assertions : pour chaque new component, un test qui vérifie `expect(el).not.toHaveClass('rounded-md')` (sauf pill counter) + `.not.toHaveClass(/transition-/)` + `.not.toHaveClass(/shadow-/)`. -## Task C — Sequencing +## Out of scope (explicite) -Both tasks have **zero shared files**. Dispatch backend-builder + frontend-builder **in parallel**. No ordering constraint. - -After both report green : -- **code-reviewer** : sprint diff scan (focus : migration reversibility, urllib3 gating, no leftover hardcoded `True`). -- **design-reviewer** : helper-text placement, token compliance, focus ring still works, no regression on the card. -- **spec-reviewer** : verify SPEC.md § Intégration C2 still matches the new defaults (may need a one-line note about lab-mode default ; check before editing). +- ❌ Side nav / drawer / breadcrumb component — top nav + BackLink suffit +- ❌ Global engagement selector top-bar (Spectrum pattern rejeté) +- ❌ Editor dynamic-column grid — pas d'éditeur dans Mimic +- ❌ Dark mode rework +- ❌ Toast queue system (les toasts actuels via `useToast` restent) +- ❌ PDF/document deliverable +- ❌ Color palette changes +- ❌ Tokens spacing/typo dans tailwind.config ## Definition of Done -- Test connection against self-signed Mythic from a freshly-created C2 config works **without unchecking anything**. -- Existing rows are untouched (operators who saved verify_tls=true keep it until they re-save). -- `pytest` 468/468 → 468+ (no shrink), `vitest` 212/212. -- `ruff` + `mypy --strict` + `tsc --noEmit` + `eslint` clean. -- Migration 0008 round-trip OK. -- No `urllib3.InsecureRequestWarning` on stderr when `verify_tls=False`. -- Code-reviewer + design-reviewer + spec-reviewer APPROVED. -- PR opened on Gitea ; tasks/pr-body-sprint-10.md drafted by team-lead. - -## Operator notes (for PR body) - -- This sprint flips a security-relevant default. Production operators who legitimately use a publicly-trusted cert chain will need to explicitly check the box — but the dominant case in this BAS tool is lab Mythic with self-signed, so the new default matches the actual workflow. -- Existing engagements with `verify_tls=true` already stored remain unchanged. They keep their current behaviour. If the operator reported the bug because their existing row has `verify_tls=true`, they will still need to uncheck + save once after this sprint lands. +- ✅ 5 primitives livrées + EngagementDetailPage refactorée en tabs + SimulationFormPage banners migrés + BackLink dédup × 3 +- ✅ vitest **212+/212+** (4 new specs ajoutées), tsc + lint clean +- ✅ pytest **469/469** intact (zéro backend touché) +- ✅ Toutes tables passent à 32px row density (validation visuelle) +- ✅ Design-reviewer APPROVED (brutalism check sur les 4 new recipes) +- ✅ Code-reviewer APPROVED +- ✅ DESIGN.md amendé (3 subsections additives) +- ✅ CHANGELOG.md entrée sprint 11 +- ✅ PR ouverte sur Gitea avec body