feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / Tâche C2) (sprint 13) #15

Open
knacky wants to merge 3 commits from sprint/13-sim-tabs into main
Showing only changes of commit 9b6b682acc - Show all commits

View File

@@ -1,260 +1,208 @@
# Sprint 12i18n FR complet + EngagementDetailPage tab merge # Sprint 13SimulationFormPage : layout en 3 tabs (Red Team / SOC / Tâche C2)
**Base** : `sprint/11-spectrum-ux` (PR #13 ouverte, pas encore mergée). Sprint 12 enchaîne directement. **Base** : `sprint/12-i18n-fr` (PR #14 ouverte, pas encore mergée). Sprint 13 enchaîne directement.
**Worktree** : `.claude/worktrees/sprint-12-i18n-fr/` (dédié — conforme à la mémoire worktree-per-sprint). **Worktree** : `.claude/worktrees/sprint-13-sim-tabs/` (dédié — conforme à `feedback_worktree_per_sprint`).
**Branch** : `sprint/12-i18n-fr`. **Branch** : `sprint/13-sim-tabs`.
**Scope** : frontend uniquement. Backend intouché. **Scope** : frontend uniquement. Backend intouché. Aligné sur le pattern Tabs du sprint 11 (EngagementDetailPage 3→2 tabs) appliqué à SimulationFormPage.
--- ---
## Décisions binding (lockées par user) ## Décisions binding (lockées par user)
1. **Approche i18n** : `react-i18next` complet (deps `react-i18next` + `i18next`, JSON FR, hook `useTranslation`). 1. **Mode création** : 3 tabs visibles mais SOC + Tâche C2 **désactivés** (`aria-disabled="true"`, non-clickables, opacité réduite). Pas de banner ni d'empty-state — juste tab disabled.
2. **Acronymes** : RT, SOC, MITRE, ATT&CK, C2, BAS, TLS, MITM, JWT, IP, URL, API, ID — **gardés en anglais** dans les valeurs FR. 2. **Tab SOC** : désactivé tant que la sim n'est pas en `review_required` (ou plus). Force l'opérateur à passer par le RT pour avancer (Mark for review depuis sticky bar RT).
3. **Status enum** : valeurs DB (`pending`, `in_progress`, `done`, `planned`, `active`, `closed`) gardées EN — traduction au rendering layer via `i18n/status.ts`. 3. **Sticky bar entièrement contextuelle** par tab :
4. **Format de date** : `fr-FR` locale (`jj/mm/aaaa`, ex. `20/06/2026`). - **RT tab** : `[Save, Mark for review]` (et `[Delete]` global, voir plus bas)
5. **Ton toasts/alerts** : **bref** (ex. `"Engagement créé"`, pas `"Engagement enregistré avec succès"`). - **SOC tab** : `[Save SOC, Close]`
6. **Schedule → Description tab** : merger Schedule INTO Description. Résultat : 2 tabs (Description + Simulations). - **C2 tab** : aucune sticky bar — actions inline (`Execute via C2`, `Import C2 history`) déjà dans le tab content
4. **Count pill sur tab Tâche C2** : nombre exact de tâches C2 (depuis le hook `useSimulationC2Tasks` existant). Caché si 0 ou en mode création.
## Décisions secondaires (à appliquer par défaut)
- **Default active tab** : Red Team (premier opérateur surface).
- **Wiring URL** : `useHashTab('red-team')` (réutilise sprint 11). Hashes : `#red-team`, `#soc`, `#c2`.
- **Delete + Reopen** : pas mentionnés dans la sticky bar contextuelle user. Décision team-lead :
- **Delete** sur le tab **RT** (action destructrice côté RT — c'est l'auteur).
- **Reopen** sur le tab **SOC** (cohérent avec Close, même actor flow). Visible quand status `done`.
- **AlertBanner** (sprint 12) : `Done banner` reste visible au-dessus des tabs (status info, pas tab-spécifique). `SOC-blocked banner` disparaît (remplacé par le mécanisme de tab désactivé).
- **C2TasksPanel** (sprint 8) : déplace son rendering DANS le panel du tab C2. Plus en-dessous de la 2-col.
- **Header** (back link, title, status badge) : reste **au-dessus** des tabs, intouché.
## Contraintes constantes ## Contraintes constantes
- Primary `#024ad8` Electric Blue intouchée. - Primary `#024ad8` Electric Blue intouchée.
- Brutalisme intact : `rounded-none` partout sauf status pills + tab count pills + avatars, zéro `transition-*`, zéro `shadow-*`. - Brutalisme intact : `rounded-none` (exceptions documentées), zéro `transition-*`, zéro `shadow-*`.
- `tailwind.config.*` inchangé sauf cas exceptionnel justifié. - DESIGN.md amendments additives uniquement (sub-section "Sub-page tabs with disabled state" si besoin).
- DESIGN.md amendments additives uniquement. - Tous les nouveaux labels passent par `i18n/fr.json` (cf. sprint 12) — pas de string EN hardcodée.
- Mono uniquement pour data (IDs MITRE, dates ISO côté backend, commands, etc.). - `pnpm build` MANDATORY avant push (leçon `feedback_css_apply_tokens`).
--- ---
## Task 1 — Merge Schedule into Description (mécanique, commit 1) ## Task 1 — Extend Tabs primitive : support `disabled` per item
**File** : `frontend/src/pages/EngagementDetailPage.tsx` **File** : `frontend/src/components/Tabs.tsx`
**Current** (3 tabs Schedule / Description / Simulations) : Current `TabItem` :
```tsx
interface TabItem<T extends string> { id: T; label: string; count?: number; }
```
Target :
```tsx
interface TabItem<T extends string> { id: T; label: string; count?: number; disabled?: boolean; }
```
Comportement quand `disabled` :
- `<button>` reçoit `disabled={true}` et `aria-disabled="true"`
- Class CSS `.tab-underline-disabled` ajoutée (nouvelle recipe ci-dessous)
- Le hover ne change pas la couleur (`hover:text-graphite` ou rien)
- ArrowLeft/ArrowRight nav skippent les tabs disabled (cycle vers le tab actif suivant non-disabled)
- Clicks ignorés (`onClick={() => !disabled && onChange(item.id)}`)
- `aria-selected` reste `false` quand disabled
**New recipe** dans `frontend/src/styles/index.css` :
```css
.tab-underline-disabled {
@apply text-graphite opacity-50 cursor-not-allowed;
}
```
(Override le hover du `.tab-underline` parent ; pas de `border-primary` sur l'active state quand disabled — n'arrivera jamais puisque `aria-selected=false`.)
**Tests** : extend `Tabs.test.tsx` :
- Tab avec `disabled: true` ne fire pas `onChange` au click
- ArrowRight/ArrowLeft skippent les tabs disabled
- `aria-disabled="true"` présent
- Classe `.tab-underline-disabled` appliquée
DESIGN.md : ajouter 1 ligne dans la sub-section `Sub-page tabs` documentant la variante `disabled`.
---
## Task 2 — SimulationFormPage : refactor en 3 tabs
**File** : `frontend/src/pages/SimulationFormPage.tsx`
**Layout cible (edit mode)** :
```
[Header : ← Retour à l'engagement | titre sim | status badge]
[AlertBanner Done si done (au-dessus des tabs, status info global)]
[Tabs : Red Team | SOC (disabled si !=review_required+) | Tâche C2 (count pill)]
[Panel du tab actif :
- Red Team : tous les fields RT (Name, MITRE Techniques, Description, Commands, Prerequisites, Executed at, Execution result) + actions inline (Execute via C2, Import C2 history déjà existants en sprint 8/12)
- SOC : tous les fields SOC (Log source, Logs, SOC comment, Incident number)
- Tâche C2 : C2TasksPanel (sprint 8) directement, sans wrapper
]
[Sticky bar bottom (contextuel selon tab actif) :
- RT : [Save] [Mark for review (si pending/in_progress)] ... [Delete (toujours)]
- SOC : [Save SOC] [Close (si review_required)] [Reopen (si done)]
- C2 : aucune sticky bar (display:none ou pas de render)
]
```
**Layout en mode création (new)** :
```
[Header : ← Retour à l'engagement | "Nouvelle simulation"]
[Tabs : Red Team (active) | SOC (disabled) | Tâche C2 (disabled, pas de count pill)]
[Panel Red Team : champs simplifiés (juste Name + MITRE Techniques minimum)]
[Sticky bar bottom : [Create simulation] [Cancel]]
```
**Wiring** :
- `useHashTab<TabId>('red-team')``TabId = 'red-team' | 'soc' | 'c2'`.
- Items du Tabs :
```tsx ```tsx
type TabId = 'schedule' | 'description' | 'simulations';
const items: TabItem<TabId>[] = [ const items: TabItem<TabId>[] = [
{ id: 'schedule', label: 'Schedule' }, { id: 'red-team', label: t('simulation.form.tab.redTeam') },
{ id: 'description', label: 'Description' }, {
{ id: 'simulations', label: 'Simulations', count }, id: 'soc',
label: t('simulation.form.tab.soc'),
disabled: !editing || sim.status === 'pending' || sim.status === 'in_progress',
},
{
id: 'c2',
label: t('simulation.form.tab.c2'),
count: editing ? c2Tasks?.length : undefined,
disabled: !editing,
},
]; ];
``` ```
- Le hook `useHashTab` doit fallback proprement : si `#soc` mais tab disabled, retomber sur `'red-team'`. **Vérifie le comportement actuel** (sprint 11 a peut-être déjà ça, sinon ajout dans la même PR).
**Target** (2 tabs) : **Sticky bar contextuel** :
```tsx - Wrapper `<div className="sticky bottom-0 …">` avec contenu conditionnel `{activeTab === 'red-team' && <RtActions />}{activeTab === 'soc' && <SocActions />}{activeTab === 'c2' && null}`.
type TabId = 'description' | 'simulations'; - Ne pas rendre le wrapper du tout si C2 actif (pas de sticky div vide).
const items: TabItem<TabId>[] = [ - Réutiliser les boutons existants (Save/Mark/Save SOC/Close/Reopen/Delete) — juste les regrouper.
{ id: 'description', label: 'Description' },
{ id: 'simulations', label: 'Simulations', count }, **Banners** :
]; - `Done banner` (success) : garde au-dessus des tabs, indépendant du tab actif.
- `SOC-blocked banner` (warn) : **supprime**. Le mécanisme de tab désactivé remplace.
**C2TasksPanel** : déplacer son rendering directement dans `{activeTab === 'c2' && <C2TasksPanel … />}`. Conserver toutes les props existantes. Le `[Execute via C2]` et `[Import C2 history]` qui étaient dans la carte Red Team du sprint 8 — décision : **les laisser dans le tab Red Team** (ce sont des actions RT qui déclenchent le C2). Le tab C2 montre juste le panel des tâches.
**Tests** : `SimulationFormPage.test.tsx` — refactor important :
- Les anciens tests "RT card visible AND SOC card visible simultaneously" → maintenant on test "RT tab content visible, SOC tab content via switch-tab".
- Tests Mark/Close/Reopen : naviguer vers le bon tab avant d'asserter le bouton.
- Tests `data-testid="soc-blocked-banner"` : ce testid disparaît — ajuster ou supprimer le test.
- Add : tab SOC `disabled` quand status pending → test que click ne change pas le tab actif.
- Add : count pill sur tab C2 reflète le nombre de tasks.
## Task 3 — i18n keys nouvelles + DESIGN.md
**`fr.json`** ajouter :
```json
"simulation.form.tab.redTeam": "Red Team",
"simulation.form.tab.soc": "SOC",
"simulation.form.tab.c2": "Tâche C2"
``` ```
(`Red Team` reste idiom, `SOC` acronyme — cohérent avec les conventions sprint 12.)
**Migration du contenu Schedule** : déplacer dans le panel Description, en HEADER (au-dessus du paragraphe description) : **DESIGN.md** § Navigation > Sub-page tabs : ajouter 1 paragraphe sur la variante `disabled` (visual : opacity-50 + cursor-not-allowed, aria-disabled, skipped par arrow-keys).
```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>}
```
- 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".
**Note** : les strings ci-dessus seront ensuite traduites dans le pass i18n. Pas la peine de les écrire en FR dans commit 1.
--- ---
## Task 2 — Full FR translation via react-i18next ## Counts attendus
### 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.
---
## Counts (estimation workflow)
| Métrique | Valeur | | Métrique | Valeur |
|---|---| |---|---|
| Strings UI uniques à traduire (dedup) | **~140 keys** | | Fichiers source modifiés | 3 (`Tabs.tsx`, `SimulationFormPage.tsx`, `index.css` recipe) |
| Test assertions à update | **~123** (sur 193 raw matches, 70 sont data fixture) | | Fichiers source touched (juste i18n keys) | 1 (`fr.json`) |
| Fichiers source modifiés | **~30** | | Fichiers tests modifiés | 2 (`Tabs.test.tsx`, `SimulationFormPage.test.tsx`) |
| Fichiers tests modifiés | **23** | | Nouveaux fichiers | 0 |
| Nouveaux fichiers | 3 (`i18n/index.ts`, `i18n/fr.json`, `i18n/status.ts`) | | DESIGN.md amendments | 1 paragraphe additive |
--- ---
## Commits plan (10 commits) ## Commits plan (~4 commits)
1. `feat(frontend): merge Schedule tab into Description on EngagementDetailPage` 1. `feat(frontend): Tabs primitive supports per-item disabled state` (Tabs.tsx + recipe + Tabs.test.tsx)
2. `chore(frontend): install react-i18next + i18next deps` 2. `feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / C2 tasks)` (SimulationFormPage.tsx + tests + i18n keys + DESIGN.md amendment)
3. `feat(frontend): scaffold i18n init, fr.json skeleton, status map` 3. `refactor(frontend): contextual sticky bar per active tab on SimulationFormPage` (extraction des actions par tab, supprimer SOC-blocked banner)
4. `feat(frontend): i18n common + nav + auth (Layout, LoginPage, ProtectedRoute)` 4. `docs(changelog): sprint 13 simulation tabs entry`
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. Collapse à 2-3 si plus efficace.
---
## 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 ## DoD
1. EngagementDetailPage a **2 tabs** (Description + Simulations). Schedule content = header block dans le panel Description. Edit button atteignable. 1. SimulationFormPage en edit mode : 3 tabs visibles (Red Team default, SOC, Tâche C2). Count pill sur C2 = `c2Tasks.length`.
2. `package.json` inclut `react-i18next` et `i18next`. 2. Tabs SOC + C2 désactivés en mode création.
3. `frontend/src/i18n/index.ts` initialise i18next avec FR comme `lng` ET `fallbackLng`. 3. Tab SOC désactivé tant que status ∉ `{review_required, done}`.
4. `frontend/src/i18n/fr.json` contient ~140 keys, organisées en namespaces nested. 4. Sticky bar contextuelle : RT [Save + Mark for review + Delete], SOC [Save SOC + Close + Reopen], C2 [aucune sticky bar].
5. `frontend/src/i18n/status.ts` expose les 2 labels et est consommé partout où raw status est rendu. 5. `useHashTab` gère le fallback gracieux quand hash pointe vers un tab disabled (retombe sur `'red-team'`).
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. 6. Tabs primitive : ArrowLeft/ArrowRight skippent les tabs disabled.
7. Dates rendues via `formatDate()` au format `fr-FR` (jj/mm/aaaa). 7. AlertBanner Done garde sa place au-dessus des tabs (status global). SOC-blocked banner supprimé.
8. `npm run test`**236+ / 236+** (+5 nouveaux tests i18n). 8. C2TasksPanel rendu dans le tab C2 panel.
9. `npm run build` succès (zéro TS error, PostCSS clean — leçon `feedback_css_apply_tokens` respectée). 9. `[Execute via C2]` + `[Import C2 history]` restent dans le tab Red Team.
10. **Manual smoke obligatoire** : login → engagements → detail → simulation form → templates → users → C2 config — chaque string visible est en FR (sauf acronymes + brand). 10. Tests : Tabs.test.tsx + SimulationFormPage.test.tsx adaptés, baseline ≥ 245 vitest, +N nouveaux tests sur disabled tabs + count pill + tab gating.
11. CHANGELOG.md entrée sprint 12 listant migration i18n + tab merge. 11. `tsc --noEmit` + `eslint --max-warnings=0` + `pnpm build` clean.
12. CHANGELOG.md entrée sprint 13.
---
## Out of scope (explicite) ## Out of scope (explicite)
-Backend i18n (pas de translations DB-side) -Refactor des sub-forms RT / SOC (juste déplacement dans le tab)
-Multi-langue (uniquement FR pour ce sprint, infra prête mais pas d'EN switcher) -Nouveau pattern Tabs ailleurs (uniquement SimulationFormPage cette sprint)
-Language picker UI -Changes backend
-ForbiddenState / NotFoundState components creation -Color / brutalism / token changes
-Color palette / brutalism / token changes -Multi-langue (FR uniquement, infra sprint 12)
- ❌ New features