From 3723bd009b5248ec934dd43118a7d41cff61548a Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 23:21:02 +0200 Subject: [PATCH] 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); + }); +});