Compare commits

20 Commits

Author SHA1 Message Date
Knacky
10d4293f44 docs(sprint-12): plan i18n FR (react-i18next) + EngagementDetail tab merge 2026-06-21 23:12:57 +02: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
65857134ef Merge pull request 'fix(c2): redteam-friendly verify_tls default (sprint 10)' (#12) from sprint/10-c2-tls-default into main
Reviewed-on: #12
2026-06-21 19:44:32 +00:00
Knacky
208c6e6428 fix(c2): append trailing slash to mythic /graphql URL to avoid 301 redirect
Nginx fronting Mythic 3.x redirects /graphql → /graphql/ (HTTP 301).
The sprint 8 SSRF defense (allow_redirects=False) is correct and intentional;
the fix is the URL, not the redirect policy. Upstream reference:
mythic_utilities.get_http_transport hardcodes /graphql/ with trailing slash.

Updated _GQL_URL in all three adapter test files to match.
Added TestMythicAdapterUrlConstruction regression test to prevent silent
regression on future refactors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:25:06 +02:00
Knacky
91533c214c docs(spec): align c2_config.verify_tls default with sprint 10 (false) 2026-06-21 21:06:10 +02:00
Knacky
0fab40b486 feat(backend): default verify_tls to false for lab/redteam Mythic
flip default to False at model, API fallback, adapter, and factory layers.
add migration 0008 flipping c2_config.verify_tls server_default to false.
suppress urllib3 InsecureRequestWarning when verify_tls is off.
adapt c2 tests to new verify_tls default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:03:05 +02:00
Knacky
ee5fda6059 fix(frontend): make verify_tls helper text spell out the MITM tradeoff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:01:49 +02:00
Knacky
774ae05748 fix(frontend): default verify_tls checkbox to unchecked + add helper text
Flip useState(true) → useState(false) and delete-reset from true → false
so a freshly-created C2 config does not block self-signed Mythic instances.
Add hint paragraph below the checkbox matching FormField hint style
(text-[12px] text-graphite): "Leave unchecked for lab Mythic with
self-signed certificates."

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:57:41 +02:00
Knacky
c31f985e7a docs(sprint-10): plan flip verify_tls default to false (redteam-friendly) 2026-06-21 20:55:32 +02:00
24d4a3b146 Merge pull request 'feat(c2): integrate Mythic command and control (sprint 8)' (#11) from sprint/8-c2 into main
Reviewed-on: #11
2026-06-11 10:29:19 +00:00
30 changed files with 885 additions and 179 deletions

View File

@@ -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. `<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)
**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`
- 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 `<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

View File

@@ -82,7 +82,7 @@ Couche d'intégration C2 permettant d'exécuter les commandes d'une simulation
| `engagement_id` | int FK `engagements.id` ON DELETE CASCADE, **UNIQUE** | |
| `url` | text | endpoint Mythic, ex. `https://lab.internal:7443` |
| `api_token_encrypted` | text | Fernet ciphertext, jamais en clair |
| `verify_tls` | bool, défaut `true` | `false` autorisé pour labs auto-signés |
| `verify_tls` | bool, défaut `false` | Désactivé par défaut — Mimic cible des labs Mythic avec certs auto-signés. Cocher pour forcer la vérification TLS en prod. |
| `created_at`, `updated_at` | datetime | |
`c2_task` (lien simulation ↔ tâche Mythic) :

View File

@@ -84,7 +84,7 @@ def upsert_c2_config(eid: int):
if not parsed.hostname:
return jsonify({"error": "url must contain a hostname"}), 400
verify_tls = data.get("verify_tls", True)
verify_tls = data.get("verify_tls", False)
if not isinstance(verify_tls, bool):
return jsonify({"error": "verify_tls must be a boolean"}), 400

View File

@@ -19,7 +19,7 @@ class C2Config(db.Model): # type: ignore[name-defined]
)
url = db.Column(db.Text, nullable=False)
api_token_encrypted = db.Column(db.Text, nullable=False)
verify_tls = db.Column(db.Boolean, nullable=False, default=True)
verify_tls = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)

View File

@@ -6,7 +6,7 @@ import os
from backend.app.services.c2.adapter import C2Adapter
def get_adapter(url: str, api_token: str, verify_tls: bool = True) -> C2Adapter:
def get_adapter(url: str, api_token: str, verify_tls: bool = False) -> C2Adapter:
"""Return the correct C2Adapter based on MIMIC_C2_ADAPTER (default: mythic)."""
adapter_name = os.environ.get("MIMIC_C2_ADAPTER", "mythic").lower()

