# Sprint 12 — i18n FR complet + EngagementDetailPage tab merge **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é. --- ## Décisions binding (lockées par user) 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 1 — Merge Schedule into Description (mécanique, commit 1) **File** : `frontend/src/pages/EngagementDetailPage.tsx` **Current** (3 tabs Schedule / Description / Simulations) : ```tsx type TabId = 'schedule' | 'description' | 'simulations'; const items: TabItem[] = [ { id: 'schedule', label: 'Schedule' }, { id: 'description', label: 'Description' }, { id: 'simulations', label: 'Simulations', count }, ]; ``` **Target** (2 tabs) : ```tsx type TabId = 'description' | 'simulations'; const items: TabItem[] = [ { id: 'description', label: 'Description' }, { id: 'simulations', label: 'Simulations', count }, ]; ``` **Migration du contenu Schedule** : déplacer dans le panel Description, en HEADER (au-dessus du paragraphe description) : ```tsx
Start date
{formatDate(eng.start_date)}
End date
{eng.end_date ? formatDate(eng.end_date) : '—'}
Status
Created at
{formatDate(eng.created_at)}
Edit

Description

{eng.description ||

No description provided.

} ``` - 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 ### 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 ``. ### Pattern usage ```tsx const { t } = useTranslation();

{t('engagement.list.title')}

{t('engagement.delete.confirm', { name: eng.name })}

``` ### 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 | |---|---| | 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