Compare commits
20 Commits
sprint/9-u
...
10d4293f44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d4293f44 | ||
|
|
2c7fcec7cd | ||
|
|
11ce3cfb86 | ||
|
|
790ced4204 | ||
|
|
1324a0c8ce | ||
|
|
5d34a5e171 | ||
|
|
1819089925 | ||
|
|
9a8c5f52ab | ||
|
|
59eaa342a9 | ||
|
|
004d075cad | ||
|
|
bca39dcca7 | ||
|
|
89fb38b107 | ||
| 65857134ef | |||
|
|
208c6e6428 | ||
|
|
91533c214c | ||
|
|
0fab40b486 | ||
|
|
ee5fda6059 | ||
|
|
774ae05748 | ||
|
|
c31f985e7a | ||
| 24d4a3b146 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — Sprint 11 (Spectrum UX port: 4 primitives + compact density global)
|
||||
|
||||
**Frontend only** (233 vitest passing — baseline 212 + 21 new tests across 4 new specs)
|
||||
|
||||
- `frontend/src/hooks/useHashTab.ts` — NEW. Pure-TS hook that reads `window.location.hash`, falls back to a default tab id, and listens to `hashchange` (cleanup on unmount). No style dependency.
|
||||
- `frontend/src/components/Tabs.tsx` — NEW. `<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)
|
||||
|
||||
31
DESIGN.md
31
DESIGN.md
@@ -216,11 +216,23 @@ Shadows (`box-shadow`) are not used anywhere.
|
||||
- Text `{colors.slab-text}`, caption-md, `rounded-none`
|
||||
- Active state: 2px primary bottom border
|
||||
|
||||
#### Sub-page tabs
|
||||
|
||||
Used inside pages that need content partitioning without a URL change (e.g. EngagementDetailPage: Schedule / Description / Simulations).
|
||||
|
||||
- **Container**: `flex items-end border-b border-hairline gap-xs` — sits on the `{colors.hairline}` bottom line.
|
||||
- **`.tab-underline`**: `text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs`. Hover is **instant** — no `transition-*`.
|
||||
- **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text.
|
||||
- **Count pill** (optional, e.g. simulation count): `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception.
|
||||
- **Active count pill**: `.tab-count-pill-active` — `bg-primary-soft text-primary`.
|
||||
- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` on each button.
|
||||
|
||||
### Data Tables
|
||||
|
||||
- `table-layout: fixed`, `word-break: break-word`
|
||||
- Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border
|
||||
- Data cells: 44px min-height, 1px `{colors.hairline}` bottom border
|
||||
- Data cells: **32px min-height** (compact density, all surfaces). Class: `.table-compact`.
|
||||
- **WCAG SC 2.5.5 tradeoff**: the recommended 44×44px touch target is intentionally not met on data rows. Mimic is a BAS operator console used at desktop on mouse/keyboard — density and scanability take priority over touch target size. Action buttons within rows retain icon ≥ 24px with adequate padding to preserve a usable hit area.
|
||||
- ID / date / technique columns: `font-mono`
|
||||
- Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide
|
||||
|
||||
@@ -229,6 +241,23 @@ Shadows (`box-shadow`) are not used anywhere.
|
||||
- Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary)
|
||||
- Background `{colors.paper}`, 1px `{colors.hairline}` border
|
||||
|
||||
#### Inline Banners
|
||||
|
||||
For persistent in-page status messages (not ephemeral toasts). Use `<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
|
||||
|
||||
2
SPEC.md
2
SPEC.md
@@ -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) :
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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/"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
30
frontend/src/components/AlertBanner.tsx
Normal file
30
frontend/src/components/AlertBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/BackLink.tsx
Normal file
17
frontend/src/components/BackLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
frontend/src/components/Tabs.tsx
Normal file
58
frontend/src/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/hooks/useHashTab.ts
Normal file
26
frontend/src/hooks/useHashTab.ts
Normal 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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "Review required" 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 "Review required" before you can fill in the SOC section.
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
48
frontend/tests/components/AlertBanner.test.tsx
Normal file
48
frontend/tests/components/AlertBanner.test.tsx
Normal 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-/);
|
||||
});
|
||||
});
|
||||
39
frontend/tests/components/BackLink.test.tsx
Normal file
39
frontend/tests/components/BackLink.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
84
frontend/tests/components/Tabs.test.tsx
Normal file
84
frontend/tests/components/Tabs.test.tsx
Normal 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-/);
|
||||
}
|
||||
});
|
||||
});
|
||||
43
frontend/tests/hooks/useHashTab.test.tsx
Normal file
43
frontend/tests/hooks/useHashTab.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
289
tasks/todo.md
289
tasks/todo.md
@@ -1,73 +1,260 @@
|
||||
# Sprint 9 — UI: engagement 2-col + global contrast pass
|
||||
# Sprint 12 — i18n 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 A — EngagementFormPage 2-col
|
||||
## Task 1 — Merge 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
|
||||
|
||||
Reference in New Issue
Block a user