From 10d4293f445940430a882bad6266f81fdc340056 Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:12:57 +0200 Subject: [PATCH 01/14] docs(sprint-12): plan i18n FR (react-i18next) + EngagementDetail tab merge --- tasks/todo.md | 376 +++++++++++++++++++++++++++++++------------------- 1 file changed, 236 insertions(+), 140 deletions(-) diff --git a/tasks/todo.md b/tasks/todo.md index 02b3964..11df455 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,164 +1,260 @@ -# Sprint 11 — Spectrum UX port : 4 primitives + compact density global +# Sprint 12 — i18n FR complet + EngagementDetailPage tab merge -**Base** : `origin/main` (PR #12 mergée — sprint 8 + 9 + 10 en). -**Branch** : `sprint/11-spectrum-ux`. -**Scope** : frontend-only sprint, 5 livrables coordonnés. Pas de backend, pas de schéma, pas de nouvelle API. Pas de modif tokens couleurs/spacing/font dans tailwind.config. +**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é. --- -## Contexte - -Analyse Spectrum (`/home/user/Documents/01_Projects/spectrum`) faite via workflow. Résultat : Spectrum n'a **pas de design system** (Tailwind raw + violet ramp + recipes inlinées 30× partout). Mimic est structurellement meilleur. On ne porte pas la philosophie Spectrum — on extrait **5 patterns isolés** qui survivent au brutalisme Mimic. - -## Constraints absolues - -1. **Primary `#024ad8` (Electric Blue) reste**. Pas de violet, pas de spectrum ramp. -2. **Brutalisme reste** : `rounded-none` sur containers (sauf status pills + tab count pills + avatars), zero `transition-*`, zero `shadow-*`, hairline 1px borders. -3. **Pas de modif `tailwind.config.*`** sauf si une nouvelle classe utility est strictement nécessaire (justifier dans le PR). -4. **DESIGN.md amendments additives uniquement** — pas de modif des tokens existants. -5. **Mono uniquement pour data** (IDs MITRE, dates ISO, commands, etc.) — rule sprint 7. - ## Décisions binding (lockées par user) -- **Q1 → B** : Tabs primitive **+ consumer EngagementDetailPage** (3 tabs : Schedule / Description / Simulations au lieu du stack vertical actuel). -- **Q2 → B** : **Compact density GLOBAL** — toutes les tables passent à 32px row, pas opt-in. User accepte le tradeoff WCAG (touch target) sur Engagements list. -- **Q3 → A** : AlertBanner + refactor `SimulationFormPage` (Done banner + SOC-blocked banner). +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.). --- -## Livrables +## Task 1 — Merge Schedule into Description (mécanique, commit 1) -### Livrable 1 — `Tabs` primitive + `useHashTab` hook + consumer +**File** : `frontend/src/pages/EngagementDetailPage.tsx` -**Files** : -- `frontend/src/hooks/useHashTab.ts` (NEW) — ~30 LoC. Reads `window.location.hash`, falls back to default ID, listens to `hashchange`, updates URL without reload. TypeScript pur, zéro style. -- `frontend/src/components/Tabs.tsx` (NEW) — composant ``. Underline variant. -- `frontend/src/styles/index.css` — nouvelles recipes : - - `.tab-underline` — `text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink` (instantané, pas de transition) - - `.tab-underline-active` — `text-primary border-primary` (override) - - `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono` - - `.tab-count-pill-active` — `bg-primary-soft text-primary` (override) -- `frontend/src/pages/EngagementDetailPage.tsx` — refactor en 3 tabs : - - Tab 1 "Schedule" (id `schedule`, default) — dates + statut + bouton "Edit engagement" - - Tab 2 "Description" (id `description`) — texte description - - Tab 3 "Simulations" (id `simulations`) — la liste actuelle de simulations - - Count pills sur tab 3 = nombre de simulations - - Tabs branchées sur `useHashTab('schedule')` -- `DESIGN.md` — nouvelle subsection `### Navigation › Sub-page tabs` (additive) +**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 }, +]; +``` -**Brutalism check** : -- Tab : `border-b-2` only, pas de bg, pas de rounded, pas de transition -- Pill : `rounded-pill` autorisé (exception status pill DESIGN.md L125) -- Hover : `hover:text-ink` instantané +**Target** (2 tabs) : +```tsx +type TabId = 'description' | 'simulations'; +const items: TabItem[] = [ + { id: 'description', label: 'Description' }, + { id: 'simulations', label: 'Simulations', count }, +]; +``` -### Livrable 2 — `AlertBanner` component + 4 recipes +**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.

} +``` -**Files** : -- `frontend/src/components/AlertBanner.tsx` (NEW) — ``. Brutalist : border-l-4 strip semantic, fill `bg-paper`, border `border-hairline` partout sauf le strip gauche, `rounded-none`. Icon Lucide à gauche (`AlertCircle` error, `AlertTriangle` warn, `CheckCircle` success, `Info` info) à `size={16}`. -- `frontend/src/styles/index.css` — 4 nouvelles recipes : - - `.alert-error` — `bg-paper border border-hairline border-l-4 border-l-bloom-deep px-md py-sm flex items-start gap-sm` - - `.alert-warn` — same + `border-l-warn` - - `.alert-success` — same + `border-l-success` - - `.alert-info` — same + `border-l-primary` -- `frontend/src/pages/SimulationFormPage.tsx` — refactor 2 banners hand-rollés (~L300 Done banner + ~L310 SOC-blocked banner) → `` / ``. -- `DESIGN.md` — extend `### Toast Notifications` avec subsection `### Inline Banners` (additive, mêmes 4 variants). +- 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** : `bloom-deep`, `warn`, `success`, `primary` sont les tokens existants — pas de nouveau token couleur. - -### Livrable 3 — `BackLink` component (dédup) - -**Files** : -- `frontend/src/components/BackLink.tsx` (NEW) — `Back to engagements`. Renders `← {children}` (ArrowLeft Lucide size=14, gap-xxs). Style : `text-graphite hover:text-primary caption-md` instantané. -- Refactor consumers (3 instances hand-rollées) : - - `frontend/src/pages/EngagementDetailPage.tsx` (~L35) - - `frontend/src/pages/SimulationFormPage.tsx` (back-link en edit mode) - - `frontend/src/pages/TemplateFormPage.tsx` (back-link en edit mode) - -### Livrable 4 — `.table-compact` recipe + apply GLOBAL - -**Files** : -- `frontend/src/styles/index.css` — nouvelle recipe (mais appliquée comme default, pas opt-in cette sprint per user decision Q2-B) : - - Soit : modifier la recipe `.card-product table` existante pour passer à 32px row, `caption-md` text, `py-xxs px-xs` cells, divider `border-hairline`. - - Soit : ajouter `.table-compact` puis l'appliquer à TOUTES les list pages (Engagements, Templates, Users, etc.). - - **Choix builder** : pick the cleaner path. Préférer modifier le default si toutes les tables doivent suivre, sinon `.table-compact` + apply partout. -- Apply sur : - - `frontend/src/pages/EngagementsListPage.tsx` - - `frontend/src/pages/TemplatesListPage.tsx` - - `frontend/src/pages/UsersAdminPage.tsx` - - `frontend/src/components/SimulationList.tsx` (utilisé dans EngagementDetailPage) - - Toute autre table dans le code (grep ``. + +### 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) -- ❌ Side nav / drawer / breadcrumb component — top nav + BackLink suffit -- ❌ Global engagement selector top-bar (Spectrum pattern rejeté) -- ❌ Editor dynamic-column grid — pas d'éditeur dans Mimic -- ❌ Dark mode rework -- ❌ Toast queue system (les toasts actuels via `useToast` restent) -- ❌ PDF/document deliverable -- ❌ Color palette changes -- ❌ Tokens spacing/typo dans tailwind.config - -## Definition of Done - -- ✅ 5 primitives livrées + EngagementDetailPage refactorée en tabs + SimulationFormPage banners migrés + BackLink dédup × 3 -- ✅ vitest **212+/212+** (4 new specs ajoutées), tsc + lint clean -- ✅ pytest **469/469** intact (zéro backend touché) -- ✅ Toutes tables passent à 32px row density (validation visuelle) -- ✅ Design-reviewer APPROVED (brutalism check sur les 4 new recipes) -- ✅ Code-reviewer APPROVED -- ✅ DESIGN.md amendé (3 subsections additives) -- ✅ CHANGELOG.md entrée sprint 11 -- ✅ PR ouverte sur Gitea avec body +- ❌ 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 From 3cdfba636e647590f7933d39c3032f18f1b81435 Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:16:00 +0200 Subject: [PATCH 02/14] feat(frontend): merge Schedule tab into Description on EngagementDetailPage Reduces from 3 tabs (Schedule / Description / Simulations) to 2 (Description / Simulations). Schedule content (start/end/status/created_at + Edit button) moved into a header block at the top of the Description panel, separated from description text by a hairline
. Default tab changed from 'schedule' to 'description'. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/EngagementDetailPage.tsx | 53 ++++++++++----------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/frontend/src/pages/EngagementDetailPage.tsx b/frontend/src/pages/EngagementDetailPage.tsx index 47ca46c..f0ff78e 100644 --- a/frontend/src/pages/EngagementDetailPage.tsx +++ b/frontend/src/pages/EngagementDetailPage.tsx @@ -1,4 +1,4 @@ -import { useParams } from 'react-router-dom'; +import { useParams, Link } from 'react-router-dom'; import { extractApiError } from '@/api/client'; import { useAuth } from '@/hooks/useAuth'; import { useEngagement } from '@/hooks/useEngagements'; @@ -11,9 +11,8 @@ 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'; +type TabId = 'description' | 'simulations'; export function EngagementDetailPage(): JSX.Element { const { id } = useParams<{ id: string }>(); @@ -23,7 +22,7 @@ export function EngagementDetailPage(): JSX.Element { const detail = useEngagement(numericId); const simsQuery = useEngagementSimulations(numericId); - const [activeTabRaw, setActiveTab] = useHashTab('schedule'); + const [activeTabRaw, setActiveTab] = useHashTab('description'); const activeTab = activeTabRaw as TabId; if (detail.isLoading) return ; @@ -41,7 +40,6 @@ export function EngagementDetailPage(): JSX.Element { const simCount = simsQuery.data?.length; const tabs = [ - { id: 'schedule', label: 'Schedule' }, { id: 'description', label: 'Description' }, { id: 'simulations', label: 'Simulations', count: simCount }, ]; @@ -60,12 +58,7 @@ export function EngagementDetailPage(): JSX.Element { {canEditEngagements ? ( -
- - - Edit - -
+ ) : null} @@ -76,25 +69,27 @@ export function EngagementDetailPage(): JSX.Element { id={`tabpanel-${activeTab}`} aria-labelledby={`tab-${activeTab}`} > - {activeTab === 'schedule' && ( -
-

Schedule

-
-
Start date
-
{eng.start_date}
-
End date
-
{eng.end_date ?? '—'}
-
Status
-
{eng.status}
-
Created at
-
{eng.created_at}
-
-
- )} - {activeTab === 'description' && ( -
-

Description

+
+
+
+
Start date
+
{eng.start_date}
+
End date
+
{eng.end_date ?? '—'}
+
Status
+
+
Created at
+
{eng.created_at}
+
+ {canEditEngagements ? ( + + Edit + + ) : null} +
+
+

Description

{eng.description?.trim() ? eng.description : 'No description provided.'}

From 51fb2e4f955b0ec64668aa25d1486488c797341a Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:16:13 +0200 Subject: [PATCH 03/14] chore(frontend): install react-i18next + i18next deps Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 87 +++++++++++++++++++++++++++++++++++++- frontend/package.json | 2 + 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d198e7..df15861 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,9 +11,11 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@tanstack/react-query": "^5.59.0", "axios": "^1.7.7", + "i18next": "^26.3.1", "lucide-react": "^1.16.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.8", "react-router-dom": "^6.27.0" }, "devDependencies": { @@ -340,7 +342,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4232,6 +4233,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4269,6 +4279,34 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5936,6 +5974,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -7056,7 +7121,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7133,6 +7198,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7289,6 +7363,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3a16c03..5836e6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,9 +15,11 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@tanstack/react-query": "^5.59.0", "axios": "^1.7.7", + "i18next": "^26.3.1", "lucide-react": "^1.16.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.8", "react-router-dom": "^6.27.0" }, "devDependencies": { From 3723bd009b5248ec934dd43118a7d41cff61548a Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:21:02 +0200 Subject: [PATCH 04/14] feat(frontend): scaffold i18n init, fr.json (~140 keys), status map, format helpers Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/i18n/fr.json | 478 ++++++++++++++++++++++++++++++++++++ frontend/src/i18n/index.ts | 13 + frontend/src/i18n/status.ts | 8 + frontend/src/lib/format.ts | 7 + frontend/src/main.tsx | 1 + frontend/tests/i18n.test.ts | 46 ++++ 6 files changed, 553 insertions(+) create mode 100644 frontend/src/i18n/fr.json create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/status.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/tests/i18n.test.ts diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json new file mode 100644 index 0000000..15fd608 --- /dev/null +++ b/frontend/src/i18n/fr.json @@ -0,0 +1,478 @@ +{ + "common": { + "save": "Enregistrer", + "saving": "Enregistrement…", + "cancel": "Annuler", + "delete": "Supprimer", + "edit": "Modifier", + "new": "Nouveau", + "loading": "Chargement…", + "retry": "Réessayer", + "back": "Retour", + "dismiss": "Fermer", + "view": "Voir", + "create": "Créer", + "creating": "Création…", + "close": "Fermer", + "confirm": "Confirmer" + }, + "nav": { + "engagements": "Engagements", + "templates": "Templates", + "users": "Utilisateurs", + "signOut": "Se déconnecter", + "brand": "Mimic" + }, + "auth": { + "login": { + "title": "Mimic", + "subtitle": "Connectez-vous pour accéder à vos engagements.", + "username": "Nom d'utilisateur", + "password": "Mot de passe", + "signIn": "Se connecter", + "signingIn": "Connexion…", + "invalid": "Identifiants invalides" + }, + "forbidden": "Accès refusé", + "loadingSession": "Chargement de la session…" + }, + "engagement": { + "list": { + "title": "Engagements", + "subtitle": "Missions red team et leur statut.", + "new": "Nouveau", + "empty": { + "title": "Aucun engagement", + "desc": "Créez votre premier engagement pour commencer à suivre les missions red team." + }, + "col": { + "name": "Nom", + "status": "Statut", + "start": "Début", + "end": "Fin", + "createdBy": "Créé par", + "actions": "Actions" + }, + "view": "Voir", + "edit": "Modifier", + "delete": "Supprimer", + "deleteConfirm": "Supprimer l'engagement « {{name}} » ? Cette action est irréversible.", + "toast": { + "deleted": "Engagement supprimé" + }, + "error": { + "load": "Impossible de charger les engagements", + "delete": "Impossible de supprimer l'engagement" + } + }, + "detail": { + "tabs": { + "description": "Description", + "simulations": "Simulations" + }, + "schedule": { + "startDate": "Date de début", + "endDate": "Date de fin", + "status": "Statut", + "createdAt": "Créé le" + }, + "edit": "Modifier", + "createdBy": "Créé par", + "noDescription": "Aucune description fournie.", + "loading": "Chargement de l'engagement…", + "errorLoad": "Impossible de charger l'engagement", + "notFound": "Engagement introuvable", + "backTo": "Retour aux engagements" + }, + "form": { + "title": { + "new": "Nouvel engagement", + "edit": "Modifier l'engagement" + }, + "subtitle": { + "new": "Définir le périmètre et la planification.", + "edit": "Mettre à jour les détails." + }, + "field": { + "name": "Nom", + "description": "Description", + "startDate": "Date de début", + "endDate": "Date de fin", + "endDateHint": "Optionnel", + "status": "Statut" + }, + "validation": { + "nameRequired": "Le nom est obligatoire", + "startDateRequired": "La date de début est obligatoire", + "endDateAfterStart": "La date de fin doit être égale ou postérieure à la date de début" + }, + "btn": { + "create": "Créer", + "save": "Enregistrer", + "saving": "Enregistrement…", + "cancel": "Annuler" + }, + "toast": { + "created": "Engagement créé", + "updated": "Engagement mis à jour" + }, + "error": { + "save": "Impossible d'enregistrer l'engagement", + "load": "Impossible de charger l'engagement" + }, + "loading": "Chargement…", + "c2Config": "Configuration C2" + } + }, + "simulation": { + "form": { + "title": { + "new": "Nouvelle simulation" + }, + "field": { + "name": "Nom", + "mitre": "Techniques et tactiques MITRE", + "description": "Description", + "commands": "Commandes", + "commandsHint": "Une commande par ligne", + "prerequisites": "Prérequis", + "executedAt": "Exécuté le", + "executionResult": "Résultat d'exécution", + "logSource": "Source de log", + "logs": "Logs", + "socComment": "Commentaire SOC", + "incidentNumber": "Numéro d'incident" + }, + "btn": { + "create": "Créer la simulation", + "creating": "Création…", + "save": "Enregistrer", + "saving": "Enregistrement…", + "saveSoc": "Enregistrer SOC", + "markReview": "Marquer pour révision", + "close": "Clore", + "reopen": "Rouvrir", + "delete": "Supprimer", + "executeC2": "Exécuter via C2", + "importC2History": "Importer l'historique C2", + "cancel": "Annuler" + }, + "deleteConfirm": { + "title": "Supprimer la simulation", + "desc": "Cette action est permanente. La simulation sera définitivement supprimée.", + "confirm": "Supprimer", + "cancel": "Annuler" + }, + "banner": { + "done": "Cette simulation est terminée et en lecture seule. Utilisez Rouvrir pour effectuer des modifications.", + "socNotReady": "La simulation n'est pas encore prête pour la révision — l'équipe red team doit la marquer comme « Révision requise » avant que vous puissiez remplir la section SOC." + }, + "header": { + "redTeam": "Red Team", + "soc": "SOC" + }, + "validation": { + "nameRequired": "Le nom est obligatoire" + }, + "toast": { + "created": "Simulation créée", + "updated": "Simulation mise à jour", + "deleted": "Simulation supprimée", + "markedReview": "Simulation marquée pour révision", + "closed": "Simulation close", + "reopened": "Simulation rouverte", + "socUpdated": "Rapport SOC mis à jour" + }, + "error": { + "create": "Impossible de créer la simulation", + "update": "Impossible de mettre à jour la simulation", + "delete": "Impossible de supprimer la simulation", + "soc": "Impossible de mettre à jour les champs SOC", + "transition": "Transition échouée", + "load": "Impossible de charger la simulation" + }, + "loading": "Chargement de la simulation…" + }, + "list": { + "col": { + "name": "Nom", + "mitre": "MITRE", + "status": "Statut", + "executedAt": "Exécuté le" + }, + "empty": { + "title": "Aucune simulation", + "desc": "Créez votre première simulation pour cet engagement." + }, + "new": "Nouvelle simulation", + "error": "Impossible de charger les simulations" + } + }, + "template": { + "list": { + "title": "Templates", + "subtitle": "Modèles de simulation réutilisables.", + "new": "Nouveau", + "empty": { + "title": "Aucun template", + "desc": "Créez votre premier template de simulation." + }, + "col": { + "name": "Nom", + "mitre": "MITRE", + "createdBy": "Créé par", + "updated": "Modifié" + }, + "edit": "Modifier", + "delete": "Supprimer" + }, + "form": { + "title": { + "new": "Nouveau template", + "edit": "Modifier le template" + }, + "field": { + "name": "Nom", + "description": "Description", + "descriptionPlaceholder": "Description du template…", + "commands": "Commandes", + "commandsHint": "Une commande par ligne", + "commandsPlaceholder": "whoami\nnet user", + "prerequisites": "Prérequis", + "prerequisitesPlaceholder": "Prérequis…", + "mitre": "Techniques MITRE", + "mitreSearch": "Rechercher une technique…", + "mitreOpenMatrix": "Ouvrir la matrice", + "mitreEmpty": "Aucune technique associée." + }, + "btn": { + "save": "Enregistrer", + "saving": "Enregistrement…", + "delete": "Supprimer", + "cancel": "Annuler" + }, + "validation": { + "nameRequired": "Le nom est obligatoire" + }, + "toast": { + "created": "Template créé", + "saved": "Template enregistré", + "deleted": "Template supprimé" + }, + "deleteConfirm": { + "title": "Supprimer le template", + "desc": "Cette action est permanente.", + "confirm": "Supprimer", + "cancel": "Annuler" + }, + "loading": "Chargement du template…", + "errorLoad": "Impossible de charger le template", + "errorSave": "Impossible d'enregistrer le template", + "errorDelete": "Impossible de supprimer le template" + }, + "picker": { + "title": "Choisir un template", + "empty": "Aucun template disponible.", + "loading": "Chargement…" + } + }, + "user": { + "admin": { + "title": "Utilisateurs", + "subtitle": "Gestion des comptes.", + "new": "Nouveau", + "col": { + "username": "Nom d'utilisateur", + "role": "Rôle", + "createdAt": "Créé le", + "actions": "Actions" + }, + "role": { + "admin": "Admin", + "redteam": "Red Team", + "soc": "SOC" + }, + "field": { + "username": "Nom d'utilisateur", + "password": "Mot de passe" + }, + "btn": { + "create": "Créer", + "creating": "Création…", + "resetPassword": "Réinitialiser le mot de passe", + "setPassword": "Définir le mot de passe", + "delete": "Supprimer", + "cancel": "Annuler" + }, + "you": "(vous)", + "toast": { + "created": "Utilisateur créé", + "deleted": "Utilisateur supprimé", + "passwordReset": "Mot de passe réinitialisé" + }, + "error": { + "create": "Impossible de créer l'utilisateur", + "delete": "Impossible de supprimer l'utilisateur", + "passwordReset": "Impossible de réinitialiser le mot de passe" + }, + "deleteConfirm": "Supprimer l'utilisateur « {{username}} » ?", + "newPassword": "Nouveau mot de passe", + "empty": { + "title": "Aucun utilisateur", + "desc": "Créez le premier compte via le formulaire ci-dessus." + } + } + }, + "c2": { + "config": { + "title": "Configuration C2", + "disabled": "Les fonctionnalités C2 sont désactivées (aucune clé de chiffrement configurée sur le serveur).", + "field": { + "url": "URL", + "urlHint": "HTTPS requis (ex. https://mythic.lab:7443)", + "token": "Token API", + "tokenHint": "Token API", + "verifyTls": "Vérifier le certificat TLS", + "verifyTlsHint": "Décochez uniquement pour un Mythic de lab avec des certificats auto-signés. Désactiver la vérification expose le token API aux attaques MITM." + }, + "btn": { + "save": "Enregistrer", + "saving": "Enregistrement…", + "test": "Tester la connexion", + "testing": "Test…", + "delete": "Supprimer la configuration", + "replaceToken": "Remplacer le token" + }, + "toast": { + "saved": "Configuration C2 enregistrée", + "deleted": "Configuration C2 supprimée" + }, + "error": { + "save": "Impossible d'enregistrer la configuration C2", + "delete": "Impossible de supprimer la configuration C2" + }, + "connected": "Connecté", + "connectionFailed": "Connexion échouée", + "deleteConfirm": { + "title": "Supprimer la configuration C2", + "desc": "La configuration C2 de cet engagement sera supprimée. Le token API sera définitivement effacé.", + "confirm": "Supprimer", + "cancel": "Annuler" + }, + "loading": "Chargement…" + }, + "tasks": { + "title": "Tâches C2", + "empty": "Aucune tâche C2.", + "col": { + "task": "Tâche", + "command": "Commande", + "source": "Source", + "status": "Statut", + "completedAt": "Terminé le" + }, + "refreshing": "Actualisation…" + }, + "modal": { + "execute": { + "title": "Exécuter via C2", + "callback": "Agent cible", + "btn": { + "launch": "Lancer", + "launching": "Lancement…", + "cancel": "Annuler" + }, + "commandCount_one": "{{count}} commande", + "commandCount_other": "{{count}} commandes", + "validation": { + "callbackRequired": "Sélectionnez un agent", + "commandsRequired": "Aucune commande à exécuter" + }, + "toast": { + "launched": "Tâche C2 lancée" + }, + "error": { + "launch": "Impossible de lancer la tâche C2" + } + }, + "import": { + "title": "Importer l'historique C2", + "total": "Total", + "page": "Page", + "of": "sur", + "prev": "Précédent", + "next": "Suivant", + "btn": { + "import": "Importer", + "importing": "Importation…", + "cancel": "Annuler" + }, + "selected": "sélectionné(s)", + "empty": "Aucun historique C2 disponible.", + "toast": { + "imported": "Historique C2 importé", + "partial": "Import partiel" + }, + "error": { + "import": "Impossible d'importer l'historique C2" + } + }, + "picker": { + "title": "Sélectionner un agent", + "empty": "Aucun agent disponible.", + "hostnameColon": "Hôte :", + "lastCheckinColon": "Dernière connexion :" + } + } + }, + "mitre": { + "matrix": { + "title": "Matrice MITRE ATT&CK", + "filter": "Filtrer…", + "loading": "Chargement de la matrice…", + "error": "Impossible de charger la matrice MITRE", + "applyItem_one": "Appliquer ({{count}} élément)", + "applyItem_other": "Appliquer ({{count}} éléments)", + "clearAll": "Tout effacer", + "close": "Fermer", + "retry": "Réessayer" + }, + "field": { + "empty": "Aucune technique associée.", + "search": "Rechercher une technique…", + "openMatrix": "Ouvrir la matrice", + "savedToast": "Technique ajoutée", + "errorToast": "Impossible d'ajouter la technique" + }, + "tag": { + "remove": "Retirer" + } + }, + "state": { + "loading": "Chargement…", + "error": { + "title": "Erreur", + "desc": "Une erreur est survenue." + }, + "empty": { + "default": "Aucun résultat." + }, + "retry": "Réessayer" + }, + "toast": { + "dismiss": "Fermer" + }, + "status": { + "engagement": { + "planned": "Planifié", + "active": "Actif", + "closed": "Clôturé" + }, + "simulation": { + "pending": "En attente", + "in_progress": "En cours", + "review_required": "Révision requise", + "done": "Terminé" + } + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..7913b54 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,13 @@ +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; diff --git a/frontend/src/i18n/status.ts b/frontend/src/i18n/status.ts new file mode 100644 index 0000000..840a345 --- /dev/null +++ b/frontend/src/i18n/status.ts @@ -0,0 +1,8 @@ +import i18n from './index'; +import type { EngagementStatus, SimulationStatus } from '@/api/types'; + +export const engagementStatusLabel = (s: EngagementStatus): string => + i18n.t(`status.engagement.${s}`); + +export const simulationStatusLabel = (s: SimulationStatus): string => + i18n.t(`status.simulation.${s}`); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..e3a9df2 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,7 @@ +export const formatDate = (iso: string | null | undefined): string => { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('fr-FR'); +}; + +export const formatDateTime = (iso: string): string => + new Date(iso).toLocaleString('fr-FR'); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 838fc15..a2ec449 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; import { AuthProvider } from './hooks/useAuth'; import { ToastProvider } from './hooks/useToast'; +import './i18n'; import './styles/index.css'; const queryClient = new QueryClient({ diff --git a/frontend/tests/i18n.test.ts b/frontend/tests/i18n.test.ts new file mode 100644 index 0000000..8dbeade --- /dev/null +++ b/frontend/tests/i18n.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import i18n from '@/i18n/index'; +import { engagementStatusLabel, simulationStatusLabel } from '@/i18n/status'; +import { formatDate, formatDateTime } from '@/lib/format'; + +describe('i18n smoke tests', () => { + it('resolves common.save to French', () => { + expect(i18n.t('common.save')).toBe('Enregistrer'); + }); + + it('missing key returns the key path', () => { + expect(i18n.t('this.key.does.not.exist')).toBe('this.key.does.not.exist'); + }); + + it('simulationStatusLabel done → Terminé', () => { + expect(simulationStatusLabel('done')).toBe('Terminé'); + }); + + it('engagementStatusLabel active → Actif', () => { + expect(engagementStatusLabel('active')).toBe('Actif'); + }); + + it('interpolation inserts the value', () => { + expect(i18n.t('engagement.list.deleteConfirm', { name: 'Test' })).toContain('Test'); + }); +}); + +describe('format helpers', () => { + it('formatDate returns — for null', () => { + expect(formatDate(null)).toBe('—'); + }); + + it('formatDate returns — for undefined', () => { + expect(formatDate(undefined)).toBe('—'); + }); + + it('formatDate formats ISO date in fr-FR', () => { + const result = formatDate('2026-06-01'); + expect(result).toMatch(/\d{2}\/\d{2}\/\d{4}/); + }); + + it('formatDateTime returns a non-empty string for valid ISO', () => { + const result = formatDateTime('2026-06-01T08:00:00'); + expect(result.length).toBeGreaterThan(0); + }); +}); From fe597e9be322520b3e6331a3c295525ae0351026 Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:23:36 +0200 Subject: [PATCH 05/14] feat(frontend): i18n common + nav + auth (Layout, LoginPage, ProtectedRoute) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/Layout.tsx | 10 ++++++---- frontend/src/components/ProtectedRoute.tsx | 8 +++++--- frontend/src/pages/LoginPage.tsx | 12 +++++++----- frontend/vitest.setup.ts | 1 + 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2d14a51..aafacbe 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,6 @@ import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'; import { Moon, Sun, Monitor } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '@/hooks/useAuth'; import { useTheme } from '@/hooks/useTheme'; import type { Theme } from '@/hooks/useTheme'; @@ -20,6 +21,7 @@ export function Layout(): JSX.Element { const { user, isAdmin, isRedteam, logout } = useAuth(); const navigate = useNavigate(); const { theme, cycleTheme } = useTheme(); + const { t } = useTranslation(); const handleLogout = async () => { await logout(); @@ -52,7 +54,7 @@ export function Layout(): JSX.Element { onClick={handleLogout} className="text-[14px] underline-offset-2 hover:underline" > - Sign out + {t('nav.signOut')}
) : null} @@ -78,7 +80,7 @@ export function Layout(): JSX.Element { }` } > - Engagements + {t('nav.engagements')} {isAdmin || isRedteam ? ( - Templates + {t('nav.templates')} ) : null} {isAdmin ? ( @@ -105,7 +107,7 @@ export function Layout(): JSX.Element { }` } > - Users + {t('nav.users')} ) : null} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 97b3799..a109d65 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '@/hooks/useAuth'; import { useToast } from '@/hooks/useToast'; import type { Role } from '@/api/types'; @@ -23,6 +24,7 @@ export function ProtectedRoute({ }: ProtectedRouteProps): JSX.Element { const { user, status } = useAuth(); const { push } = useToast(); + const { t } = useTranslation(); const location = useLocation(); const roleDenied = Boolean( @@ -31,12 +33,12 @@ export function ProtectedRoute({ useEffect(() => { if (roleDenied) { - push('Accès refusé', 'error'); + push(t('auth.forbidden'), 'error'); } - }, [roleDenied, push]); + }, [roleDenied, push, t]); if (status === 'loading') { - return ; + return ; } if (status === 'unauthenticated' || !user) { diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 49daf16..93fcd71 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,5 +1,6 @@ import { useState, type FormEvent } from 'react'; import { Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import { useAuth } from '@/hooks/useAuth'; import { FormField, TextInput } from '@/components/FormField'; @@ -14,6 +15,7 @@ export function LoginPage(): JSX.Element { const location = useLocation(); const fromPath = (location.state as LocationState | null)?.from; + const { t } = useTranslation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -34,7 +36,7 @@ export function LoginPage(): JSX.Element { await login(username, password); navigate(fromPath ?? '/engagements', { replace: true }); } catch (err) { - setError(extractApiError(err, 'Invalid credentials')); + setError(extractApiError(err, t('auth.login.invalid'))); } finally { setSubmitting(false); } @@ -48,10 +50,10 @@ export function LoginPage(): JSX.Element {

Mimic

-

Sign in to access your engagements.

+

{t('auth.login.subtitle')}

- + - + - {submitting ? 'Signing in…' : 'Sign in'} + {submitting ? t('auth.login.signingIn') : t('auth.login.signIn')} diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index bb02c60..4ad2b0d 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -1 +1,2 @@ import '@testing-library/jest-dom/vitest'; +import './src/i18n'; From ea870af324b8646f4e6635eb6847a6e7aca3ff90 Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:26:22 +0200 Subject: [PATCH 06/14] feat(frontend): i18n engagement pages (list, detail, form) + fr-FR date formatting Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/EngagementDetailPage.tsx | 34 +++++----- frontend/src/pages/EngagementFormPage.tsx | 73 +++++++++++---------- frontend/src/pages/EngagementsListPage.tsx | 48 +++++++------- frontend/tests/EngagementFormPage.test.tsx | 4 +- 4 files changed, 83 insertions(+), 76 deletions(-) diff --git a/frontend/src/pages/EngagementDetailPage.tsx b/frontend/src/pages/EngagementDetailPage.tsx index f0ff78e..d8216d4 100644 --- a/frontend/src/pages/EngagementDetailPage.tsx +++ b/frontend/src/pages/EngagementDetailPage.tsx @@ -1,4 +1,5 @@ import { useParams, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import { useAuth } from '@/hooks/useAuth'; import { useEngagement } from '@/hooks/useEngagements'; @@ -11,10 +12,12 @@ import { SimulationList } from '@/components/SimulationList'; import { ExportEngagementButton } from '@/components/ExportEngagementButton'; import { BackLink } from '@/components/BackLink'; import { Tabs } from '@/components/Tabs'; +import { formatDateTime } from '@/lib/format'; type TabId = 'description' | 'simulations'; export function EngagementDetailPage(): JSX.Element { + const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const numericId = id ? Number(id) : undefined; const { canEditEngagements } = useAuth(); @@ -25,35 +28,36 @@ export function EngagementDetailPage(): JSX.Element { const [activeTabRaw, setActiveTab] = useHashTab('description'); const activeTab = activeTabRaw as TabId; - if (detail.isLoading) return ; + if (detail.isLoading) return ; if (detail.isError) { return ( detail.refetch()} /> ); } - if (!detail.data) return ; + if (!detail.data) return ; const eng = detail.data; const simCount = simsQuery.data?.length; const tabs = [ - { id: 'description', label: 'Description' }, - { id: 'simulations', label: 'Simulations', count: simCount }, + { id: 'description', label: t('engagement.detail.tabs.description') }, + { id: 'simulations', label: t('engagement.detail.tabs.simulations'), count: simCount }, ]; return (
- Back to engagements + {t('engagement.detail.backTo')}

{eng.name}

- Created by {eng.created_by.username} + {t('engagement.detail.createdBy')}{' '} + {eng.created_by.username}
@@ -73,25 +77,25 @@ export function EngagementDetailPage(): JSX.Element {
-
Start date
+
{t('engagement.detail.schedule.startDate')}
{eng.start_date}
-
End date
+
{t('engagement.detail.schedule.endDate')}
{eng.end_date ?? '—'}
-
Status
+
{t('engagement.detail.schedule.status')}
-
Created at
-
{eng.created_at}
+
{t('engagement.detail.schedule.createdAt')}
+
{formatDateTime(eng.created_at)}
{canEditEngagements ? ( - Edit + {t('engagement.detail.edit')} ) : null}

-

Description

+

{t('engagement.detail.tabs.description')}

- {eng.description?.trim() ? eng.description : 'No description provided.'} + {eng.description?.trim() ? eng.description : t('engagement.detail.noDescription')}

)} diff --git a/frontend/src/pages/EngagementFormPage.tsx b/frontend/src/pages/EngagementFormPage.tsx index 3047437..b99c340 100644 --- a/frontend/src/pages/EngagementFormPage.tsx +++ b/frontend/src/pages/EngagementFormPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, type FormEvent } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import type { EngagementInput, EngagementStatus } from '@/api/types'; import { @@ -13,12 +14,7 @@ import { FormField, Select, TextArea, TextInput } from '@/components/FormField'; import { LoadingState } from '@/components/LoadingState'; import { ErrorState } from '@/components/ErrorState'; import { C2ConfigCard } from '@/components/C2ConfigCard'; - -const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [ - { value: 'planned', label: 'Planned' }, - { value: 'active', label: 'Active' }, - { value: 'closed', label: 'Closed' }, -]; +import { engagementStatusLabel } from '@/i18n/status'; interface FormState { name: string; @@ -36,17 +32,8 @@ const EMPTY: FormState = { status: 'planned', }; -function validate(state: FormState): Partial> { - const errors: Partial> = {}; - if (!state.name.trim()) errors.name = 'Name is required'; - if (!state.start_date) errors.start_date = 'Start date is required'; - if (state.end_date && state.start_date && state.end_date < state.start_date) { - errors.end_date = 'End date must be on or after start date'; - } - return errors; -} - export function EngagementFormPage(): JSX.Element { + const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const editing = Boolean(id); const numericId = id ? Number(id) : undefined; @@ -62,6 +49,22 @@ export function EngagementFormPage(): JSX.Element { const [errors, setErrors] = useState>>({}); const [submitError, setSubmitError] = useState(null); + const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [ + { value: 'planned', label: engagementStatusLabel('planned') }, + { value: 'active', label: engagementStatusLabel('active') }, + { value: 'closed', label: engagementStatusLabel('closed') }, + ]; + + function validate(state: FormState): Partial> { + const errs: Partial> = {}; + if (!state.name.trim()) errs.name = t('engagement.form.validation.nameRequired'); + if (!state.start_date) errs.start_date = t('engagement.form.validation.startDateRequired'); + if (state.end_date && state.start_date && state.end_date < state.start_date) { + errs.end_date = t('engagement.form.validation.endDateAfterStart'); + } + return errs; + } + // Hydrate edit form when data arrives. useEffect(() => { if (editing && detail.data) { @@ -75,11 +78,11 @@ export function EngagementFormPage(): JSX.Element { } }, [editing, detail.data]); - if (editing && detail.isLoading) return ; + if (editing && detail.isLoading) return ; if (editing && detail.isError) { return ( detail.refetch()} /> ); @@ -109,15 +112,15 @@ export function EngagementFormPage(): JSX.Element { try { if (editing && numericId) { await patchMutation.mutateAsync(payload); - push('Engagement updated', 'success'); + push(t('engagement.form.toast.updated'), 'success'); navigate(`/engagements/${numericId}`); } else { const created = await createMutation.mutateAsync(payload); - push('Engagement created', 'success'); + push(t('engagement.form.toast.created'), 'success'); navigate(`/engagements/${created.id}`); } } catch (err) { - setSubmitError(extractApiError(err, 'Could not save engagement')); + setSubmitError(extractApiError(err, t('engagement.form.error.save'))); } }; @@ -127,12 +130,10 @@ export function EngagementFormPage(): JSX.Element {

- {editing ? 'Edit engagement' : 'New engagement'} + {editing ? t('engagement.form.title.edit') : t('engagement.form.title.new')}

- {editing - ? 'Update the engagement metadata.' - : 'Create a new red team mission to host simulations.'} + {editing ? t('engagement.form.subtitle.edit') : t('engagement.form.subtitle.new')}

@@ -144,7 +145,7 @@ export function EngagementFormPage(): JSX.Element { } >
- + - +