diff --git a/CHANGELOG.md b/CHANGELOG.md index d27197d..88fa1ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n) + +**Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests) + +- `frontend/src/i18n/fr.json` — NEW. ~160-key nested translation file (French). Covers: common, nav, auth, engagement, simulation, template, user.admin, c2, mitre, state, toast, status. +- `frontend/src/i18n/index.ts` — NEW. i18next init (lng: fr, no fallback to en, escapeValue: false). +- `frontend/src/i18n/status.ts` — NEW. `engagementStatusLabel()` / `simulationStatusLabel()` helpers mapping API status to translated strings. +- `frontend/src/lib/format.ts` — NEW. `formatDate()` / `formatDateTime()` using `fr-FR` locale. +- `frontend/src/main.tsx` — Added `import './i18n'` bootstrap. +- `frontend/vitest.setup.ts` — Added `import './src/i18n'` so all tests use real French translations. +- `frontend/src/pages/EngagementDetailPage.tsx` — Merged 3-tab layout → 2 tabs (Description + Simulations); full i18n pass. +- `frontend/src/pages/EngagementsListPage.tsx` — Full i18n pass; local `formatDate` removed, `@/lib/format` used. +- `frontend/src/pages/EngagementFormPage.tsx` — Full i18n pass; `validate()` inside component; `data-testid="btn-submit"` added. +- `frontend/src/pages/SimulationFormPage.tsx` — Full i18n pass; `data-testid` on 4 action buttons. +- `frontend/src/pages/TemplatesListPage.tsx` — Full i18n pass. +- `frontend/src/pages/TemplateFormPage.tsx` — Full i18n pass. +- `frontend/src/pages/UsersAdminPage.tsx` — Full i18n pass; `data-testid="new-role-select"` added. +- `frontend/src/components/Layout.tsx` — Nav labels translated. +- `frontend/src/components/LoginPage.tsx` — Form labels and error message translated. +- `frontend/src/components/ProtectedRoute.tsx` — Forbidden/loading messages translated. +- `frontend/src/components/SimulationList.tsx` — Full i18n pass. +- `frontend/src/components/TemplatePickerModal.tsx` — Full i18n pass. +- `frontend/src/components/MitreTechniqueTag.tsx` — `aria-label` translated (Retirer TX…). +- `frontend/src/components/MitreTechniquesField.tsx` — Full i18n pass. +- `frontend/src/components/MitreMatrixModal.tsx` — Full i18n pass; plural apply button. +- `frontend/src/components/MitreTechniquePicker.tsx` — Placeholder and messages translated. +- `frontend/src/components/C2ConfigCard.tsx` — Full i18n pass. +- `frontend/src/components/C2TasksPanel.tsx` — Full i18n pass. +- `frontend/src/components/ExecuteViaC2Modal.tsx` — Full i18n pass. +- `frontend/src/components/ImportC2HistoryModal.tsx` — Full i18n pass. +- `frontend/src/components/C2CallbackPicker.tsx` — Full i18n pass. +- `frontend/src/components/Toast.tsx` — Dismiss aria-label translated. +- `frontend/src/components/ErrorState.tsx` — Title default and Retry button translated. +- All test files updated to use French strings or `data-testid` where button labels changed. + +**No backend changes. No DB schema change. No migration.** + ### Added — Sprint 11 (Spectrum UX port: 4 primitives + compact density global) **Frontend only** (233 vitest passing — baseline 212 + 21 new tests across 4 new specs) 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": { diff --git a/frontend/src/components/C2CallbackPicker.tsx b/frontend/src/components/C2CallbackPicker.tsx index 7c760c0..ac4f683 100644 --- a/frontend/src/components/C2CallbackPicker.tsx +++ b/frontend/src/components/C2CallbackPicker.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import type { C2Callback } from '@/api/types'; @@ -20,20 +21,22 @@ export function C2CallbackPicker({ onSelect, rowTestId = 'c2-callback-row', }: C2CallbackPickerProps): JSX.Element { + const { t } = useTranslation(); + if (isLoading) { - return

Loading callbacks…

; + return

{t('state.loading')}

