Files
mimic/tasks/todo.md

14 KiB
Raw Blame History

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) :

type TabId = 'schedule' | 'description' | 'simulations';
const items: TabItem<TabId>[] = [
  { id: 'schedule',    label: 'Schedule' },
  { id: 'description', label: 'Description' },
  { id: 'simulations', label: 'Simulations', count },
];

Target (2 tabs) :

type TabId = 'description' | 'simulations';
const items: TabItem<TabId>[] = [
  { 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) :

<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

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)

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

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)

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)

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 test236+ / 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