feat(frontend): full FR i18n via react-i18next + 2-tab engagement detail (sprint 12) #14

Merged
knacky merged 14 commits from sprint/12-i18n-fr into main 2026-06-22 08:25:51 +00:00
6 changed files with 553 additions and 0 deletions
Showing only changes of commit 3723bd009b - Show all commits

478
frontend/src/i18n/fr.json Normal file
View File

@@ -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é"
}
}
}

View File

@@ -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;

View File

@@ -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}`);

View File

@@ -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');

View File

@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom';
import { App } from './App'; import { App } from './App';
import { AuthProvider } from './hooks/useAuth'; import { AuthProvider } from './hooks/useAuth';
import { ToastProvider } from './hooks/useToast'; import { ToastProvider } from './hooks/useToast';
import './i18n';
import './styles/index.css'; import './styles/index.css';
const queryClient = new QueryClient({ const queryClient = new QueryClient({

View File

@@ -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);
});
});