View File

@@ -1,7 +1,8 @@
# Contract pinned from MythicMeta/Mythic_Scripting master @ 2026-06-10 (raw.githubusercontent.com/MythicMeta/Mythic_Scripting/master/mythic/mythic.py)
# Sprint 10 correction: /graphql/ MUST have trailing slash (matches mythic_utilities.get_http_transport).
"""Mythic 3.x C2 adapter.
Transport: POST https://<host>:7443/graphql
Transport: POST https://<host>:7443/graphql/
Header: apitoken: <token>
Backend: Hasura-proxied Postgres behind nginx.
@@ -15,6 +16,8 @@ from __future__ import annotations
from datetime import datetime
import requests
import urllib3
from urllib3.exceptions import InsecureRequestWarning
from backend.app.services.c2.adapter import (
C2Adapter,
@@ -113,10 +116,14 @@ query GetTaskOutput($display_id: Int!) {
class MythicAdapter(C2Adapter):
"""Real Mythic 3.x adapter using GraphQL over HTTP."""
def __init__(self, url: str, api_token: str, verify_tls: bool = True) -> None:
self._url = url.rstrip("/") + "/graphql"
def __init__(self, url: str, api_token: str, verify_tls: bool = False) -> None:
self._url = url.rstrip("/") + "/graphql/"
self._token = api_token
self._verify = verify_tls
if not verify_tls:
# Lab/red-team Mythic instances commonly use self-signed certs.
# Suppress urllib3 warnings once per process when verification is off.
urllib3.disable_warnings(InsecureRequestWarning)
def _headers(self) -> dict[str, str]:
return {

View File

@@ -0,0 +1,33 @@
"""flip c2_config.verify_tls server_default to false
Revision ID: 0008_c2_verify_tls_default_false
Revises: 0007
Create Date: 2026-06-21 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "0008_c2_verify_tls_default_false"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("c2_config") as batch_op:
batch_op.alter_column(
"verify_tls",
existing_type=sa.Boolean(),
existing_nullable=False,
server_default=sa.false(),
)
def downgrade() -> None:
with op.batch_alter_table("c2_config") as batch_op:
batch_op.alter_column(
"verify_tls",
existing_type=sa.Boolean(),
existing_nullable=False,
server_default=sa.true(),
)

View File

@@ -9,7 +9,7 @@ from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_GQL_URL = _BASE_URL + "/graphql/"
_TOKEN = "fake-api-token"
@@ -143,9 +143,19 @@ class TestMythicAdapterNoRedirects:
"""Adapter must not follow HTTP redirects (allow_redirects=False)."""
with rm_module.Mocker() as m:
# Simulate a redirect response; requests-mock won't auto-follow it.
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/graphql"})
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/graphql/"})
# With allow_redirects=False the 301 is treated as a non-2xx → raise_for_status raises.
with pytest.raises(C2Error):
adapter.list_callbacks()
# Exactly one request was made — no follow-up to Location.
assert len(m.request_history) == 1
class TestMythicAdapterUrlConstruction:
def test_mythic_adapter_url_has_trailing_slash(self):
a1 = MythicAdapter(url="https://x:7443", api_token="t")
assert a1._url == "https://x:7443/graphql/"
a2 = MythicAdapter(url="https://x:7443/", api_token="t")
assert a2._url == "https://x:7443/graphql/"
a3 = MythicAdapter(url="https://x:7443///", api_token="t")
assert a3._url == "https://x:7443/graphql/"

View File

@@ -9,7 +9,7 @@ from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_GQL_URL = _BASE_URL + "/graphql/"
_TOKEN = "fake-api-token"

View File

@@ -9,7 +9,7 @@ from backend.app.services.c2.adapter import C2Error, C2HistoricalTask
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_GQL_URL = _BASE_URL + "/graphql/"
_TOKEN = "fake-api-token"

View File