; } if (isError) { return (

- Could not load callbacks: {extractApiError(error, 'Unknown error')} + {extractApiError(error, t('state.error.desc'))}

); } if (callbacks.length === 0) { - return

No callbacks available.

; + return

{t('c2.modal.picker.empty')}

; } return ( @@ -41,12 +44,12 @@ export function C2CallbackPicker({ - - - - - - + + + + + + @@ -78,7 +81,7 @@ export function C2CallbackPicker({ : 'bg-cloud text-graphite border border-hairline' }`} > - {cb.active ? 'Active' : 'Inactive'} + {cb.active ? t('c2.modal.picker.status.active') : t('c2.modal.picker.status.inactive')} diff --git a/frontend/src/components/C2ConfigCard.tsx b/frontend/src/components/C2ConfigCard.tsx index 7dc2955..5e0cf1f 100644 --- a/frontend/src/components/C2ConfigCard.tsx +++ b/frontend/src/components/C2ConfigCard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2'; import { ConfirmDialog } from './ConfirmDialog'; @@ -10,6 +11,7 @@ interface C2ConfigCardProps { } export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { + const { t } = useTranslation(); const { push } = useToast(); const configQuery = useC2Config(engagementId); @@ -55,11 +57,11 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { try { await updateMutation.mutateAsync(input); - push('C2 configuration saved', 'success'); + push(t('c2.config.toast.saved'), 'success'); setToken(''); setReplaceToken(false); } catch (err) { - push(extractApiError(err, 'Could not save C2 configuration'), 'error'); + push(extractApiError(err, t('c2.config.error.save')), 'error'); } }; @@ -68,13 +70,13 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { setTestResult(null); try { await deleteMutation.mutateAsync(); - push('C2 configuration removed', 'success'); + push(t('c2.config.toast.deleted'), 'success'); setUrl(''); setToken(''); setVerifyTls(false); setReplaceToken(false); } catch (err) { - push(extractApiError(err, 'Could not remove C2 configuration'), 'error'); + push(extractApiError(err, t('c2.config.error.delete')), 'error'); } }; @@ -84,10 +86,10 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { const result = await testMutation.mutateAsync(); setTestResult({ ok: result.ok, - message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'), + message: result.ok ? t('c2.config.connected') : (result.error ?? t('c2.config.connectionFailed')), }); } catch (err) { - setTestResult({ ok: false, message: extractApiError(err, 'Test failed') }); + setTestResult({ ok: false, message: extractApiError(err, t('c2.config.connectionFailed')) }); } }; @@ -98,25 +100,25 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { data-testid="c2-config-card" className="card-product flex flex-col gap-md" > -

C2 configuration

+

{t('c2.config.title')}

{is503 && (
- C2 features are disabled (server has no encryption key configured). + {t('c2.config.disabled')}
)} {configQuery.isLoading ? ( -

Loading…

+

{t('c2.config.loading')}

) : ( - + {config?.has_token && !replaceToken ? (
setReplaceToken(true)} disabled={disabled} > - Replace token + {t('c2.config.btn.replaceToken')}
) : ( @@ -158,7 +160,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { data-testid="c2-token-input" type="password" name="api_token" - placeholder={config?.has_token ? 'Enter new token' : 'API token'} + placeholder={config?.has_token ? t('c2.config.field.tokenHint') : t('c2.config.field.token')} value={token} onChange={(e) => setToken(e.target.value)} disabled={disabled} @@ -178,12 +180,11 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { className="h-4 w-4 accent-primary" />

- Uncheck only for lab Mythic with self-signed certificates. Disabling - verification exposes the API token to MITM. + {t('c2.config.field.verifyTlsHint')}

@@ -194,7 +195,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { className="btn-primary" disabled={disabled || submitting} > - {updateMutation.isPending ? 'Saving…' : 'Save'} + {updateMutation.isPending ? t('c2.config.btn.saving') : t('c2.config.btn.save')} {testResult !== null && ( @@ -223,7 +224,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { onClick={() => setShowDeleteConfirm(true)} disabled={disabled || submitting} > - Delete configuration + {t('c2.config.btn.delete')} )} @@ -232,10 +233,10 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { {showDeleteConfirm && ( setShowDeleteConfirm(false)} diff --git a/frontend/src/components/C2TasksPanel.tsx b/frontend/src/components/C2TasksPanel.tsx index 138729a..a516a0d 100644 --- a/frontend/src/components/C2TasksPanel.tsx +++ b/frontend/src/components/C2TasksPanel.tsx @@ -1,5 +1,6 @@ import { Fragment, useState } from 'react'; import { ChevronRight, ChevronDown } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { useC2Tasks } from '@/hooks/useC2'; import { C2TaskStatusBadge } from './C2TaskStatusBadge'; @@ -8,6 +9,7 @@ interface C2TasksPanelProps { } export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element { + const { t } = useTranslation(); const query = useC2Tasks(simulationId, { enabled: true }); const [expandedIds, setExpandedIds] = useState>(new Set()); @@ -33,13 +35,13 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element { className="card-product flex flex-col gap-md" >
-

C2 Tasks

+

{t('c2.tasks.title')}

{isRefreshing && ( - Refreshing… + {t('c2.tasks.refreshing')} )}
@@ -47,7 +49,7 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element { {tasks.length === 0 ? (

- No C2 tasks yet. Use Execute via C2 to launch commands. + {t('c2.tasks.empty')}

) : ( @@ -55,12 +57,12 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
Display IDActiveHostUserDomainLast check-in{t('c2.modal.picker.col.displayId')}{t('c2.modal.picker.col.active')}{t('c2.modal.picker.col.host')}{t('c2.modal.picker.col.user')}{t('c2.modal.picker.col.domain')}{t('c2.modal.picker.col.lastCheckin')}
{cb.host}
- - - - - + + + + + diff --git a/frontend/src/components/ErrorState.tsx b/frontend/src/components/ErrorState.tsx index 9bf984b..4b5a612 100644 --- a/frontend/src/components/ErrorState.tsx +++ b/frontend/src/components/ErrorState.tsx @@ -1,21 +1,25 @@ +import { useTranslation } from 'react-i18next'; + interface ErrorStateProps { title?: string; message: string; onRetry?: () => void; } -export function ErrorState({ title = 'Something went wrong', message, onRetry }: ErrorStateProps): JSX.Element { +export function ErrorState({ title, message, onRetry }: ErrorStateProps): JSX.Element { + const { t } = useTranslation(); + const resolvedTitle = title ?? t('state.error.title'); return (
-

{title}

+

{resolvedTitle}

{message}

{onRetry ? ( ) : null}
diff --git a/frontend/src/components/ExecuteViaC2Modal.tsx b/frontend/src/components/ExecuteViaC2Modal.tsx index 5eee92b..fdae5f1 100644 --- a/frontend/src/components/ExecuteViaC2Modal.tsx +++ b/frontend/src/components/ExecuteViaC2Modal.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/api/client'; import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2'; import { C2CallbackPicker } from './C2CallbackPicker'; @@ -17,6 +18,7 @@ export function ExecuteViaC2Modal({ initialCommands, onClose, }: ExecuteViaC2ModalProps): JSX.Element { + const { t } = useTranslation(); const { push } = useToast(); const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); @@ -43,10 +45,10 @@ export function ExecuteViaC2Modal({ callback_display_id: selectedId, commands: commandLines, }); - push(`${result.tasks.length} task(s) submitted`, 'success'); + push(t('c2.modal.execute.toast.launched', { count: result.tasks.length }), 'success'); onClose(); } catch (err) { - setSubmitError(extractApiError(err, 'Could not execute via C2')); + setSubmitError(extractApiError(err, t('c2.modal.execute.error.launch'))); } }; @@ -62,12 +64,12 @@ export function ExecuteViaC2Modal({

- Execute via C2 + {t('c2.modal.execute.title')}

{/* Callback picker */}
- Select callback + {t('c2.modal.execute.callback')}
- TaskCommandSourceStatusCompleted at + {t('c2.tasks.col.task')}{t('c2.tasks.col.command')}{t('c2.tasks.col.source')}{t('c2.tasks.col.status')}{t('c2.tasks.col.completedAt')}