Compare commits

..

12 Commits

Author SHA1 Message Date
88dfa74663 Merge pull request 'feat(frontend): Spectrum UX port — Tabs + AlertBanner + BackLink + compact tables (sprint 11)' (#13) from sprint/11-spectrum-ux into main
Reviewed-on: #13
2026-06-22 08:25:38 +00:00
Knacky
2c7fcec7cd fix(frontend): add text- prefix to caption-bold and caption-md @apply (sprint 11 build fix)
caption-bold and caption-md are fontSize tokens — Tailwind generates
text-caption-bold / text-caption-md utilities, not bare-name utilities.
Missing text- prefix caused @apply resolution failure in vite build
(PostCSS step), while vitest passed because it does not resolve @apply.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:38:01 +02:00
Knacky
11ce3cfb86 fix(frontend): code-review polish — replaceState, arrow-key nav, TabId, comment
Fix 4: useHashTab navigate() uses history.replaceState instead of
window.location.hash assignment — no spurious history entries, no
anchor-jump scroll side-effect.
Fix 5: Tabs ArrowLeft/ArrowRight keyboard nav (WAI-ARIA tabs pattern).
Fix 6: TabId union type in EngagementDetailPage, cast from string for
type-safe switch on activeTab without breaking Tabs.onChange signature.
Fix 7: intentional comment on UsersAdminPage reset-password aerated row.
Tests: 236/236 (+2 arrow-key assertions in Tabs.test.tsx).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:20:51 +02:00
Knacky
790ced4204 fix(frontend): design-review polish — table-compact height, header type-scale, tabs a11y
Fix 1 (A): move row height constraint from min-height on tbody tr
(CSS no-op on table-row) to height: 32px on td (works on table-cell).
Fix 2 (A): table header text-[11px] -> text-[12px] to align with
documented caption scale.
Fix 3: add aria-controls + id to Tabs buttons; wrap active tab content
in role="tabpanel" + aria-labelledby in EngagementDetailPage.
Test: add aria-controls/id assertion to Tabs.test.tsx (234 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:18:09 +02:00
Knacky
1324a0c8ce docs(design): add tabs / inline banners / compact-table-density to DESIGN.md
Adds:
- Navigation > Sub-page tabs subsection (tab-underline recipes, count pill, ARIA)
- Data Tables: row min-height 44px -> 32px + WCAG SC 2.5.5 rationale
- Toast Notifications > Inline Banners subsection (4 variants table)
Also adds Sprint 11 entry to CHANGELOG.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:38 +02:00
Knacky
5d34a5e171 test(frontend): add specs for Tabs, AlertBanner, BackLink, useHashTab
4 new spec files adding 21 tests (7+6+4+4). Each component spec
includes brutalism invariant assertions (no rounded-md, no transition-*,
no shadow-*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:31 +02:00
Knacky
1819089925 feat(frontend): compact table density (32px row) global
Add table-compact class to all 4 list surfaces: EngagementsListPage,
TemplatesListPage, UsersAdminPage, SimulationList. Remove inline
px-xl py-md from th/td — recipe handles padding. Row border-b moved
to recipe. WCAG SC 2.5.5 tradeoff accepted (BAS operator tool).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:24 +02:00
Knacky
9a8c5f52ab feat(frontend): add BackLink helper + dedup 3 hand-rolled instances
BackLink: ArrowLeft (14px) + caption-md text-graphite hover:text-primary,
instant. Replaces inline Link patterns in EngagementDetailPage (already
committed), SimulationFormPage, and TemplateFormPage. Also migrates
SimulationFormPage Done/SOC banners to AlertBanner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:17 +02:00
Knacky
59eaa342a9 feat(frontend): add AlertBanner component + 4 semantic variants
border-l-4 semantic strip, bg-paper, Lucide icons at size=16.
ARIA role="alert" for error/warn, role="status" for success/info.
No shadow, no radius, no transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:10 +02:00
Knacky
004d075cad feat(frontend): refactor EngagementDetailPage to 3-tab layout
Tabs: Schedule (default) / Description / Simulations, wired via
useHashTab('schedule'). Count pill on Simulations tab. BackLink
replaces hand-rolled Link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:04 +02:00
Knacky
bca39dcca7 feat(frontend): add Tabs primitive + useHashTab hook
- useHashTab: pure-TS hook reading window.location.hash, falls back to
  defaultId, hashchange listener cleaned up on unmount
- Tabs: <Tabs items activeId onChange> with tab-underline / tab-underline-active
  recipes and count-pill (rounded-pill exception per DESIGN.md)
- index.css: tab-underline*, tab-count-pill*, alert-*, table-compact recipes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:10:59 +02:00
Knacky
89fb38b107 docs(sprint-11): plan spectrum UX port — 4 primitives + compact density global 2026-06-21 22:03:15 +02:00
19 changed files with 700 additions and 188 deletions

View File

@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [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. `<Tabs items activeId onChange>` 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. `<AlertBanner variant="error|warn|success|info" title? children>`. 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. `<BackLink to children>` 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 `<AlertBanner>`. Both back-link instances replaced with `<BackLink>`.
- `frontend/src/pages/TemplateFormPage.tsx` — Back-link replaced with `<BackLink>`.
- `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) ### Changed — Sprint 7 (Terminal-SOC design refresh)
**Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names) **Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names)

View File

@@ -216,11 +216,23 @@ Shadows (`box-shadow`) are not used anywhere.
- Text `{colors.slab-text}`, caption-md, `rounded-none` - Text `{colors.slab-text}`, caption-md, `rounded-none`
- Active state: 2px primary bottom border - 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 ### Data Tables
- `table-layout: fixed`, `word-break: break-word` - `table-layout: fixed`, `word-break: break-word`
- Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border - 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` - ID / date / technique columns: `font-mono`
- Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide - 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) - Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary)
- Background `{colors.paper}`, 1px `{colors.hairline}` border - Background `{colors.paper}`, 1px `{colors.hairline}` border
#### Inline Banners
For persistent in-page status messages (not ephemeral toasts). Use `<AlertBanner variant="...">` 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 ## Do's and Don'ts

View File

@@ -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<AlertVariant, { cls: string; Icon: typeof AlertCircle; role: string }> = {
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 (
<div role={role} className={cls}>
<Icon size={16} aria-hidden className="mt-[2px] shrink-0" />
<div className="flex flex-col gap-xxs text-[14px]">
{title ? <span className="font-medium text-ink">{title}</span> : null}
<span className="text-charcoal">{children}</span>
</div>
</div>
);
}

View File

@@ -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 (
<Link to={to} className="inline-flex items-center gap-xxs caption-md text-graphite hover:text-primary">
<ArrowLeft size={14} aria-hidden />
{children}
</Link>
);
}

View File

@@ -172,25 +172,25 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
</div> </div>
<div className="card-product overflow-hidden p-0"> <div className="card-product overflow-hidden p-0">
<table className="w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr>
<th className="px-xl py-md">Name</th> <th>Name</th>
<th className="px-xl py-md">MITRE</th> <th>MITRE</th>
<th className="px-xl py-md">Status</th> <th>Status</th>
<th className="px-xl py-md">Executed at</th> <th>Executed at</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.map((sim) => ( {data.map((sim) => (
<tr <tr
key={sim.id} key={sim.id}
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer" className="hover:bg-cloud cursor-pointer"
onClick={() => onClick={() =>
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`) navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`)
} }
> >
<td className="px-xl py-md"> <td>
<Link <Link
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`} to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
className="text-ink font-medium hover:underline" className="text-ink font-medium hover:underline"
@@ -199,7 +199,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
{sim.name} {sim.name}
</Link> </Link>
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px] font-mono"> <td className="text-charcoal font-mono">
{(() => { {(() => {
const items = [ const items = [
...(sim.tactics ?? []).map((t) => t.id), ...(sim.tactics ?? []).map((t) => t.id),
@@ -210,10 +210,10 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
return `${items[0]} +${items.length - 1}`; return `${items[0]} +${items.length - 1}`;
})()} })()}
</td> </td>
<td className="px-xl py-md"> <td>
<SimulationStatusBadge status={sim.status} /> <SimulationStatusBadge status={sim.status} />
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px] font-mono"> <td className="text-charcoal font-mono">
{formatDate(sim.executed_at)} {formatDate(sim.executed_at)}
</td> </td>
</tr> </tr>

View File

@@ -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<HTMLButtonElement>, 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 (
<div
role="tablist"
className="flex items-end border-b border-hairline gap-xs"
>
{items.map((item, index) => {
const isActive = item.id === activeId;
return (
<button
key={item.id}
id={`tab-${item.id}`}
role="tab"
aria-selected={isActive}
aria-controls={`tabpanel-${item.id}`}
type="button"
onClick={() => onChange(item.id)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={`tab-underline${isActive ? ' tab-underline-active' : ''} pb-xs`}
>
{item.label}
{item.count !== undefined ? (
<span
className={`ml-xxs tab-count-pill${isActive ? ' tab-count-pill-active' : ''}`}
>
{item.count}
</span>
) : null}
</button>
);
})}
</div>
);
}

View File

@@ -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<string>(() => 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];
}

View File

@@ -1,12 +1,19 @@
import { Link, useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useEngagement } from '@/hooks/useEngagements'; import { useEngagement } from '@/hooks/useEngagements';
import { useHashTab } from '@/hooks/useHashTab';
import { useEngagementSimulations } from '@/hooks/useSimulations';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { StatusBadge } from '@/components/StatusBadge'; import { StatusBadge } from '@/components/StatusBadge';
import { SimulationList } from '@/components/SimulationList'; import { SimulationList } from '@/components/SimulationList';
import { ExportEngagementButton } from '@/components/ExportEngagementButton'; 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 { export function EngagementDetailPage(): JSX.Element {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -14,6 +21,10 @@ export function EngagementDetailPage(): JSX.Element {
const { canEditEngagements } = useAuth(); const { canEditEngagements } = useAuth();
const detail = useEngagement(numericId); const detail = useEngagement(numericId);
const simsQuery = useEngagementSimulations(numericId);
const [activeTabRaw, setActiveTab] = useHashTab('schedule');
const activeTab = activeTabRaw as TabId;
if (detail.isLoading) return <LoadingState label="Loading engagement…" />; if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
if (detail.isError) { if (detail.isError) {
@@ -27,14 +38,19 @@ export function EngagementDetailPage(): JSX.Element {
if (!detail.data) return <ErrorState message="Engagement not found" />; if (!detail.data) return <ErrorState message="Engagement not found" />;
const eng = detail.data; 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 ( return (
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<Link to="/engagements" className="btn-text-link text-[14px]"> <BackLink to="/engagements">Back to engagements</BackLink>
Back to engagements
</Link>
<h1 className="text-[32px] font-medium leading-none">{eng.name}</h1> <h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<StatusBadge status={eng.status} /> <StatusBadge status={eng.status} />
@@ -53,32 +69,42 @@ export function EngagementDetailPage(): JSX.Element {
) : null} ) : null}
</header> </header>
<section className="grid grid-cols-1 md:grid-cols-2 gap-md"> <Tabs items={tabs} activeId={activeTab} onChange={setActiveTab} />
<div className="card-product">
<h2 className="text-[20px] font-medium mb-md">Schedule</h2>
<dl className="grid grid-cols-2 gap-md text-[14px]">
<dt className="text-graphite">Start date</dt>
<dd className="text-ink font-mono">{eng.start_date}</dd>
<dt className="text-graphite">End date</dt>
<dd className="text-ink font-mono">{eng.end_date ?? '—'}</dd>
<dt className="text-graphite">Status</dt>
<dd className="text-ink capitalize">{eng.status}</dd>
<dt className="text-graphite">Created at</dt>
<dd className="text-ink font-mono">{eng.created_at}</dd>
</dl>
</div>
<div className="card-product"> <div
<h2 className="text-[20px] font-medium mb-md">Description</h2> role="tabpanel"
<p className="text-[16px] text-charcoal whitespace-pre-line"> id={`tabpanel-${activeTab}`}
{eng.description?.trim() ? eng.description : 'No description provided.'} aria-labelledby={`tab-${activeTab}`}
</p> >
</div> {activeTab === 'schedule' && (
</section> <div className="card-product">
<h2 className="text-[20px] font-medium mb-md">Schedule</h2>
<dl className="grid grid-cols-2 gap-md text-[14px]">
<dt className="text-graphite">Start date</dt>
<dd className="text-ink font-mono">{eng.start_date}</dd>
<dt className="text-graphite">End date</dt>
<dd className="text-ink font-mono">{eng.end_date ?? '—'}</dd>
<dt className="text-graphite">Status</dt>
<dd className="text-ink capitalize">{eng.status}</dd>
<dt className="text-graphite">Created at</dt>
<dd className="text-ink font-mono">{eng.created_at}</dd>
</dl>
</div>
)}
<section> {activeTab === 'description' && (
<SimulationList engagementId={eng.id} /> <div className="card-product">
</section> <h2 className="text-[20px] font-medium mb-md">Description</h2>
<p className="text-[16px] text-charcoal whitespace-pre-line">
{eng.description?.trim() ? eng.description : 'No description provided.'}
</p>
</div>
)}
{activeTab === 'simulations' && (
<SimulationList engagementId={eng.id} />
)}
</div>
</div> </div>
); );
} }

View File

@@ -69,32 +69,32 @@ export function EngagementsListPage(): JSX.Element {
{!isLoading && !isError && data && data.length > 0 ? ( {!isLoading && !isError && data && data.length > 0 ? (
<div className="card-product overflow-hidden p-0"> <div className="card-product overflow-hidden p-0">
<table className="w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr>
<th className="px-xl py-md">Name</th> <th>Name</th>
<th className="px-xl py-md">Status</th> <th>Status</th>
<th className="px-xl py-md">Start</th> <th>Start</th>
<th className="px-xl py-md">End</th> <th>End</th>
<th className="px-xl py-md">Created by</th> <th>Created by</th>
<th className="px-xl py-md text-right">Actions</th> <th className="text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.map((eng) => ( {data.map((eng) => (
<tr key={eng.id} className="border-b border-hairline last:border-0"> <tr key={eng.id}>
<td className="px-xl py-md"> <td>
<Link to={`/engagements/${eng.id}`} className="text-ink font-medium hover:underline"> <Link to={`/engagements/${eng.id}`} className="text-ink font-medium hover:underline">
{eng.name} {eng.name}
</Link> </Link>
</td> </td>
<td className="px-xl py-md"> <td>
<StatusBadge status={eng.status} /> <StatusBadge status={eng.status} />
</td> </td>
<td className="px-xl py-md text-charcoal font-mono">{formatDate(eng.start_date)}</td> <td className="text-charcoal font-mono">{formatDate(eng.start_date)}</td>
<td className="px-xl py-md text-charcoal font-mono">{formatDate(eng.end_date)}</td> <td className="text-charcoal font-mono">{formatDate(eng.end_date)}</td>
<td className="px-xl py-md text-charcoal">{eng.created_by.username}</td> <td className="text-charcoal">{eng.created_by.username}</td>
<td className="px-xl py-md text-right"> <td className="text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<Link to={`/engagements/${eng.id}`} className="btn-text-link"> <Link to={`/engagements/${eng.id}`} className="btn-text-link">
View View

View File

@@ -22,6 +22,8 @@ import { MitreTechniquesField } from '@/components/MitreTechniquesField';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal'; import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal'; import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
import { C2TasksPanel } from '@/components/C2TasksPanel'; import { C2TasksPanel } from '@/components/C2TasksPanel';
import { AlertBanner } from '@/components/AlertBanner';
import { BackLink } from '@/components/BackLink';
interface RedteamFormState { interface RedteamFormState {
name: string; name: string;
@@ -238,9 +240,7 @@ export function SimulationFormPage(): JSX.Element {
return ( return (
<div className="flex flex-col gap-xl max-w-2xl"> <div className="flex flex-col gap-xl max-w-2xl">
<header> <header>
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <BackLink to={`/engagements/${engagementId}`}>Back to engagement</BackLink>
Back to engagement
</Link>
<h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1> <h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1>
</header> </header>
@@ -279,9 +279,7 @@ export function SimulationFormPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <BackLink to={`/engagements/${engagementId}`}>Back to engagement</BackLink>
Back to engagement
</Link>
<h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1> <h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? ( {status ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
@@ -298,22 +296,17 @@ export function SimulationFormPage(): JSX.Element {
{/* Done banner */} {/* Done banner */}
{isDone && ( {isDone && (
<div <AlertBanner variant="success">
role="status"
className="rounded-none px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal"
>
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes. This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
</div> </AlertBanner>
)} )}
{/* SOC banner */} {/* SOC banner */}
{socBlocked && ( {socBlocked && (
<div <div data-testid="soc-blocked-banner">
role="alert" <AlertBanner variant="warn">
data-testid="soc-blocked-banner" Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section.
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal" </AlertBanner>
>
Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section.
</div> </div>
)} )}

View File

@@ -9,6 +9,7 @@ import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { BackLink } from '@/components/BackLink';
import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag'; import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag';
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker'; import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
import { MitreMatrixModal } from '@/components/MitreMatrixModal'; import { MitreMatrixModal } from '@/components/MitreMatrixModal';
@@ -127,9 +128,7 @@ export function TemplateFormPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<Link to="/admin/templates" className="btn-text-link text-[14px]"> <BackLink to="/admin/templates">Back to templates</BackLink>
Back to templates
</Link>
<h1 className="text-[32px] font-medium leading-none"> <h1 className="text-[32px] font-medium leading-none">
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')} {isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}
</h1> </h1>

View File

@@ -69,20 +69,20 @@ export function TemplatesListPage(): JSX.Element {
{!isLoading && !isError && data && data.length > 0 ? ( {!isLoading && !isError && data && data.length > 0 ? (
<div className="card-product overflow-hidden p-0"> <div className="card-product overflow-hidden p-0">
<table className="w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr>
<th className="px-xl py-md">Name</th> <th>Name</th>
<th className="px-xl py-md">MITRE</th> <th>MITRE</th>
<th className="px-xl py-md">Created by</th> <th>Created by</th>
<th className="px-xl py-md">Updated</th> <th>Updated</th>
<th className="px-xl py-md text-right">Actions</th> <th className="text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.map((t) => ( {data.map((t) => (
<tr key={t.id} className="border-b border-hairline last:border-0"> <tr key={t.id}>
<td className="px-xl py-md"> <td>
<Link <Link
to={`/admin/templates/${t.id}/edit`} to={`/admin/templates/${t.id}/edit`}
className="text-ink font-medium hover:underline" className="text-ink font-medium hover:underline"
@@ -90,12 +90,12 @@ export function TemplatesListPage(): JSX.Element {
{t.name} {t.name}
</Link> </Link>
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px]"> <td className="text-charcoal">
{mitreCount(t) === 0 ? '—' : mitreCount(t)} {mitreCount(t) === 0 ? '—' : mitreCount(t)}
</td> </td>
<td className="px-xl py-md text-charcoal">{t.created_by.username}</td> <td className="text-charcoal">{t.created_by.username}</td>
<td className="px-xl py-md text-charcoal font-mono">{formatDate(t.updated_at)}</td> <td className="text-charcoal font-mono">{formatDate(t.updated_at)}</td>
<td className="px-xl py-md text-right"> <td className="text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link"> <Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link">
Edit Edit

View File

@@ -188,13 +188,13 @@ export function UsersAdminPage(): JSX.Element {
{!list.isLoading && !list.isError && list.data && list.data.length > 0 ? ( {!list.isLoading && !list.isError && list.data && list.data.length > 0 ? (
<div className="card-product overflow-hidden p-0"> <div className="card-product overflow-hidden p-0">
<table className="w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr>
<th className="px-xl py-md">Username</th> <th>Username</th>
<th className="px-xl py-md">Role</th> <th>Role</th>
<th className="px-xl py-md">Created</th> <th>Created</th>
<th className="px-xl py-md text-right">Actions</th> <th className="text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -204,8 +204,8 @@ export function UsersAdminPage(): JSX.Element {
// Fragment must carry the key — `<>` cannot, which broke // Fragment must carry the key — `<>` cannot, which broke
// per-row reconciliation (reset-password state leaked across rows). // per-row reconciliation (reset-password state leaked across rows).
<Fragment key={u.id}> <Fragment key={u.id}>
<tr className="border-b border-hairline last:border-0"> <tr>
<td className="px-xl py-md text-ink"> <td className="text-ink">
<span className="font-mono font-medium">{u.username}</span> <span className="font-mono font-medium">{u.username}</span>
{isSelf ? ( {isSelf ? (
<span className="ml-sm font-sans text-[12px] text-graphite"> <span className="ml-sm font-sans text-[12px] text-graphite">
@@ -213,7 +213,7 @@ export function UsersAdminPage(): JSX.Element {
</span> </span>
) : null} ) : null}
</td> </td>
<td className="px-xl py-md"> <td>
<Select <Select
value={u.role} value={u.role}
onChange={(e) => onRoleChange(u, e.target.value as Role)} onChange={(e) => onRoleChange(u, e.target.value as Role)}
@@ -222,8 +222,8 @@ export function UsersAdminPage(): JSX.Element {
disabled={patchMutation.isPending} disabled={patchMutation.isPending}
/> />
</td> </td>
<td className="px-xl py-md text-charcoal font-mono">{u.created_at}</td> <td className="text-charcoal font-mono">{u.created_at}</td>
<td className="px-xl py-md text-right"> <td className="text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<button <button
type="button" type="button"
@@ -248,6 +248,7 @@ export function UsersAdminPage(): JSX.Element {
</tr> </tr>
{resetOpen === u.id ? ( {resetOpen === u.id ? (
<tr className="border-b border-hairline last:border-0 bg-cloud"> <tr className="border-b border-hairline last:border-0 bg-cloud">
{/* Aerated row — inline form needs room; compact-density tradeoff intentional */}
<td colSpan={4} className="px-xl py-md"> <td colSpan={4} className="px-xl py-md">
<form <form
onSubmit={(e) => onResetPassword(u, e)} onSubmit={(e) => onResetPassword(u, e)}

View File

@@ -147,4 +147,48 @@
.tag-mitre { .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]; @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;
}
} }

View File

@@ -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(<AlertBanner variant="error">Error message</AlertBanner>);
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(<AlertBanner variant="warn">Warning message</AlertBanner>);
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(<AlertBanner variant="success">Done!</AlertBanner>);
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(<AlertBanner variant="info">FYI</AlertBanner>);
const el = container.firstChild as HTMLElement;
expect(el).toHaveAttribute('role', 'status');
expect(el).toHaveClass('alert-info');
});
it('renders optional title when provided', () => {
render(<AlertBanner variant="info" title="Heads up">Detail text</AlertBanner>);
expect(screen.getByText('Heads up')).toBeInTheDocument();
expect(screen.getByText('Detail text')).toBeInTheDocument();
});
it('has no rounded-md, transition-*, or shadow-* (brutalism)', () => {
const { container } = render(<AlertBanner variant="error">Test</AlertBanner>);
const el = container.firstChild as HTMLElement;
expect(el).not.toHaveClass('rounded-md');
expect(el.className).not.toMatch(/transition-/);
expect(el.className).not.toMatch(/shadow-/);
});
});

View File

@@ -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(
<MemoryRouter>
<BackLink to={to}>{label}</BackLink>
</MemoryRouter>,
);
}
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();
});
});

View File

@@ -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(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
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(<Tabs items={ITEMS} activeId="description" onChange={vi.fn()} />);
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(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
const activeBtn = screen.getByRole('tab', { name: /Schedule/i });
expect(activeBtn).toHaveClass('tab-underline-active');
});
it('renders count pill for the simulations tab', () => {
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
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(<Tabs items={ITEMS} activeId="simulations" onChange={vi.fn()} />);
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(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
fireEvent.click(screen.getByRole('tab', { name: /Description/i }));
expect(onChange).toHaveBeenCalledWith('description');
});
it('sets aria-controls and id on each tab button', () => {
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
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(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
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(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
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(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
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-/);
}
});
});

View File

@@ -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');
});
});

View File

@@ -1,115 +1,164 @@
# Sprint 10C2 TLS verify: redteam-friendly defaults + warning suppression # Sprint 11Spectrum UX port : 4 primitives + compact density global
**Base**: `origin/main` (PR #11 merged — sprint 8 + 9 are in). **Base** : `origin/main` (PR #12 mergée — sprint 8 + 9 + 10 en).
**Branch**: `sprint/10-c2-tls-default`. **Branch** : `sprint/11-spectrum-ux`.
**Scope**: tiny correctness sprint. No new feature. Flip a security-relevant default to match the actual operator usage of this tool. **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 : ## Décisions binding (lockées par user)
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)`.
The bug is the **default** at every layer is `True` : - **Q1 → B** : Tabs primitive **+ consumer EngagementDetailPage** (3 tabs : Schedule / Description / Simulations au lieu du stack vertical actuel).
- `backend/app/models/c2_config.py:22``default=True` - **Q2 → B** : **Compact density GLOBAL** — toutes les tables passent à 32px row, pas opt-in. User accepte le tradeoff WCAG (touch target) sur Engagements list.
- `backend/migrations/versions/0006_c2_layer.py:29``server_default=sa.true()` - **Q3 → A** : AlertBanner + refactor `SimulationFormPage` (Done banner + SOC-blocked banner).
- `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.
--- ---
## Decisions (locked) ## Livrables
1. **Flip default to `False` at every layer** — model, API fallback, adapter, factory, React state, delete reset. ### Livrable 1 — `Tabs` primitive + `useHashTab` hook + consumer
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)
**Files** : **Files** :
- `backend/app/models/c2_config.py` — line 22 : `default=True``default=False` - `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.
- `backend/app/api/c2.py` — line 87 : `data.get("verify_tls", True)``data.get("verify_tls", False)` - `frontend/src/components/Tabs.tsx` (NEW) — composant `<Tabs items={[{id, label, count?}]} activeId onChange />`. Underline variant.
- `backend/app/services/c2/factory.py` — line 9 : `verify_tls: bool = True``verify_tls: bool = False` - `frontend/src/styles/index.css` — nouvelles recipes :
- `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`: - `.tab-underline``text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink` (instantané, pas de transition)
```python - `.tab-underline-active``text-primary border-primary` (override)
if not verify_tls: - `.tab-count-pill``rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`
urllib3.disable_warnings(InsecureRequestWarning) - `.tab-count-pill-active``bg-primary-soft text-primary` (override)
``` - `frontend/src/pages/EngagementDetailPage.tsx` — refactor en 3 tabs :
- **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()`. - Tab 1 "Schedule" (id `schedule`, default) — dates + statut + bouton "Edit engagement"
- **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. - 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** : **Brutalism check** :
- `pytest` baseline 468/468 must hold (or grow ; never shrink). - Tab : `border-b-2` only, pas de bg, pas de rounded, pas de transition
- `ruff` + `mypy --strict` clean. - Pill : `rounded-pill` autorisé (exception status pill DESIGN.md L125)
- 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. - Hover : `hover:text-ink` instantané
- Don't restructure or refactor anything else. Minimum surface.
## Task B — Frontend (frontend-builder) ### Livrable 2 — `AlertBanner` component + 4 recipes
**Files** : **Files** :
- `frontend/src/components/C2ConfigCard.tsx` : - `frontend/src/components/AlertBanner.tsx` (NEW) — `<AlertBanner variant="error|warn|success|info" title? children />`. 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}`.
- Line 27 : `useState(true)` → `useState(false)` - `frontend/src/styles/index.css` — 4 nouvelles recipes :
- Line 74 (delete handler) : `setVerifyTls(true)` → `setVerifyTls(false)` - `.alert-error``bg-paper border border-hairline border-l-4 border-l-bloom-deep px-md py-sm flex items-start gap-sm`
- Under the checkbox JSX (around lines 169-182) : add a `<p>` with helper text : - `.alert-warn` — same + `border-l-warn`
```tsx - `.alert-success` — same + `border-l-success`
<p className="text-[12px] text-charcoal mt-xxs"> - `.alert-info` — same + `border-l-primary`
Leave unchecked for lab Mythic with self-signed certificates. - `frontend/src/pages/SimulationFormPage.tsx` — refactor 2 banners hand-rollés (~L300 Done banner + ~L310 SOC-blocked banner) → `<AlertBanner variant="success">` / `<AlertBanner variant="warn">`.
</p> - `DESIGN.md` — extend `### Toast Notifications` avec subsection `### Inline Banners` (additive, mêmes 4 variants).
```
(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".
**Constraints** : **Note** : `bloom-deep`, `warn`, `success`, `primary` sont les tokens existants — pas de nouveau token couleur.
- `vitest` baseline 212/212 must hold.
### Livrable 3 — `BackLink` component (dédup)
**Files** :
- `frontend/src/components/BackLink.tsx` (NEW) — `<BackLink to="/engagements">Back to engagements</BackLink>`. 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 `<table` dans `frontend/src/`)
- `DESIGN.md``### Data Tables` section : modifier la ligne "Row min-height 44px" → "Row min-height 32px (compact density, all surfaces)". Documenter le tradeoff WCAG (touch target dérogé en faveur de la densité opérateur — c'est un BAS tool, pas un site grand public).
**ATTENTION** : WCAG SC 2.5.5 (Target Size: Minimum) recommande 44×44. Tu déroges. Garder les boutons d'action des rows ≥ 24px d'icône avec padding pour préserver le hit area minimal. Tester en clavier + lecteur d'écran nicht regressé.
### Livrable 5 — DESIGN.md amendments + CHANGELOG
**Files** :
- `DESIGN.md` :
- `### Navigation` : nouvelle subsection "Sub-page tabs" (livrable 1)
- `### Toast Notifications` : nouvelle subsection "Inline Banners" (livrable 2)
- `### Data Tables` : ligne row min-height passe à 32px + paragraphe rationale (livrable 4)
- `CHANGELOG.md` : entrée Sprint 11 listant chaque primitive + ses tokens DESIGN.md.
---
## Sequencing
1. **frontend-builder** : livrables 1 + 2 + 3 + 4 + 5 en cascade. Une seule passe. Commits atomiques :
```
feat(frontend): add Tabs primitive + useHashTab hook
feat(frontend): refactor EngagementDetailPage to 3-tab layout
feat(frontend): add AlertBanner component + 4 semantic variants
refactor(frontend): migrate SimulationFormPage banners to AlertBanner
feat(frontend): add BackLink helper + dedup 3 hand-rolled instances
feat(frontend): compact table density (32px row) global
docs(design): add tabs / inline banners / compact-table-density to DESIGN.md
```
(collapse OK si plus efficace ; ~5-7 commits attendus)
2. **design-reviewer** : visual + token compliance check. Focus :
- Underline tabs : pas de transition, pas de bg fill, border-b instantané
- Count pill : `rounded-pill` est dans l'exception DESIGN.md, OK
- AlertBanner : `border-l-4` semantic OK, pas de shadow
- BackLink : caption + icon align baseline
- 32px tables : densité lisible ? Hit area boutons d'action OK ?
3. **code-reviewer** : sprint diff. Focus :
- Aucune transition / shadow / rounded leak
- useHashTab : `hashchange` listener cleanup dans useEffect return
- Tabs : prop API stable, fallback default ID si hash invalide
- AlertBanner : ARIA `role="alert"` ou `role="status"` selon variant
- BackLink : `to` est `string` ou objet React Router ? Choisir un seul
4. **spec-reviewer** : `SPEC.md` n'a pas vocation à mentionner ces primitives, **skip ce sprint** sauf si tu détectes une feature côté backend qui aurait dérivé.
## Contraintes tests
- `vitest --run` baseline **212/212** doit tenir.
- **Ajouter 4 specs** (1 par new component) :
- `Tabs.test.tsx` — active state, hash sync, count pill render
- `AlertBanner.test.tsx` — 4 variants rendent les bonnes classes
- `BackLink.test.tsx` — render + click navigation
- `useHashTab.test.tsx` — hash read/write
- `tsc --noEmit` + `eslint --max-warnings=0` clean. - `tsc --noEmit` + `eslint --max-warnings=0` clean.
- No type change to `C2ConfigInput` (the field is already required `boolean`). - 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-/)`.
- Visual: same row, just one extra `<p>` hint below the checkbox-label row. Same brutalist treatment, no transition.
## Task C — Sequencing ## Out of scope (explicite)
Both tasks have **zero shared files**. Dispatch backend-builder + frontend-builder **in parallel**. No ordering constraint. - ❌ Side nav / drawer / breadcrumb component — top nav + BackLink suffit
- ❌ Global engagement selector top-bar (Spectrum pattern rejeté)
After both report green : - ❌ Editor dynamic-column grid — pas d'éditeur dans Mimic
- **code-reviewer** : sprint diff scan (focus : migration reversibility, urllib3 gating, no leftover hardcoded `True`). - ❌ Dark mode rework
- **design-reviewer** : helper-text placement, token compliance, focus ring still works, no regression on the card. - ❌ Toast queue system (les toasts actuels via `useToast` restent)
- **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). - ❌ PDF/document deliverable
- ❌ Color palette changes
- ❌ Tokens spacing/typo dans tailwind.config
## Definition of Done ## Definition of Done
- Test connection against self-signed Mythic from a freshly-created C2 config works **without unchecking anything**. - ✅ 5 primitives livrées + EngagementDetailPage refactorée en tabs + SimulationFormPage banners migrés + BackLink dédup × 3
- Existing rows are untouched (operators who saved verify_tls=true keep it until they re-save). - ✅ vitest **212+/212+** (4 new specs ajoutées), tsc + lint clean
- `pytest` 468/468 → 468+ (no shrink), `vitest` 212/212. -pytest **469/469** intact (zéro backend touché)
- `ruff` + `mypy --strict` + `tsc --noEmit` + `eslint` clean. - ✅ Toutes tables passent à 32px row density (validation visuelle)
- Migration 0008 round-trip OK. - ✅ Design-reviewer APPROVED (brutalism check sur les 4 new recipes)
- No `urllib3.InsecureRequestWarning` on stderr when `verify_tls=False`. - ✅ Code-reviewer APPROVED
- Code-reviewer + design-reviewer + spec-reviewer APPROVED. - ✅ DESIGN.md amendé (3 subsections additives)
- PR opened on Gitea ; tasks/pr-body-sprint-10.md drafted by team-lead. - ✅ CHANGELOG.md entrée sprint 11
- ✅ PR ouverte sur Gitea avec body
## 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.