@@ -206,13 +206,13 @@ def test_get_config_returns_has_token_not_cleartext(
assert "s3cr3t" not in str(body)
def test_get_config_verify_tls_default_true(
def test_get_config_verify_tls_default_false(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
_put_config(client, admin_token, eng["id"], verify_tls=False)
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.get_json()["verify_tls"] is True
assert resp.get_json()["verify_tls"] is False
# ---------------------------------------------------------------------------

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

@@ -24,7 +24,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [verifyTls, setVerifyTls] = useState(true);
const [verifyTls, setVerifyTls] = useState(false);
const [replaceToken, setReplaceToken] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
@@ -71,7 +71,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
push('C2 configuration removed', 'success');
setUrl('');
setToken('');
setVerifyTls(true);
setVerifyTls(false);
setReplaceToken(false);
} catch (err) {
push(extractApiError(err, 'Could not remove C2 configuration'), 'error');
@@ -166,19 +166,25 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
)}
</FormField>
<div className="flex items-center gap-sm">
<input
id="c2-verify-tls"
data-testid="c2-verify-tls"
type="checkbox"
checked={verifyTls}
onChange={(e) => setVerifyTls(e.target.checked)}
disabled={disabled}
className="h-4 w-4 accent-primary"
/>
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
Verify TLS certificate
</label>
<div className="flex flex-col gap-xs">
<div className="flex items-center gap-sm">
<input
id="c2-verify-tls"
data-testid="c2-verify-tls"
type="checkbox"
checked={verifyTls}
onChange={(e) => setVerifyTls(e.target.checked)}
disabled={disabled}
className="h-4 w-4 accent-primary"
/>
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
Verify TLS certificate
</label>
</div>
<p className="text-[12px] text-graphite">
Uncheck only for lab Mythic with self-signed certificates. Disabling
verification exposes the API token to MITM.
</p>
</div>
<div className="flex items-center gap-md flex-wrap">

View File

@@ -172,25 +172,25 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
</div>
<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">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
<th className="px-xl py-md">Name</th>
<th className="px-xl py-md">MITRE</th>
<th className="px-xl py-md">Status</th>
<th className="px-xl py-md">Executed at</th>
<tr>
<th>Name</th>
<th>MITRE</th>
<th>Status</th>
<th>Executed at</th>
</tr>
</thead>
<tbody>
{data.map((sim) => (
<tr
key={sim.id}
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
className="hover:bg-cloud cursor-pointer"
onClick={() =>
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`)
}
>
<td className="px-xl py-md">
<td>
<Link
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
className="text-ink font-medium hover:underline"
@@ -199,7 +199,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
{sim.name}
</Link>
</td>
<td className="px-xl py-md text-charcoal text-[14px] font-mono">
<td className="text-charcoal font-mono">
{(() => {
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}`;
})()}
</td>
<td className="px-xl py-md">
<td>
<SimulationStatusBadge status={sim.status} />
</td>
<td className="px-xl py-md text-charcoal text-[14px] font-mono">
<td className="text-charcoal font-mono">
{formatDate(sim.executed_at)}
</td>
</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 { 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 <LoadingState label="Loading engagement…" />;
if (detail.isError) {
@@ -27,14 +38,19 @@ export function EngagementDetailPage(): JSX.Element {
if (!detail.data) return <ErrorState message="Engagement not found" />;
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 (
<div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm">
<Link to="/engagements" className="btn-text-link text-[14px]">
Back to engagements
</Link>
<BackLink to="/engagements">Back to engagements</BackLink>
<h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
<div className="flex items-center gap-md">
<StatusBadge status={eng.status} />
@@ -53,32 +69,42 @@ export function EngagementDetailPage(): JSX.Element {
) : null}
</header>
<section className="grid grid-cols-1 md:grid-cols-2 gap-md">
<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>
<Tabs items={tabs} activeId={activeTab} onChange={setActiveTab} />
<div className="card-product">
<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>
</section>
<div
role="tabpanel"
id={`tabpanel-${activeTab}`}
aria-labelledby={`tab-${activeTab}`}
>
{activeTab === 'schedule' && (
<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>
<SimulationList engagementId={eng.id} />
</section>
{activeTab === 'description' && (
<div className="card-product">
<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>
);
}

View File

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

View File

@@ -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 (
<div className="flex flex-col gap-xl max-w-2xl">
<header>
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement
</Link>
<BackLink to={`/engagements/${engagementId}`}>Back to engagement</BackLink>
<h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1>
</header>
@@ -279,9 +279,7 @@ export function SimulationFormPage(): JSX.Element {
<div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm">
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement
</Link>
<BackLink to={`/engagements/${engagementId}`}>Back to engagement</BackLink>
<h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? (
<div className="flex items-center gap-md">
@@ -298,22 +296,17 @@ export function SimulationFormPage(): JSX.Element {
{/* Done banner */}
{isDone && (
<div
role="status"
className="rounded-none px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal"
>
<AlertBanner variant="success">
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
</div>
</AlertBanner>
)}
{/* SOC banner */}
{socBlocked && (
<div
role="alert"
data-testid="soc-blocked-banner"
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
>
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 data-testid="soc-blocked-banner">
<AlertBanner variant="warn">
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.
</AlertBanner>
</div>
)}

View File

@@ -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 {
<div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm">
<Link to="/admin/templates" className="btn-text-link text-[14px]">
Back to templates
</Link>
<BackLink to="/admin/templates">Back to templates</BackLink>
<h1 className="text-[32px] font-medium leading-none">
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}
</h1>

View File

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

View File

@@ -188,13 +188,13 @@ export function UsersAdminPage(): JSX.Element {
{!list.isLoading && !list.isError && list.data && list.data.length > 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">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
<th className="px-xl py-md">Username</th>
<th className="px-xl py-md">Role</th>
<th className="px-xl py-md">Created</th>
<th className="px-xl py-md text-right">Actions</th>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
@@ -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).
<Fragment key={u.id}>
<tr className="border-b border-hairline last:border-0">
<td className="px-xl py-md text-ink">
<tr>
<td className="text-ink">
<span className="font-mono font-medium">{u.username}</span>
{isSelf ? (
<span className="ml-sm font-sans text-[12px] text-graphite">
@@ -213,7 +213,7 @@ export function UsersAdminPage(): JSX.Element {
</span>
) : null}
</td>
<td className="px-xl py-md">
<td>
<Select
value={u.role}
onChange={(e) => onRoleChange(u, e.target.value as Role)}
@@ -222,8 +222,8 @@ export function UsersAdminPage(): JSX.Element {
disabled={patchMutation.isPending}
/>
</td>
<td className="px-xl py-md text-charcoal font-mono">{u.created_at}</td>
<td className="px-xl py-md text-right">
<td className="text-charcoal font-mono">{u.created_at}</td>
<td className="text-right">
<div className="inline-flex gap-sm">
<button
type="button"
@@ -248,6 +248,7 @@ export function UsersAdminPage(): JSX.Element {
</tr>
{resetOpen === u.id ? (
<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">
<form
onSubmit={(e) => onResetPassword(u, e)}

View File

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

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,73 +1,260 @@
# Sprint 9UI: engagement 2-col + global contrast pass
# Sprint 12i18n FR complet + EngagementDetailPage tab merge
**Base**: `sprint/8-c2` (sprint 8 not yet merged on origin/main, but its `C2ConfigCard` is the right pane).
**Scope**: frontend-only. No backend, no schema. No new features.
**Base** : `sprint/11-spectrum-ux` (PR #13 ouverte, pas encore mergée). Sprint 12 enchaîne directement.
**Worktree** : `.claude/worktrees/sprint-12-i18n-fr/` (dédié — conforme à la mémoire worktree-per-sprint).
**Branch** : `sprint/12-i18n-fr`.
**Scope** : frontend uniquement. Backend intouché.
---
## Decisions (locked)
## Décisions binding (lockées par user)
1. **Engagement page** : passer en **2 colonnes** sur desktop (`lg:grid-cols-2`), `[engagement form | C2ConfigCard]`. Mobile/tablet : stack vertical (comportement actuel).
2. **Contraste global** : le problème est que `canvas` (page bg) et `paper` (card bg) sont **tous deux `#ffffff`** en light mode. Les cartes ne ressortent que par leur hairline 1px → fatigue oculaire confirmée par l'utilisateur.
3. **Fix retenu** : **tinter le canvas light** d'un neutre froid très pâle. `paper` reste blanc pur. Les cartes "lèvent" naturellement sans casser le brutalisme.
- Proposition : `canvas` light `#f3f5f8` (gris-bleu très pâle, cohérent avec l'electric blue), `paper` light `#ffffff`.
- Dark mode **inchangé** (`canvas #111827` / `paper #1f2937` déjà différenciés).
4. **Pas de shadow**, pas de radius. La brutalité reste intacte — seul le contraste de surface change.
5. **Hairline** : à vérifier sur le nouveau canvas. Si nécessaire, passer `hairline` light de la valeur actuelle à un poil plus sombre pour préserver la lisibilité du bord sur tinted canvas. Mais éviter si la lecture est déjà bonne.
1. **Approche i18n** : `react-i18next` complet (deps `react-i18next` + `i18next`, JSON FR, hook `useTranslation`).
2. **Acronymes** : RT, SOC, MITRE, ATT&CK, C2, BAS, TLS, MITM, JWT, IP, URL, API, ID — **gardés en anglais** dans les valeurs FR.
3. **Status enum** : valeurs DB (`pending`, `in_progress`, `done`, `planned`, `active`, `closed`) gardées EN — traduction au rendering layer via `i18n/status.ts`.
4. **Format de date** : `fr-FR` locale (`jj/mm/aaaa`, ex. `20/06/2026`).
5. **Ton toasts/alerts** : **bref** (ex. `"Engagement créé"`, pas `"Engagement enregistré avec succès"`).
6. **Schedule → Description tab** : merger Schedule INTO Description. Résultat : 2 tabs (Description + Simulations).
## Contraintes constantes
- Primary `#024ad8` Electric Blue intouchée.
- Brutalisme intact : `rounded-none` partout sauf status pills + tab count pills + avatars, zéro `transition-*`, zéro `shadow-*`.
- `tailwind.config.*` inchangé sauf cas exceptionnel justifié.
- DESIGN.md amendments additives uniquement.
- Mono uniquement pour data (IDs MITRE, dates ISO côté backend, commands, etc.).
---
## Task AEngagementFormPage 2-col
## Task 1Merge Schedule into Description (mécanique, commit 1)
**File** : `frontend/src/pages/EngagementFormPage.tsx`
**File** : `frontend/src/pages/EngagementDetailPage.tsx`
- Remplacer le wrapper `<div className="flex flex-col gap-xl max-w-2xl">` par un container plus large + grid 2-col responsive.
- Header reste en haut, full width.
- Body : `grid grid-cols-1 lg:grid-cols-2 gap-xl` avec :
- Col gauche : `<form>` engagement (déjà en `card-product`).
- Col droite : `<C2ConfigCard>` (seulement quand `editing && canEditEngagements`).
- Si pas en edit (création) : col droite vide → garder la grid mais que la col gauche se déploie via `lg:col-span-2` (pour pas avoir un vide à droite). Acceptable alternative : `flex` + `max-w-2xl` quand non-editing.
- Pas de modif sur la logique de form, validation, mutations.
**Current** (3 tabs Schedule / Description / Simulations) :
```tsx
type TabId = 'schedule' | 'description' | 'simulations';
const items: TabItem<TabId>[] = [
{ id: 'schedule', label: 'Schedule' },
{ id: 'description', label: 'Description' },
{ id: 'simulations', label: 'Simulations', count },
];
```
## Task B — Contrast pass (tokens)
**Target** (2 tabs) :
```tsx
type TabId = 'description' | 'simulations';
const items: TabItem<TabId>[] = [
{ id: 'description', label: 'Description' },
{ id: 'simulations', label: 'Simulations', count },
];
```
**Files** :
- `DESIGN.md` § Surface : mettre à jour `canvas` light = `#f3f5f8`, conserver `paper` light = `#ffffff`. Documenter dans la même section que "canvas tints lift paper cards in light mode without violating brutalism".
- Token source de vérité (Tailwind config ou CSS vars). Localiser et appliquer la même MAJ. Probablement `frontend/tailwind.config.js` ou un `frontend/src/styles/tokens.css` / `index.css`.
- Vérifier qu'aucun composant ne hardcode `#ffffff` pour la page bg (devrait utiliser `bg-canvas`).
- Tests CSS smoke : `bg-canvas` continue de matcher, dark mode inchangé.
**Migration du contenu Schedule** : déplacer dans le panel Description, en HEADER (au-dessus du paragraphe description) :
```tsx
<header className="flex items-start justify-between gap-md mb-md">
<dl className="grid grid-cols-2 gap-x-lg gap-y-xxs text-caption-md">
<dt className="text-graphite">Start date</dt> <dd>{formatDate(eng.start_date)}</dd>
<dt className="text-graphite">End date</dt> <dd>{eng.end_date ? formatDate(eng.end_date) : '—'}</dd>
<dt className="text-graphite">Status</dt> <dd><StatusBadge status={eng.status} /></dd>
<dt className="text-graphite">Created at</dt> <dd>{formatDate(eng.created_at)}</dd>
</dl>
<Link to={`/engagements/${id}/edit`} className="btn-outline">Edit</Link>
</header>
<hr className="my-md border-hairline" />
<h2 className="text-display-sm mb-sm">Description</h2>
{eng.description || <p className="text-graphite">No description provided.</p>}
```
## Task C — Visual regression check
- Default `useHashTab('description')` au lieu de `'schedule'`.
- Header de la page (BackLink + name + créé par + Edit + Export) reste **au-dessus des tabs**, intouché.
- Mettre à jour `EngagementDetailPage.test.tsx` si une assertion vise le tab "Schedule".
- `pnpm vitest run` clean.
- `pnpm tsc --noEmit` clean.
- `pnpm lint` clean.
- Screenshots avant/après (au moins) :
- EngagementsListPage (cards-on-canvas)
- EngagementDetailPage
- EngagementFormPage (edit, avec C2ConfigCard à droite)
- SimulationFormPage (déjà 2-col sprint 7, vérifier que le tinted canvas n'écrase pas)
- LoginPage
- Dark mode : passe rapide pour confirmer aucune régression.
**Note** : les strings ci-dessus seront ensuite traduites dans le pass i18n. Pas la peine de les écrire en FR dans commit 1.
---
## Sequencing
## Task 2 — Full FR translation via react-i18next
1. **frontend-builder** : Task A + B + C. Une seule passe, commits atomiques.
2. **design-reviewer** : revue visuelle après merge des commits builder. Focus :
- Lecture confortable cards-on-tinted-canvas.
- Hairline encore visible.
- Dark mode inchangé.
- Pas de régression sur components qui pourraient ré-utiliser `bg-canvas` pour autre chose (dropdowns, modals).
### Structure cible
```
frontend/src/i18n/
├── index.ts # init i18next, single FR locale
├── fr.json # toutes les keys FR
└── status.ts # engagementStatusLabel + simulationStatusLabel
```
### Init (`frontend/src/i18n/index.ts`)
```ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import fr from './fr.json';
i18n.use(initReactI18next).init({
resources: { fr: { translation: fr } },
lng: 'fr',
fallbackLng: 'fr',
interpolation: { escapeValue: false },
returnEmptyString: false,
});
export default i18n;
```
Mount dans `frontend/src/main.tsx` : `import './i18n'` avant `<App />`.
### Pattern usage
```tsx
const { t } = useTranslation();
<button>{t('common.save')}</button>
<h1>{t('engagement.list.title')}</h1>
<p>{t('engagement.delete.confirm', { name: eng.name })}</p>
```
### Convention de keys — **nested by feature**
```
common.* save, cancel, delete, edit, new, loading, retry, back, dismiss
nav.* engagements, templates, users, signOut, brand
auth.* login.title, login.subtitle, login.signIn, login.signingIn, login.invalid, forbidden
engagement.list.* title, subtitle, new, empty.title, empty.desc, col.name, col.status, col.start, col.end, col.createdBy, col.actions, view, edit, delete, deleteConfirm, toast.deleted, error.delete
engagement.detail.* tabs.description, tabs.simulations, schedule.startDate, schedule.endDate, schedule.status, schedule.createdAt, edit, createdBy, noDescription, loading, errorLoad, notFound
engagement.form.* title.new, title.edit, subtitle.new, subtitle.edit, field.name, field.description, field.startDate, field.endDate, field.endDateHint, field.status, validation.nameRequired, validation.startDateRequired, validation.endDateAfterStart, btn.create, btn.save, btn.saving, btn.cancel, toast.created, toast.updated, error.save, error.load
simulation.form.* title.new, field.name, field.mitre, field.description, field.commands, field.commandsHint, field.prerequisites, field.executedAt, field.executionResult, btn.create, btn.creating, btn.save, btn.saving, btn.saveSoc, btn.markReview, btn.close, btn.reopen, btn.delete, btn.executeC2, btn.importC2History, btn.cancel, deleteConfirm.title, deleteConfirm.desc, deleteConfirm.confirm, deleteConfirm.cancel, banner.done, banner.socNotReady, header.redTeam, header.soc, field.logSource, field.logs, field.socComment, field.incidentNumber, validation.nameRequired, toast.created, toast.updated, toast.deleted, toast.markedReview, toast.closed, toast.reopened, toast.socUpdated, error.create, error.update, error.delete, error.soc, error.transition, loading, errorLoad
template.list.* title, subtitle, new, empty.title, empty.desc, col.name, col.description, col.commands, col.createdAt, edit, delete
template.form.* title.new, title.edit, field.name, field.description, field.descriptionPlaceholder, field.commands, field.commandsHint, field.commandsPlaceholder, field.prerequisites, field.prerequisitesPlaceholder, field.mitre, field.mitreSearch, field.mitreOpenMatrix, field.mitreEmpty, btn.save, btn.saving, btn.delete, btn.cancel, validation.nameRequired, toast.created, toast.saved, toast.deleted, deleteConfirm.title, deleteConfirm.desc, deleteConfirm.confirm, deleteConfirm.cancel, loading, errorLoad, errorSave, errorDelete
user.admin.* title, subtitle, new, col.username, col.role, col.createdAt, col.actions, role.admin, role.redteam, role.soc, field.username, field.password, btn.create, btn.creating, btn.resetPassword, btn.delete, btn.cancel, ...
c2.config.* title, disabled, field.url, field.urlHint, field.token, field.tokenHint, field.verifyTls, verifyTlsHint, btn.save, btn.saving, btn.test, btn.testing, btn.delete, toast.saved, toast.tested, toast.deleted, error.save, error.test, error.delete
c2.tasks.* title, empty, col.task, col.command, col.source, col.status, col.completedAt, refreshing
c2.modal.execute.* title, callback, btn.launch, btn.launching, btn.cancel, commandCount_one, commandCount_other, validation.callbackRequired, validation.commandsRequired, toast.launched, error.launch
c2.modal.import.* title, total, page, of, prev, next, btn.import, btn.importing, btn.cancel, selected, empty, toast.imported, toast.partial, error.import
c2.modal.picker.* title, empty, hostnameColon, lastCheckinColon
mitre.matrix.* title, filter, loading, error, applyItem_one, applyItem_other, clearAll, close, retry
mitre.field.* empty, search, openMatrix, savedToast, errorToast
mitre.tag.* remove
state.* loading, error.title, error.desc, empty.default, retry
toast.* dismiss
status.engagement.* planned, active, closed
status.simulation.* pending, in_progress, review_required, done
```
### Status enum map (`frontend/src/i18n/status.ts`)
```ts
import i18n from './index';
import type { EngagementStatus, SimulationStatus } from '@/types';
export const engagementStatusLabel = (s: EngagementStatus): string =>
i18n.t(`status.engagement.${s}`);
export const simulationStatusLabel = (s: SimulationStatus): string =>
i18n.t(`status.simulation.${s}`);
```
Consumers : `StatusBadge.tsx`, `SimulationStatusBadge.tsx` si distinct, `EngagementDetailPage` (header dans Description tab), `EngagementFormPage` (STATUS_OPTIONS dérivé du map), `SimulationFormPage` banners (restructurer comme phrases entières dans JSON, pas inline status word).
### Date formatting (`frontend/src/lib/format.ts`)
```ts
export const formatDate = (iso: string | null | undefined): string => {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('fr-FR'); // jj/mm/aaaa
};
export const formatDateTime = (iso: string): string =>
new Date(iso).toLocaleString('fr-FR');
```
Replace raw ISO renders dans : `EngagementDetailPage`, `EngagementsListPage`, `TemplatesListPage`, `UsersAdminPage`. Garder ISO dans les commands / execution_result / params (data brut).
### Acronymes — locked EN dans le JSON FR
Exemples corrects :
- `"c2.config.title": "Configuration C2"`
- `"mitre.matrix.title": "Matrice MITRE ATT&CK"`
- `"simulation.form.header.redTeam": "Red Team"` (acronyme idiom)
- `"simulation.form.header.soc": "SOC"` (acronyme)
- `"auth.forbidden": "Accès refusé"` (déjà FR dans le code)
### Strings déjà FR (preserve)
- `ProtectedRoute.tsx:35``'Accès refusé'` (déjà FR). Route via `t('auth.forbidden')`.
- `Toast.test.tsx:28` → assertion `'Session expirée'`. Confirmer cohérence avec la key associée.
### Composants out-of-scope
`ForbiddenState.tsx` et `NotFoundState.tsx` n'existent pas — pas de keys i18n à créer. Si tu vois un cas où ils manquent, **flag dans le PR body** pour un sprint follow-up, ne crée rien.
---
## Definition of Done
## Counts (estimation workflow)
- EngagementFormPage en édition : 2 colonnes desktop, stack mobile.
- Page bg différencié de card bg en light mode (eyes confort).
- Vitest + typecheck + lint verts.
- Design-reviewer APPROVED.
- Screenshots livrés ou écueil documenté.
- Commits conventional, branche `sprint/9-ui-contrast`.
| Métrique | Valeur |
|---|---|
| Strings UI uniques à traduire (dedup) | **~140 keys** |
| Test assertions à update | **~123** (sur 193 raw matches, 70 sont data fixture) |
| Fichiers source modifiés | **~30** |
| Fichiers tests modifiés | **23** |
| Nouveaux fichiers | 3 (`i18n/index.ts`, `i18n/fr.json`, `i18n/status.ts`) |
---
## Commits plan (10 commits)
1. `feat(frontend): merge Schedule tab into Description on EngagementDetailPage`
2. `chore(frontend): install react-i18next + i18next deps`
3. `feat(frontend): scaffold i18n init, fr.json skeleton, status map`
4. `feat(frontend): i18n common + nav + auth (Layout, LoginPage, ProtectedRoute)`
5. `feat(frontend): i18n engagement pages (list, detail, form) + status map wiring`
6. `feat(frontend): i18n simulation pages (form + list component)`
7. `feat(frontend): i18n template pages (list + form + picker modal)`
8. `feat(frontend): i18n MITRE components (matrix modal, picker, field, tag)`
9. `feat(frontend): i18n C2 components (config, tasks, modals, picker)`
10. `feat(frontend): i18n shared state components + users admin + fr-FR date formatting`
**Ordre** : chaque commit = une slice verticale (source + tests mis à jour ensemble) → vitest reste vert à chaque commit.
---
## Tests strategy
### Baseline
- vitest **236/236** doit tenir à chaque commit.
- Tests rendent avec i18n init déjà chargé (FR loaded), pas de mock setup needed.
### Pattern de rewrite (3 stratégies)
1. **Direct rewrite** (cas le plus fréquent) : `getByRole('button', { name: /save/i })``getByRole('button', { name: /enregistrer/i })`.
2. **Refactor vers `data-testid`** (recommandé pour les boutons avec action semantics) : `data-testid="btn-save"` côté source, `getByTestId('btn-save')` côté test. Survit aux re-wordings futurs.
- Cible prioritaire : `SimulationFormPage.test.tsx` (5 boutons), `UsersAdminPage.test.tsx` (4), `TemplateFormPage.test.tsx` (4), `EngagementFormPage.test.tsx` (2).
3. **Keep domain assertions** (~70 sites) : `getByText('T1078')`, `getByText('alice')`, `getByText('WIN-DC01')`, `getByText('whoami')`. Pas de change.
### Nouveau test (commit 3)
`frontend/tests/i18n.test.ts` :
- i18n init avec `lng: 'fr'`.
- `t('common.save')``'Enregistrer'`.
- Missing key returns key path (debug).
- `simulationStatusLabel('done')``'Terminé'`.
- Interpolation : `t('engagement.delete.confirm', { name: 'X' })` contient `« X »`.
---
## DoD
1. EngagementDetailPage a **2 tabs** (Description + Simulations). Schedule content = header block dans le panel Description. Edit button atteignable.
2. `package.json` inclut `react-i18next` et `i18next`.
3. `frontend/src/i18n/index.ts` initialise i18next avec FR comme `lng` ET `fallbackLng`.
4. `frontend/src/i18n/fr.json` contient ~140 keys, organisées en namespaces nested.
5. `frontend/src/i18n/status.ts` expose les 2 labels et est consommé partout où raw status est rendu.
6. **Zéro string UI EN hardcodée** dans `frontend/src/pages/` + `frontend/src/components/`. Exceptions autorisées : acronymes (liste lockée), brand "Mimic", glyphes (`—`, `×`, `▾`, `▸`), API enum passthroughs, MITRE IDs, command/payload text.
7. Dates rendues via `formatDate()` au format `fr-FR` (jj/mm/aaaa).
8. `npm run test`**236+ / 236+** (+5 nouveaux tests i18n).
9. `npm run build` succès (zéro TS error, PostCSS clean — leçon `feedback_css_apply_tokens` respectée).
10. **Manual smoke obligatoire** : login → engagements → detail → simulation form → templates → users → C2 config — chaque string visible est en FR (sauf acronymes + brand).
11. CHANGELOG.md entrée sprint 12 listant migration i18n + tab merge.
---
## Out of scope (explicite)
- ❌ Backend i18n (pas de translations DB-side)
- ❌ Multi-langue (uniquement FR pour ce sprint, infra prête mais pas d'EN switcher)
- ❌ Language picker UI
- ❌ ForbiddenState / NotFoundState components creation
- ❌ Color palette / brutalism / token changes
- ❌ New features