feat(frontend): sprint 4 — dark mode + matrix overhaul + tactic selection + done read-only + UI polish
US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment US-18: done status fully read-only + Reopen button (done → review_required) for all roles US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=) US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence 92/92 tests passing, typecheck and lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@ L'évolution est tracée dans CHANGELOG.md § Changed sprint 4.
|
||||
- [ ] AC-21.1 : modèle `Simulation` gagne un champ `tactic_ids` (colonne JSON, liste de strings TA-id, défaut `[]`). Séparé de `techniques`.
|
||||
- [ ] AC-21.2 : migration Alembic `0004_simulation_tactic_ids.py` — ADD COLUMN `tactic_ids` (JSON, NOT NULL, default `[]`). Pas besoin de batch pour ADD COLUMN (SQLite natif). Aucun backfill (default suffit).
|
||||
- [ ] AC-21.3 : sérialisation Simulation expose `tactics: [{id, name}]` enrichi à partir de `tactic_ids` (id snapshot + name dérivé du bundle MITRE au runtime, comme pour `techniques`).
|
||||
- [ ] AC-21.4 : `PATCH /api/simulations/<sid>` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister (préfixe `TA`, présent dans `_TACTIC_ORDER`). Dedup serveur. ID inconnu → 400. Bundle non chargé → 503.
|
||||
- [ ] AC-21.4 : `PATCH /api/simulations/<sid>` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister dans `_TACTIC_IDS` (mapping TA-id → short-name, cf §2 Service MITRE). Dedup serveur. ID inconnu → 400. Bundle non chargé → 503.
|
||||
- [ ] AC-21.5 : `tactic_ids` est ajouté au gate SOC : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. SOC envoie → 403. Auto-transition se déclenche aussi si `tactic_ids` non vide.
|
||||
- [ ] AC-21.6 : `MitreMatrixModal` — le header de chaque colonne tactique devient cliquable (toggle de la tactique elle-même). État visuel distinct des techniques sélectionnées. Compteur passe à `N+M selected` (techniques + tactique).
|
||||
- [ ] AC-21.7 : `MitreTechniquesField` — tactiques sélectionnées affichées comme chips distincts (style différencié : `bg-primary text-canvas` au lieu de `bg-primary-soft text-primary-deep`). × pour retirer. Auto-save sur add/remove.
|
||||
@@ -142,17 +142,53 @@ tactic_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list
|
||||
**Serializer** : `serialize_simulation(sim)` ajoute `tactics: [{id, name}]` enrichi runtime.
|
||||
|
||||
**Service MITRE** :
|
||||
- Nouvelle fonction `lookup_tactic(tactic_id)` → `{id, name}` ou None.
|
||||
- Nouvelle fonction `get_tactic_name(tactic_id)` → name string ou fallback id.
|
||||
- `_TACTIC_ORDER` / `_TACTIC_NAMES` réutilisés.
|
||||
- Sprint 3 a indexé les tactiques par **short-name** (`"initial-access"`, `"execution"`, `...`) dans `_TACTIC_ORDER` et `TACTIC_NAMES`. La SPEC et le plan sprint 4 utilisent la notation **TA-id** (`"TA0001"`, `"TA0007"`, etc.). Il faut un mapping TA-id → short-name pour valider/résoudre les `tactic_ids` reçus.
|
||||
- Ajouter une constante module-level (12 entrées hardcodées, MITRE standard stable — attention, les TA-ids ne sont PAS séquentiels) :
|
||||
```python
|
||||
_TACTIC_IDS: dict[str, str] = {
|
||||
"TA0001": "initial-access",
|
||||
"TA0002": "execution",
|
||||
"TA0003": "persistence",
|
||||
"TA0004": "privilege-escalation",
|
||||
"TA0005": "defense-evasion",
|
||||
"TA0006": "credential-access",
|
||||
"TA0007": "discovery",
|
||||
"TA0008": "lateral-movement",
|
||||
"TA0009": "collection",
|
||||
"TA0011": "command-and-control",
|
||||
"TA0010": "exfiltration",
|
||||
"TA0040": "impact",
|
||||
}
|
||||
```
|
||||
- Nouvelle fonction `lookup_tactic(tactic_id: str) -> dict | None` :
|
||||
```python
|
||||
short = _TACTIC_IDS.get(tactic_id)
|
||||
if short is None:
|
||||
return None
|
||||
return {"id": tactic_id, "name": TACTIC_NAMES[short]}
|
||||
```
|
||||
- Nouvelle fonction `get_tactic_name(tactic_id: str) -> str | None` : pareil mais retourne juste le name.
|
||||
- Validation `tactic_ids` dans `simulation_workflow.py` : un id absent de `_TACTIC_IDS` → 400 `{"error": "unknown tactic id: <id>"}`.
|
||||
|
||||
**Service workflow `simulation_workflow.py`** — modifications :
|
||||
1. **Guard `done` (AC-18.1)** : tout en haut de `apply_patch`, AVANT le check RBAC, si `simulation.status == "done"` → 409 `{error: "simulation is done — reopen first"}`.
|
||||
1. **Guard `done` (AC-18.1)** : tout en haut de `apply_patch`, AVANT le check RBAC, si `simulation.status == "done"` → 409 `{error: "simulation is done — reopen first"}`. Vaut pour TOUS les rôles, admin compris.
|
||||
2. **SOC gate étendu** : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`.
|
||||
3. **Validation `tactic_ids`** upfront (similaire à `technique_ids`) : tous les IDs validés contre le bundle, dedup `dict.fromkeys`. Bundle non chargé → 503.
|
||||
4. **Auto-transition** : ajouter le check `len(payload["tactic_ids"]) > 0` au calcul `auto_trigger`.
|
||||
5. **Transition `done → review_required` (AC-18.2)** : ajouter ce cas au state machine. Autorisé admin + redteam + soc. Update `updated_at`. Autres transitions depuis `done` → 409.
|
||||
6. **Hook engagement auto-status (AC-19.1)** : après une transition de simu vers `in_progress` (auto OU manual), appeler une fonction `_maybe_activate_engagement(simulation)` qui, si `simulation.engagement.status == "planned"`, set `engagement.status = "active"` et add à la session (commit en même temps que la simu).
|
||||
5. **Transition `done → review_required` (AC-18.2)** — **implémentation précise** : le dict `_ALLOWED_TRANSITIONS` actuel est keyé par target status et a déjà une entrée `"review_required"` avec from={pending, in_progress} et roles={admin, redteam}. On NE peut PAS ajouter une 2e entrée avec la même clé. À la place, dans `transition()`, AVANT le lookup dict, ajoute un cas spécial qui suit les patterns existants du fichier :
|
||||
```python
|
||||
# transition() returns tuple[Any, int] | None — None on success, error tuple otherwise.
|
||||
# Existing functions use datetime.now(UTC) (timezone-aware, not deprecated utcnow).
|
||||
# Enum values are UPPERCASE: SimulationStatus.DONE, SimulationStatus.REVIEW_REQUIRED.
|
||||
if to_status == "review_required" and simulation.status == SimulationStatus.DONE:
|
||||
simulation.status = SimulationStatus.REVIEW_REQUIRED
|
||||
simulation.updated_at = datetime.now(UTC)
|
||||
db.session.commit()
|
||||
return None
|
||||
# ... reste de la fonction inchangée (dict lookup pour les autres cas)
|
||||
```
|
||||
Pas de check explicite du rôle ici — `@login_required` upstream + l'enum User limité à admin/redteam/soc rendent la défense superflue (KISS). Autres transitions depuis `done` (vers `pending`, `in_progress`, `done` lui-même) → 409 via le dict lookup qui ne les couvre pas.
|
||||
6. **Hook engagement auto-status (AC-19.1)** : après une transition de simu vers `in_progress` (auto OU manual), appeler une fonction `_maybe_activate_engagement(simulation)` qui, si `simulation.engagement.status == "planned"`, set `engagement.status = "active"` et `db.session.add(engagement)`. **NE PAS appeler `db.session.commit()` dans le helper** — le caller (`api/simulations.py:update_simulation`) gère le commit final, sinon double-commit.
|
||||
|
||||
**API `simulations.py`** :
|
||||
- PATCH : le check status==done est fait dans `apply_patch` (voir au-dessus).
|
||||
@@ -200,6 +236,7 @@ Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le
|
||||
|
||||
**US-19 — Engagement auto-status (côté UI)**
|
||||
- `useUpdateSimulation` et `useTransitionSimulation` : ajouter `["engagement", eid]` et `["engagements"]` aux invalidations après mutation réussie. Pas d'autre changement visuel.
|
||||
- **Note (spec-reviewer Pass 3)** : `eid` n'est pas directement disponible dans la signature des hooks (qui prennent `sid`). Solution : lire `engagement_id` depuis la response simulation (le backend l'expose toujours, cf serialize_simulation sprint 2) OU le passer en arg supplémentaire au hook si plus propre. Pas un trou plan, juste à anticiper.
|
||||
|
||||
**US-20 — Matrice MITRE attack.mitre.org look**
|
||||
- `MitreMatrixModal.tsx` overhaul :
|
||||
@@ -213,7 +250,8 @@ Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le
|
||||
**US-21 — Tactic selection**
|
||||
- `MitreMatrixModal.tsx` : header de tactique cliquable (toggle). État visuel distinct.
|
||||
- Apply renvoie `{techniques, tactics}` au parent.
|
||||
- `MitreTechniquesField.tsx` : tactic chips style différencié `bg-primary text-canvas`. Auto-save tactic_ids.
|
||||
- `MitreTechniquesField.tsx` : tactic chips style différencié `bg-primary text-canvas`. Auto-save.
|
||||
- **PATCH combiné (spec-reviewer fix #4)** : Apply depuis la matrice → UN SEUL PATCH `{technique_ids: [...], tactic_ids: [...]}` (les 2 listes ensemble). Pas 2 PATCH séquentiels (risque de race + risque que le 2nd appel hit le guard done). Remove via × sur un tag → un PATCH avec la liste mise à jour (seulement la dimension qui change : `technique_ids` ou `tactic_ids`). Quick Search select → 1 PATCH `{technique_ids: [...]}` (le picker n'ajoute que des techniques). Toutes les mutations passent par `useUpdateSimulation` en un appel atomique.
|
||||
|
||||
**US-22 — Refonte input MITRE**
|
||||
- `MitreTechniquesField.tsx` :
|
||||
@@ -221,6 +259,7 @@ Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le
|
||||
- Plus de boutons textuels "Add Technique" / "Quick Search".
|
||||
- Chips compacts (T-id ou TA-id seul, name en `title=`).
|
||||
- Empty state minimal.
|
||||
- **`SimulationFormPage.tsx` — call site update (spec-reviewer fix #4)** : la signature de `MitreTechniquesField` change de `value: MitreTechnique[]` (sprint 3) à `value: {techniques: MitreTechnique[], tactics: MitreTactic[]}`. La page doit passer `value={{techniques: sim.techniques, tactics: sim.tactics}}` (le champ `sim.tactics` vient du nouveau serializer backend). TypeScript catch le miss mais flag-le explicitement pour ne pas l'oublier.
|
||||
|
||||
**US-23 — Dark mode**
|
||||
- `Layout.tsx` : toggle theme dans la topbar. Hook `useTheme()` (localStorage + media query). 3 états avec cycle.
|
||||
@@ -267,6 +306,8 @@ US-24/25 non e2e (process / repo files). Couverture par dogfood (la PR sprint 4
|
||||
|
||||
Adapter les sprint 2/3 e2e si l'audit boutons (AC-17.2) renomme certains labels.
|
||||
|
||||
**Spec-reviewer INFO B** : AC-22.2 change le format des chips de "T1059 — Command and Scripting Interpreter" (sprint 3) à juste "T1059" (avec name dans `title=`). Les e2e sprint 3 (notamment `us14-techniques-tags.spec.ts`) qui assertent le format complet doivent être mis à jour. Pas seulement les labels boutons.
|
||||
|
||||
---
|
||||
|
||||
## 6. Décisions arrêtées
|
||||
|
||||
Reference in New Issue
Block a user