Compare commits

28 Commits

Author SHA1 Message Date
Knacky
578a7c3d1a fix(frontend): SOC C2 tab RBAC gate + safeTab sync + disabled tab contrast (review polish)
- c2TabDisabled: add isSoc condition — SOC role has no access to /c2/tasks (backend 403)
- safeTab sync: useEffect before early returns using detail.data?.status; syncs
  window.location.hash when hash points to a now-disabled tab after status transition
- create-mode submit button: type="submit" form="" → type="button" (form association fix)
- .tab-underline-disabled: text-graphite opacity-50 → text-steel (consistent with
  btn-outline:disabled; better perceptual contrast)
- tests: 254 passing (+ SOC × C2 tab disabled assertion)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:59:54 +02:00
Knacky
0378792dc4 feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / Tâche C2)
- Tabs: disabled per-item (aria-disabled, native disabled, tab-underline-disabled,
  arrow-key skip); aria-controls wired
- useHashTab: optional validIds fallback to defaultId
- SimulationFormPage: 3-tab refactor; SOC tab gated on review_required|done;
  C2 tab disabled in create mode; contextual sticky bar hidden on C2 tab;
  safeTab guard against stale hash; SOC-blocked banner removed
- fr.json: simulation.form.tab.{redTeam,soc,c2} keys
- DESIGN.md: disabled tab variant + aria-controls documented
- Tests: 253 passing (5 new Tabs disabled tests, SimulationFormPage rewritten
  for tab layout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:48:18 +02:00
Knacky
9b6b682acc docs(sprint-13): plan SimulationFormPage 3-tab layout (RT/SOC/C2) 2026-06-22 10:24:36 +02:00
Knacky
2ff097b648 fix(frontend): i18n reactive badges + aria-labels + ExportEngagementButton + noMatch (review polish)
- ExportEngagementButton: full i18n (export.btn, export.btnLoading, export.aria)
- Layout: aria-label nav.brandHome on brand link, nav.themeAria on theme button
- SimulationList: aria-label simulation.list.rowActions on chevron button
- C2TasksPanel: aria-label c2.tasks.col.expand on expand column header
- ImportC2HistoryModal: aria-label common.select on select column header
- MitreMatrixModal: common.collapse/expand interpolated aria-label + mitre.matrix.noMatch
- fr.json: new keys common.expand/collapse/select, nav.brandHome/themeAria, export.*, simulation.list.rowActions, mitre.matrix.noMatch, c2.tasks.col.expand

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 00:23:03 +02:00
Knacky
ac0bc8d1b2 fix(frontend): i18n reactive badges + fr-FR dates on detail/admin + FR footer (design-review)
- StatusBadge/SimulationStatusBadge: swap bare i18n.t() helper for
  useTranslation() hook so badges re-render reactively on i18n init
- EngagementDetailPage: formatDate() on start_date/end_date/created_at
  (was: raw ISO string, formatDateTime with noisy 00:00:00)
- UsersAdminPage: formatDate() on created_at column (was: raw ISO string)
- Layout footer: t('nav.footer') with FR string in fr.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 00:18:05 +02:00
Knacky
6648d1cc2e fix(frontend): translate status badge labels via i18n helpers
StatusBadge and SimulationStatusBadge now call engagementStatusLabel() /
simulationStatusLabel() from @/i18n/status.ts instead of hardcoded English
strings. Tests updated to expect French labels (Planifié/Actif/Clôturé,
En attente/En cours/Révision requise/Terminé).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 00:08:45 +02:00
Knacky
2931e4aaf9 feat(frontend): i18n shared state components + UsersAdminPage + CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:56:47 +02:00
Knacky
ad0a3f5cac feat(frontend): i18n C2 components (config card, tasks panel, execute/import modals, picker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:51:12 +02:00
Knacky
bdda02b07a feat(frontend): i18n MITRE components (matrix modal, picker, field, tag)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:41:32 +02:00
Knacky
284494cee8 feat(frontend): i18n template pages (list, form, picker modal)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:36:20 +02:00
Knacky
5b93f880a3 feat(frontend): i18n simulation pages (SimulationFormPage, SimulationList)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:31:21 +02:00
Knacky
ea870af324 feat(frontend): i18n engagement pages (list, detail, form) + fr-FR date formatting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:26:22 +02:00
Knacky
fe597e9be3 feat(frontend): i18n common + nav + auth (Layout, LoginPage, ProtectedRoute)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:23:36 +02:00
Knacky
3723bd009b feat(frontend): scaffold i18n init, fr.json (~140 keys), status map, format helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:21:02 +02:00
Knacky
51fb2e4f95 chore(frontend): install react-i18next + i18next deps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:16:13 +02:00
Knacky
3cdfba636e 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 <hr>. Default tab
changed from 'schedule' to 'description'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:16:00 +02:00
Knacky
10d4293f44 docs(sprint-12): plan i18n FR (react-i18next) + EngagementDetail tab merge 2026-06-21 23:12:57 +02:00
Knacky
2c7fcec7cd fix(frontend): add text- prefix to caption-bold and caption-md @apply (sprint 11 build fix)
caption-bold and caption-md are fontSize tokens — Tailwind generates
text-caption-bold / text-caption-md utilities, not bare-name utilities.
Missing text- prefix caused @apply resolution failure in vite build
(PostCSS step), while vitest passed because it does not resolve @apply.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:38:01 +02:00
Knacky
11ce3cfb86 fix(frontend): code-review polish — replaceState, arrow-key nav, TabId, comment
Fix 4: useHashTab navigate() uses history.replaceState instead of
window.location.hash assignment — no spurious history entries, no
anchor-jump scroll side-effect.
Fix 5: Tabs ArrowLeft/ArrowRight keyboard nav (WAI-ARIA tabs pattern).
Fix 6: TabId union type in EngagementDetailPage, cast from string for
type-safe switch on activeTab without breaking Tabs.onChange signature.
Fix 7: intentional comment on UsersAdminPage reset-password aerated row.
Tests: 236/236 (+2 arrow-key assertions in Tabs.test.tsx).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:20:51 +02:00
Knacky
790ced4204 fix(frontend): design-review polish — table-compact height, header type-scale, tabs a11y
Fix 1 (A): move row height constraint from min-height on tbody tr
(CSS no-op on table-row) to height: 32px on td (works on table-cell).
Fix 2 (A): table header text-[11px] -> text-[12px] to align with
documented caption scale.
Fix 3: add aria-controls + id to Tabs buttons; wrap active tab content
in role="tabpanel" + aria-labelledby in EngagementDetailPage.
Test: add aria-controls/id assertion to Tabs.test.tsx (234 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:18:09 +02:00
Knacky
1324a0c8ce docs(design): add tabs / inline banners / compact-table-density to DESIGN.md
Adds:
- Navigation > Sub-page tabs subsection (tab-underline recipes, count pill, ARIA)
- Data Tables: row min-height 44px -> 32px + WCAG SC 2.5.5 rationale
- Toast Notifications > Inline Banners subsection (4 variants table)
Also adds Sprint 11 entry to CHANGELOG.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:38 +02:00
Knacky
5d34a5e171 test(frontend): add specs for Tabs, AlertBanner, BackLink, useHashTab
4 new spec files adding 21 tests (7+6+4+4). Each component spec
includes brutalism invariant assertions (no rounded-md, no transition-*,
no shadow-*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:31 +02:00
Knacky
1819089925 feat(frontend): compact table density (32px row) global
Add table-compact class to all 4 list surfaces: EngagementsListPage,
TemplatesListPage, UsersAdminPage, SimulationList. Remove inline
px-xl py-md from th/td — recipe handles padding. Row border-b moved
to recipe. WCAG SC 2.5.5 tradeoff accepted (BAS operator tool).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:24 +02:00
Knacky
9a8c5f52ab feat(frontend): add BackLink helper + dedup 3 hand-rolled instances
BackLink: ArrowLeft (14px) + caption-md text-graphite hover:text-primary,
instant. Replaces inline Link patterns in EngagementDetailPage (already
committed), SimulationFormPage, and TemplateFormPage. Also migrates
SimulationFormPage Done/SOC banners to AlertBanner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:17 +02:00
Knacky
59eaa342a9 feat(frontend): add AlertBanner component + 4 semantic variants
border-l-4 semantic strip, bg-paper, Lucide icons at size=16.
ARIA role="alert" for error/warn, role="status" for success/info.
No shadow, no radius, no transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:10 +02:00
Knacky
004d075cad feat(frontend): refactor EngagementDetailPage to 3-tab layout
Tabs: Schedule (default) / Description / Simulations, wired via
useHashTab('schedule'). Count pill on Simulations tab. BackLink
replaces hand-rolled Link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:11:04 +02:00
Knacky
bca39dcca7 feat(frontend): add Tabs primitive + useHashTab hook
- useHashTab: pure-TS hook reading window.location.hash, falls back to
  defaultId, hashchange listener cleaned up on unmount
- Tabs: <Tabs items activeId onChange> with tab-underline / tab-underline-active
  recipes and count-pill (rounded-pill exception per DESIGN.md)
- index.css: tab-underline*, tab-count-pill*, alert-*, table-compact recipes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:10:59 +02:00
Knacky
89fb38b107 docs(sprint-11): plan spectrum UX port — 4 primitives + compact density global 2026-06-21 22:03:15 +02:00
66 changed files with 2543 additions and 1035 deletions

View File

@@ -6,6 +6,82 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
### Changed — Sprint 13 (SimulationFormPage 3-tab layout)
**Frontend only** (253 vitest passing)
- `frontend/src/components/Tabs.tsx` — Added `disabled?: boolean` per-item: `aria-disabled`, `aria-controls`, native `disabled`, `.tab-underline-disabled` CSS, arrow-key skip logic.
- `frontend/src/styles/index.css` — New `.tab-underline-disabled` recipe (`text-graphite opacity-50 cursor-not-allowed`).
- `frontend/src/hooks/useHashTab.ts` — Added optional `validIds?: string[]` for hash fallback to defaultId when hash not in valid set.
- `frontend/src/pages/SimulationFormPage.tsx` — Full 3-tab refactor: Red Team / SOC / Tâche C2. RT fields in RT tab, SOC fields in SOC tab, `C2TasksPanel` in C2 tab. SOC tab disabled when `status ∉ {review_required, done}`. C2 tab disabled in create mode. Contextual sticky bar hides entirely on C2 tab. SOC-blocked `AlertBanner` removed; tab disabled state replaces it. `safeTab` guards against stale hash from previous navigation.
- `frontend/src/i18n/fr.json` — Added `simulation.form.tab.{redTeam,soc,c2}` keys.
- `DESIGN.md` — Documented disabled tab variant and `aria-controls` contract.
- `frontend/tests/SimulationFormPage.test.tsx` — Rewritten for tab-based layout: SOC-blocked-banner tests removed; tab disabled-state tests, SOC-only field tests, C2 tab panel test, count pill test added.
- `frontend/tests/components/Tabs.test.tsx` — Added 5 disabled-state tests.
### 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)
- `frontend/src/hooks/useHashTab.ts` — NEW. Pure-TS hook that reads `window.location.hash`, falls back to a default tab id, and listens to `hashchange` (cleanup on unmount). No style dependency.
- `frontend/src/components/Tabs.tsx` — NEW. `<Tabs items activeId onChange>` component. Underline variant: `tab-underline` / `tab-underline-active` CSS recipes. Count pill: `tab-count-pill` / `tab-count-pill-active` (`rounded-pill` exception per DESIGN.md). `role="tablist"` + `aria-selected` on tabs. Zero transitions.
- `frontend/src/components/AlertBanner.tsx` — NEW. `<AlertBanner variant="error|warn|success|info" title? children>`. Border-l-4 semantic strip, `bg-paper`, Lucide icon at `size={16}`. ARIA: `role="alert"` for error/warn, `role="status"` for success/info. No shadow, no radius, no transition.
- `frontend/src/components/BackLink.tsx` — NEW. `<BackLink to children>` renders `ArrowLeft` (Lucide, 14px) + text with `caption-md text-graphite hover:text-primary`. Replaces 3 hand-rolled back-link instances.
- `frontend/src/styles/index.css` — Added recipes: `.tab-underline`, `.tab-underline-active`, `.tab-count-pill`, `.tab-count-pill-active`, `.alert-error`, `.alert-warn`, `.alert-success`, `.alert-info`, `.table-compact` (32px row density, global).
- `frontend/src/pages/EngagementDetailPage.tsx` — Refactored to 3-tab layout (Schedule / Description / Simulations) wired via `useHashTab('schedule')`. Count pill on Simulations tab. BackLink replaces hand-rolled link.
- `frontend/src/pages/SimulationFormPage.tsx` — Done banner and SOC-blocked banner migrated to `<AlertBanner>`. Both back-link instances replaced with `<BackLink>`.
- `frontend/src/pages/TemplateFormPage.tsx` — Back-link replaced with `<BackLink>`.
- `frontend/src/pages/EngagementsListPage.tsx` — Table marked `table-compact`, inline `px-xl py-md` padding removed from th/td (recipe handles it).
- `frontend/src/pages/TemplatesListPage.tsx` — Same compact table treatment.
- `frontend/src/pages/UsersAdminPage.tsx` — Same compact table treatment.
- `frontend/src/components/SimulationList.tsx` — Same compact table treatment.
- `DESIGN.md` — Added `### Navigation Sub-page tabs` subsection; updated `### Data Tables` row min-height 44px → 32px with WCAG SC 2.5.5 rationale; added `### Inline Banners` subsection under `### Toast Notifications`.
**Tests added** (21 new assertions across 4 new spec files):
- `tests/hooks/useHashTab.test.tsx` — 4 tests (default, hash read on mount, navigate(), empty-hash fallback)
- `tests/components/Tabs.test.tsx` — 7 tests (render, aria-selected, active class, count pill, count-pill-active, onChange, brutalism)
- `tests/components/AlertBanner.test.tsx` — 6 tests (4 variants × role + class, title prop, brutalism)
- `tests/components/BackLink.test.tsx` — 4 tests (render, href, brutalism, svg icon)
**No backend changes.** No DB schema change. No migration.
### Changed — Sprint 7 (Terminal-SOC design refresh) ### Changed — Sprint 7 (Terminal-SOC design refresh)
**Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names) **Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names)

View File

@@ -216,11 +216,24 @@ Shadows (`box-shadow`) are not used anywhere.
- Text `{colors.slab-text}`, caption-md, `rounded-none` - Text `{colors.slab-text}`, caption-md, `rounded-none`
- Active state: 2px primary bottom border - Active state: 2px primary bottom border
#### Sub-page tabs
Used inside pages that need content partitioning without a URL change (e.g. EngagementDetailPage: Schedule / Description / Simulations).
- **Container**: `flex items-end border-b border-hairline gap-xs` — sits on the `{colors.hairline}` bottom line.
- **`.tab-underline`**: `text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs`. Hover is **instant** — no `transition-*`.
- **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text.
- **Count pill** (optional, e.g. simulation count): `.tab-count-pill``rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception.
- **Active count pill**: `.tab-count-pill-active``bg-primary-soft text-primary`.
- **Disabled variant**: `.tab-underline-disabled``text-steel cursor-not-allowed`. Uses the same `text-steel` token as `btn-outline:disabled` for system-wide consistency. Applied when `disabled?: boolean` is set on a `TabItem`. Overrides `.tab-underline` hover. ARIA: `aria-disabled="true"` + native `disabled` attribute. Arrow-key navigation skips disabled items.
- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` + `aria-controls="tabpanel-{id}"` on each button.
### Data Tables ### Data Tables
- `table-layout: fixed`, `word-break: break-word` - `table-layout: fixed`, `word-break: break-word`
- Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border - Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border
- Data cells: 44px min-height, 1px `{colors.hairline}` bottom border - Data cells: **32px min-height** (compact density, all surfaces). Class: `.table-compact`.
- **WCAG SC 2.5.5 tradeoff**: the recommended 44×44px touch target is intentionally not met on data rows. Mimic is a BAS operator console used at desktop on mouse/keyboard — density and scanability take priority over touch target size. Action buttons within rows retain icon ≥ 24px with adequate padding to preserve a usable hit area.
- ID / date / technique columns: `font-mono` - ID / date / technique columns: `font-mono`
- Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide - Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide
@@ -229,6 +242,23 @@ Shadows (`box-shadow`) are not used anywhere.
- Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary) - Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary)
- Background `{colors.paper}`, 1px `{colors.hairline}` border - Background `{colors.paper}`, 1px `{colors.hairline}` border
#### Inline Banners
For persistent in-page status messages (not ephemeral toasts). Use `<AlertBanner variant="...">` component.
Four variants — all share: `bg-paper border border-hairline border-l-4 rounded-none px-md py-sm flex items-start gap-sm`.
| Variant | Left strip color | ARIA role | Lucide icon |
|---------|-----------------|-----------|-------------|
| `error` | `border-l-bloom-deep` | `role="alert"` | `AlertCircle` |
| `warn` | `border-l-warn` | `role="alert"` | `AlertTriangle` |
| `success` | `border-l-success` | `role="status"` | `CheckCircle` |
| `info` | `border-l-primary` | `role="status"` | `Info` |
- Icon: Lucide at `size={16}`, `aria-hidden`, `mt-[2px] shrink-0` for baseline alignment.
- No shadow, no rounded corners, no transition.
- Optional `title` prop renders as `font-medium text-ink` above the message body.
--- ---
## Do's and Don'ts ## Do's and Don'ts

View File

@@ -11,9 +11,11 @@
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"i18next": "^26.3.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^17.0.8",
"react-router-dom": "^6.27.0" "react-router-dom": "^6.27.0"
}, },
"devDependencies": { "devDependencies": {
@@ -340,7 +342,6 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -4232,6 +4233,15 @@
"node": ">=18" "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": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -4269,6 +4279,34 @@
"node": ">= 6" "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": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -5936,6 +5974,33 @@
"react": "^18.3.1" "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": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -7056,7 +7121,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -7133,6 +7198,15 @@
"punycode": "^2.1.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "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": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@@ -15,9 +15,11 @@
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"i18next": "^26.3.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^17.0.8",
"react-router-dom": "^6.27.0" "react-router-dom": "^6.27.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
import { AlertCircle, AlertTriangle, CheckCircle, Info } from 'lucide-react';
type AlertVariant = 'error' | 'warn' | 'success' | 'info';
interface AlertBannerProps {
variant: AlertVariant;
title?: string;
children: ReactNode;
}
const CONFIG: Record<AlertVariant, { cls: string; Icon: typeof AlertCircle; role: string }> = {
error: { cls: 'alert-error', Icon: AlertCircle, role: 'alert' },
warn: { cls: 'alert-warn', Icon: AlertTriangle, role: 'alert' },
success: { cls: 'alert-success', Icon: CheckCircle, role: 'status' },
info: { cls: 'alert-info', Icon: Info, role: 'status' },
};
export function AlertBanner({ variant, title, children }: AlertBannerProps): JSX.Element {
const { cls, Icon, role } = CONFIG[variant];
return (
<div role={role} className={cls}>
<Icon size={16} aria-hidden className="mt-[2px] shrink-0" />
<div className="flex flex-col gap-xxs text-[14px]">
{title ? <span className="font-medium text-ink">{title}</span> : null}
<span className="text-charcoal">{children}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Link } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import type { ReactNode } from 'react';
interface BackLinkProps {
to: string;
children: ReactNode;
}
export function BackLink({ to, children }: BackLinkProps): JSX.Element {
return (
<Link to={to} className="inline-flex items-center gap-xxs caption-md text-graphite hover:text-primary">
<ArrowLeft size={14} aria-hidden />
{children}
</Link>
);
}

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { C2Callback } from '@/api/types'; import type { C2Callback } from '@/api/types';
@@ -20,20 +21,22 @@ export function C2CallbackPicker({
onSelect, onSelect,
rowTestId = 'c2-callback-row', rowTestId = 'c2-callback-row',
}: C2CallbackPickerProps): JSX.Element { }: C2CallbackPickerProps): JSX.Element {
const { t } = useTranslation();
if (isLoading) { if (isLoading) {
return <p className="text-[14px] text-graphite">Loading callbacks</p>; return <p className="text-[14px] text-graphite">{t('state.loading')}</p>;
} }
if (isError) { if (isError) {
return ( return (
<p className="text-[14px] text-bloom-deep"> <p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(error, 'Unknown error')} {extractApiError(error, t('state.error.desc'))}
</p> </p>
); );
} }
if (callbacks.length === 0) { if (callbacks.length === 0) {
return <p className="text-[14px] text-graphite">No callbacks available.</p>; return <p className="text-[14px] text-graphite">{t('c2.modal.picker.empty')}</p>;
} }
return ( return (
@@ -41,12 +44,12 @@ export function C2CallbackPicker({
<table className="w-full text-[14px]"> <table className="w-full text-[14px]">
<thead> <thead>
<tr className="bg-cloud border-b border-hairline"> <tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.displayId')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.active')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.host')}</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.user')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.domain')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.lastCheckin')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -78,7 +81,7 @@ export function C2CallbackPicker({
: 'bg-cloud text-graphite border border-hairline' : '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')}
</span> </span>
</td> </td>
<td className="px-md py-sm font-mono">{cb.host}</td> <td className="px-md py-sm font-mono">{cb.host}</td>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2'; import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2';
import { ConfirmDialog } from './ConfirmDialog'; import { ConfirmDialog } from './ConfirmDialog';
@@ -10,6 +11,7 @@ interface C2ConfigCardProps {
} }
export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
const { t } = useTranslation();
const { push } = useToast(); const { push } = useToast();
const configQuery = useC2Config(engagementId); const configQuery = useC2Config(engagementId);
@@ -55,11 +57,11 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
try { try {
await updateMutation.mutateAsync(input); await updateMutation.mutateAsync(input);
push('C2 configuration saved', 'success'); push(t('c2.config.toast.saved'), 'success');
setToken(''); setToken('');
setReplaceToken(false); setReplaceToken(false);
} catch (err) { } 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); setTestResult(null);
try { try {
await deleteMutation.mutateAsync(); await deleteMutation.mutateAsync();
push('C2 configuration removed', 'success'); push(t('c2.config.toast.deleted'), 'success');
setUrl(''); setUrl('');
setToken(''); setToken('');
setVerifyTls(false); setVerifyTls(false);
setReplaceToken(false); setReplaceToken(false);
} catch (err) { } 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(); const result = await testMutation.mutateAsync();
setTestResult({ setTestResult({
ok: result.ok, 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) { } 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" data-testid="c2-config-card"
className="card-product flex flex-col gap-md" className="card-product flex flex-col gap-md"
> >
<h2 className="text-[20px] font-medium text-ink">C2 configuration</h2> <h2 className="text-[20px] font-medium text-ink">{t('c2.config.title')}</h2>
{is503 && ( {is503 && (
<div <div
role="alert" role="alert"
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal" className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
> >
C2 features are disabled (server has no encryption key configured). {t('c2.config.disabled')}
</div> </div>
)} )}
{configQuery.isLoading ? ( {configQuery.isLoading ? (
<p className="text-[14px] text-graphite">Loading</p> <p className="text-[14px] text-graphite">{t('c2.config.loading')}</p>
) : ( ) : (
<form onSubmit={onSave} noValidate className="flex flex-col gap-md"> <form onSubmit={onSave} noValidate className="flex flex-col gap-md">
<FormField <FormField
label="URL" label={t('c2.config.field.url')}
htmlFor="c2-url" htmlFor="c2-url"
hint="HTTPS required (e.g. https://mythic.lab:7443)" hint={t('c2.config.field.urlHint')}
> >
<TextInput <TextInput
id="c2-url" id="c2-url"
@@ -130,7 +132,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="API token" htmlFor="c2-token"> <FormField label={t('c2.config.field.token')} htmlFor="c2-token">
{config?.has_token && !replaceToken ? ( {config?.has_token && !replaceToken ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<TextInput <TextInput
@@ -149,7 +151,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
onClick={() => setReplaceToken(true)} onClick={() => setReplaceToken(true)}
disabled={disabled} disabled={disabled}
> >
Replace token {t('c2.config.btn.replaceToken')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -158,7 +160,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
data-testid="c2-token-input" data-testid="c2-token-input"
type="password" type="password"
name="api_token" 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} value={token}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setToken(e.target.value)}
disabled={disabled} disabled={disabled}
@@ -178,12 +180,11 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
className="h-4 w-4 accent-primary" className="h-4 w-4 accent-primary"
/> />
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink"> <label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
Verify TLS certificate {t('c2.config.field.verifyTls')}
</label> </label>
</div> </div>
<p className="text-[12px] text-graphite"> <p className="text-[12px] text-graphite">
Uncheck only for lab Mythic with self-signed certificates. Disabling {t('c2.config.field.verifyTlsHint')}
verification exposes the API token to MITM.
</p> </p>
</div> </div>
@@ -194,7 +195,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
className="btn-primary" className="btn-primary"
disabled={disabled || submitting} disabled={disabled || submitting}
> >
{updateMutation.isPending ? 'Saving' : 'Save'} {updateMutation.isPending ? t('c2.config.btn.saving') : t('c2.config.btn.save')}
</button> </button>
<button <button
@@ -204,7 +205,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
onClick={onTest} onClick={onTest}
disabled={disabled || testMutation.isPending || !config} disabled={disabled || testMutation.isPending || !config}
> >
{testMutation.isPending ? 'Testing' : 'Test connection'} {testMutation.isPending ? t('c2.config.btn.testing') : t('c2.config.btn.test')}
</button> </button>
{testResult !== null && ( {testResult !== null && (
@@ -223,7 +224,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
disabled={disabled || submitting} disabled={disabled || submitting}
> >
Delete configuration {t('c2.config.btn.delete')}
</button> </button>
)} )}
</div> </div>
@@ -232,10 +233,10 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
{showDeleteConfirm && ( {showDeleteConfirm && (
<ConfirmDialog <ConfirmDialog
title="Delete C2 configuration" title={t('c2.config.deleteConfirm.title')}
description="This will remove the C2 configuration for this engagement. The API token will be permanently deleted." description={t('c2.config.deleteConfirm.desc')}
confirmLabel="Delete" confirmLabel={t('c2.config.deleteConfirm.confirm')}
cancelLabel="Cancel" cancelLabel={t('c2.config.deleteConfirm.cancel')}
destructive destructive
onConfirm={onDelete} onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}

View File

@@ -1,5 +1,6 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react'; import { ChevronRight, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useC2Tasks } from '@/hooks/useC2'; import { useC2Tasks } from '@/hooks/useC2';
import { C2TaskStatusBadge } from './C2TaskStatusBadge'; import { C2TaskStatusBadge } from './C2TaskStatusBadge';
@@ -8,6 +9,7 @@ interface C2TasksPanelProps {
} }
export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element { export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
const { t } = useTranslation();
const query = useC2Tasks(simulationId, { enabled: true }); const query = useC2Tasks(simulationId, { enabled: true });
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
@@ -33,13 +35,13 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
className="card-product flex flex-col gap-md" className="card-product flex flex-col gap-md"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-[16px] font-medium text-ink">C2 Tasks</h3> <h3 className="text-[16px] font-medium text-ink">{t('c2.tasks.title')}</h3>
{isRefreshing && ( {isRefreshing && (
<span <span
data-testid="c2-task-refresh-indicator" data-testid="c2-task-refresh-indicator"
className="text-[12px] text-graphite" className="text-[12px] text-graphite"
> >
Refreshing {t('c2.tasks.refreshing')}
</span> </span>
)} )}
</div> </div>
@@ -47,7 +49,7 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div className="border border-hairline rounded-none px-md py-md"> <div className="border border-hairline rounded-none px-md py-md">
<p className="text-[14px] text-graphite"> <p className="text-[14px] text-graphite">
No C2 tasks yet. Use Execute via C2 to launch commands. {t('c2.tasks.empty')}
</p> </p>
</div> </div>
) : ( ) : (
@@ -55,12 +57,12 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
<table className="w-full text-[14px]"> <table className="w-full text-[14px]">
<thead> <thead>
<tr className="bg-cloud border-b border-hairline"> <tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink w-8" aria-label="Expand" /> <th className="px-md py-sm text-left font-medium text-ink w-8" aria-label={t('c2.tasks.col.expand')} />
<th className="px-md py-sm text-left font-medium text-ink">Task</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.task')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.command')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Source</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.source')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.status')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed at</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.completedAt')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -1,21 +1,25 @@
import { useTranslation } from 'react-i18next';
interface ErrorStateProps { interface ErrorStateProps {
title?: string; title?: string;
message: string; message: string;
onRetry?: () => void; 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 ( return (
<div <div
role="alert" role="alert"
data-testid="error-state" data-testid="error-state"
className="card-product border-l-4 border-l-bloom-deep flex flex-col items-start gap-md" className="card-product border-l-4 border-l-bloom-deep flex flex-col items-start gap-md"
> >
<h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2> <h2 className="text-[24px] font-medium text-bloom-deep">{resolvedTitle}</h2>
<p className="text-[16px] text-charcoal">{message}</p> <p className="text-[16px] text-charcoal">{message}</p>
{onRetry ? ( {onRetry ? (
<button type="button" className="btn-outline" onClick={onRetry}> <button type="button" className="btn-outline" onClick={onRetry}>
Retry {t('state.retry')}
</button> </button>
) : null} ) : null}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2'; import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker'; import { C2CallbackPicker } from './C2CallbackPicker';
@@ -17,6 +18,7 @@ export function ExecuteViaC2Modal({
initialCommands, initialCommands,
onClose, onClose,
}: ExecuteViaC2ModalProps): JSX.Element { }: ExecuteViaC2ModalProps): JSX.Element {
const { t } = useTranslation();
const { push } = useToast(); const { push } = useToast();
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
@@ -43,10 +45,10 @@ export function ExecuteViaC2Modal({
callback_display_id: selectedId, callback_display_id: selectedId,
commands: commandLines, commands: commandLines,
}); });
push(`${result.tasks.length} task(s) submitted`, 'success'); push(t('c2.modal.execute.toast.launched', { count: result.tasks.length }), 'success');
onClose(); onClose();
} catch (err) { } 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({
<div className="relative card-product w-full max-w-3xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto"> <div className="relative card-product w-full max-w-3xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-modal-title" className="text-[20px] font-medium text-ink"> <h2 id="c2-modal-title" className="text-[20px] font-medium text-ink">
Execute via C2 {t('c2.modal.execute.title')}
</h2> </h2>
{/* Callback picker */} {/* Callback picker */}
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span> <span className="text-[14px] font-medium text-ink">{t('c2.modal.execute.callback')}</span>
<C2CallbackPicker <C2CallbackPicker
callbacks={callbacks} callbacks={callbacks}
isLoading={callbacksQuery.isLoading} isLoading={callbacksQuery.isLoading}
@@ -82,7 +84,7 @@ export function ExecuteViaC2Modal({
{/* Commands */} {/* Commands */}
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<label htmlFor="c2-commands" className="text-[14px] font-medium text-ink"> <label htmlFor="c2-commands" className="text-[14px] font-medium text-ink">
Commands {t('simulation.field.commands')}
</label> </label>
<textarea <textarea
id="c2-commands" id="c2-commands"
@@ -90,10 +92,10 @@ export function ExecuteViaC2Modal({
value={commands} value={commands}
onChange={(e) => setCommands(e.target.value)} onChange={(e) => setCommands(e.target.value)}
className="text-input min-h-[112px] py-sm font-mono text-[14px]" className="text-input min-h-[112px] py-sm font-mono text-[14px]"
placeholder="One command per line" placeholder={t('simulation.field.commands')}
/> />
<span className="text-[12px] text-graphite"> <span className="text-[12px] text-graphite">
{commandLines.length} command{commandLines.length !== 1 ? 's' : ''} one task per line {t('c2.modal.execute.commandCount', { count: commandLines.length })}
</span> </span>
</div> </div>
@@ -112,7 +114,7 @@ export function ExecuteViaC2Modal({
onClick={onLaunch} onClick={onLaunch}
disabled={!canLaunch || executeMutation.isPending} disabled={!canLaunch || executeMutation.isPending}
> >
{executeMutation.isPending ? 'Launching' : 'Launch'} {executeMutation.isPending ? t('c2.modal.execute.btn.launching') : t('c2.modal.execute.btn.launch')}
</button> </button>
<button <button
type="button" type="button"
@@ -120,7 +122,7 @@ export function ExecuteViaC2Modal({
onClick={onClose} onClick={onClose}
disabled={executeMutation.isPending} disabled={executeMutation.isPending}
> >
Cancel {t('c2.modal.execute.btn.cancel')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ChevronDown, Download } from 'lucide-react'; import { ChevronDown, Download } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { downloadEngagementExport, type ExportFormat } from '@/api/exports'; import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@@ -14,6 +15,7 @@ const FORMATS: { label: string; value: ExportFormat }[] = [
]; ];
export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element { export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element {
const { t } = useTranslation();
const { push } = useToast(); const { push } = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState<ExportFormat | null>(null); const [loading, setLoading] = useState<ExportFormat | null>(null);
@@ -58,11 +60,11 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
data-testid="export-btn" data-testid="export-btn"
> >
<Download size={14} aria-hidden /> Export <Download size={14} aria-hidden /> {t('export.btn')}
</button> </button>
<button <button
type="button" type="button"
aria-label="Export options" aria-label={t('export.aria')}
aria-expanded={open} aria-expanded={open}
className="btn-outline px-sm" className="btn-outline px-sm"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
@@ -89,7 +91,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
> >
{loading === value ? ( {loading === value ? (
<span className="font-mono text-[11px] animate-pulse" aria-hidden> <span className="font-mono text-[11px] animate-pulse" aria-hidden>
EXPORTING {t('export.btnLoading')}
</span> </span>
) : null} ) : null}
{loading !== value ? label : null} {loading !== value ? label : null}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2'; import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker'; import { C2CallbackPicker } from './C2CallbackPicker';
@@ -18,6 +19,7 @@ export function ImportC2HistoryModal({
engagementId, engagementId,
onClose, onClose,
}: ImportC2HistoryModalProps): JSX.Element { }: ImportC2HistoryModalProps): JSX.Element {
const { t } = useTranslation();
const { push } = useToast(); const { push } = useToast();
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
@@ -70,12 +72,12 @@ export function ImportC2HistoryModal({
}); });
const msg = const msg =
result.skipped > 0 result.skipped > 0
? `Imported ${result.imported} task(s), ${result.skipped} already attached` ? t('c2.modal.import.toast.partial', { imported: result.imported, skipped: result.skipped })
: `Imported ${result.imported} task(s)`; : t('c2.modal.import.toast.imported', { count: result.imported });
push(msg, 'success'); push(msg, 'success');
onClose(); onClose();
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not import tasks')); setSubmitError(extractApiError(err, t('c2.modal.import.error.import')));
} }
}; };
@@ -91,12 +93,12 @@ export function ImportC2HistoryModal({
<div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto"> <div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink"> <h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
Import C2 history {t('c2.modal.import.title')}
</h2> </h2>
{/* Step 1: callback picker */} {/* Step 1: callback picker */}
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span> <span className="text-[14px] font-medium text-ink">{t('c2.modal.picker.title')}</span>
<C2CallbackPicker <C2CallbackPicker
callbacks={callbacks} callbacks={callbacks}
isLoading={callbacksQuery.isLoading} isLoading={callbacksQuery.isLoading}
@@ -112,24 +114,24 @@ export function ImportC2HistoryModal({
{selectedCallbackId !== null && ( {selectedCallbackId !== null && (
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink"> <span className="text-[14px] font-medium text-ink">
Task history{' '} {t('c2.tasks.title')}{' '}
{total > 0 && ( {total > 0 && (
<span className="text-graphite font-normal">({total} total)</span> <span className="text-graphite font-normal">({total} {t('c2.modal.import.total').toLowerCase()})</span>
)} )}
</span> </span>
{historyQuery.isLoading && ( {historyQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading history</p> <p className="text-[14px] text-graphite">{t('state.loading')}</p>
)} )}
{historyQuery.isError && ( {historyQuery.isError && (
<p className="text-[14px] text-bloom-deep"> <p className="text-[14px] text-bloom-deep">
{extractApiError(historyQuery.error, 'Could not load history')} {extractApiError(historyQuery.error, t('c2.modal.import.error.import'))}
</p> </p>
)} )}
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && ( {!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
<p className="text-[14px] text-graphite">No task history for this callback.</p> <p className="text-[14px] text-graphite">{t('c2.modal.import.empty')}</p>
)} )}
{historyTasks.length > 0 && ( {historyTasks.length > 0 && (
@@ -138,12 +140,12 @@ export function ImportC2HistoryModal({
<table className="w-full text-[14px]"> <table className="w-full text-[14px]">
<thead> <thead>
<tr className="bg-cloud border-b border-hairline"> <tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm w-8" aria-label="Select" /> <th className="px-md py-sm w-8" aria-label={t('common.select')} />
<th className="px-md py-sm text-left font-medium text-ink">#</th> <th className="px-md py-sm text-left font-medium text-ink">#</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.command')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.status')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.completed')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Timestamp</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.timestamp')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -175,7 +177,7 @@ export function ImportC2HistoryModal({
<C2TaskStatusBadge status={task.status} /> <C2TaskStatusBadge status={task.status} />
</td> </td>
<td className="px-md py-sm text-[14px]"> <td className="px-md py-sm text-[14px]">
{task.completed ? 'Yes' : 'No'} {task.completed ? t('common.yes') : t('common.no')}
</td> </td>
<td className="px-md py-sm font-mono text-graphite"> <td className="px-md py-sm font-mono text-graphite">
{task.created_at} {task.created_at}
@@ -195,10 +197,10 @@ export function ImportC2HistoryModal({
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1} disabled={page <= 1}
> >
Prev {t('c2.modal.import.prev')}
</button> </button>
<span> <span>
Page {page} of {totalPages} {t('c2.modal.import.page')} {page} {t('c2.modal.import.of')} {totalPages}
</span> </span>
<button <button
type="button" type="button"
@@ -207,11 +209,11 @@ export function ImportC2HistoryModal({
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
> >
Next {t('c2.modal.import.next')}
</button> </button>
{checkedIds.size > 0 && ( {checkedIds.size > 0 && (
<span className="ml-auto text-ink"> <span className="ml-auto text-ink">
{checkedIds.size} selected {checkedIds.size} {t('c2.modal.import.selected')}
</span> </span>
)} )}
</div> </div>
@@ -235,7 +237,7 @@ export function ImportC2HistoryModal({
onClick={onImport} onClick={onImport}
disabled={!canImport || importMutation.isPending} disabled={!canImport || importMutation.isPending}
> >
{importMutation.isPending ? 'Importing' : 'Import selected'} {importMutation.isPending ? t('c2.modal.import.btn.importing') : t('c2.modal.import.btn.import')}
</button> </button>
<button <button
type="button" type="button"
@@ -243,7 +245,7 @@ export function ImportC2HistoryModal({
onClick={onClose} onClick={onClose}
disabled={importMutation.isPending} disabled={importMutation.isPending}
> >
Cancel {t('c2.modal.import.btn.cancel')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'; import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
import { Moon, Sun, Monitor } from 'lucide-react'; import { Moon, Sun, Monitor } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import type { Theme } 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 { user, isAdmin, isRedteam, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { theme, cycleTheme } = useTheme(); const { theme, cycleTheme } = useTheme();
const { t } = useTranslation();
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
@@ -41,7 +43,7 @@ export function Layout(): JSX.Element {
<button <button
type="button" type="button"
onClick={cycleTheme} onClick={cycleTheme}
aria-label={`Theme: ${themeLabel(theme)} — click to cycle`} aria-label={t('nav.themeAria', { label: themeLabel(theme) })}
className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text" className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text"
> >
<ThemeIcon theme={theme} /> <ThemeIcon theme={theme} />
@@ -52,7 +54,7 @@ export function Layout(): JSX.Element {
onClick={handleLogout} onClick={handleLogout}
className="text-[14px] underline-offset-2 hover:underline" className="text-[14px] underline-offset-2 hover:underline"
> >
Sign out {t('nav.signOut')}
</button> </button>
</div> </div>
) : null} ) : null}
@@ -62,7 +64,7 @@ export function Layout(): JSX.Element {
{/* nav-bar-top — fixed dark slab, never inverts (same visual family as utility-strip + footer) */} {/* nav-bar-top — fixed dark slab, never inverts (same visual family as utility-strip + footer) */}
<header className="bg-slab text-slab-text"> <header className="bg-slab text-slab-text">
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between"> <div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home"> <Link to="/engagements" className="flex items-center gap-sm" aria-label={t('nav.brandHome')}>
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden /> <span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
<span className="text-[20px] font-medium tracking-tight text-slab-text">Mimic</span> <span className="text-[20px] font-medium tracking-tight text-slab-text">Mimic</span>
</Link> </Link>
@@ -78,7 +80,7 @@ export function Layout(): JSX.Element {
}` }`
} }
> >
Engagements {t('nav.engagements')}
</NavLink> </NavLink>
{isAdmin || isRedteam ? ( {isAdmin || isRedteam ? (
<NavLink <NavLink
@@ -91,7 +93,7 @@ export function Layout(): JSX.Element {
}` }`
} }
> >
Templates {t('nav.templates')}
</NavLink> </NavLink>
) : null} ) : null}
{isAdmin ? ( {isAdmin ? (
@@ -105,7 +107,7 @@ export function Layout(): JSX.Element {
}` }`
} }
> >
Users {t('nav.users')}
</NavLink> </NavLink>
) : null} ) : null}
</nav> </nav>
@@ -122,7 +124,7 @@ export function Layout(): JSX.Element {
{/* footer — fixed dark slab, never inverts */} {/* footer — fixed dark slab, never inverts */}
<footer className="bg-slab text-slab-text"> <footer className="bg-slab text-slab-text">
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-slab-muted"> <div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-slab-muted">
Mimic Internal Purple Team tooling. Authorized engagements only. {t('nav.footer')}
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LoadingState } from './LoadingState'; import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState'; import { ErrorState } from './ErrorState';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
@@ -41,6 +42,7 @@ export function MitreMatrixModal({
onApply, onApply,
onCancel, onCancel,
}: MitreMatrixModalProps): JSX.Element | null { }: MitreMatrixModalProps): JSX.Element | null {
const { t } = useTranslation();
const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen); const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen);
const [selectedTechMap, setSelectedTechMap] = useState<Map<string, { id: string; name: string }>>( const [selectedTechMap, setSelectedTechMap] = useState<Map<string, { id: string; name: string }>>(
@@ -177,24 +179,24 @@ export function MitreMatrixModal({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-xl py-md border-b border-hairline flex-shrink-0"> <div className="flex items-center justify-between px-xl py-md border-b border-hairline flex-shrink-0">
<h2 id="matrix-modal-title" className="text-[18px] font-medium text-ink"> <h2 id="matrix-modal-title" className="text-[18px] font-medium text-ink">
MITRE ATT&amp;CK Matrix {t('mitre.matrix.title')}
</h2> </h2>
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
placeholder="Filter techniques…" placeholder={t('mitre.matrix.filter')}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="text-input w-56 h-9 text-[14px]" className="text-input w-56 h-9 text-[14px]"
aria-label="Filter techniques" aria-label={t('mitre.matrix.filter')}
/> />
</div> </div>
{/* Body — overflow-y-auto, NO overflow-x */} {/* Body — overflow-y-auto, NO overflow-x */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-md py-md"> <div className="flex-1 overflow-y-auto overflow-x-hidden px-md py-md">
{isLoading && <LoadingState label="Loading MITRE matrix…" />} {isLoading && <LoadingState label={t('mitre.matrix.loading')} />}
{isError && ( {isError && (
<ErrorState message={extractApiError(error, 'Could not load MITRE matrix')} /> <ErrorState message={extractApiError(error, t('mitre.matrix.error'))} />
)} )}
{!isLoading && !isError && matrix && ( {!isLoading && !isError && matrix && (
<div <div
@@ -276,7 +278,7 @@ export function MitreMatrixModal({
{hasSubtechniques ? ( {hasSubtechniques ? (
<button <button
type="button" type="button"
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`} aria-label={isExpanded ? t('common.collapse', { id: tech.id }) : t('common.expand', { id: tech.id })}
onClick={() => toggleExpand(tech.id)} onClick={() => toggleExpand(tech.id)}
className={`mr-[2px] flex-shrink-0 text-[9px] w-3 leading-none mt-[1px] ${ className={`mr-[2px] flex-shrink-0 text-[9px] w-3 leading-none mt-[1px] ${
isSelected ? 'text-white' : 'text-graphite' isSelected ? 'text-white' : 'text-graphite'
@@ -327,7 +329,7 @@ export function MitreMatrixModal({
); );
})} })}
{visibleTechniques.length === 0 && searchLower && ( {visibleTechniques.length === 0 && searchLower && (
<div className="px-xs py-xxs text-[10px] text-graphite italic">No match</div> <div className="px-xs py-xxs text-[10px] text-graphite italic">{t('mitre.matrix.noMatch')}</div>
)} )}
</div> </div>
</div> </div>
@@ -340,7 +342,7 @@ export function MitreMatrixModal({
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-end gap-md px-xl py-md border-t border-hairline flex-shrink-0"> <div className="flex items-center justify-end gap-md px-xl py-md border-t border-hairline flex-shrink-0">
<button type="button" className="btn-outline-ink" onClick={onCancel}> <button type="button" className="btn-outline-ink" onClick={onCancel}>
Cancel {t('mitre.matrix.close')}
</button> </button>
<button <button
type="button" type="button"
@@ -349,8 +351,8 @@ export function MitreMatrixModal({
disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)} disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)}
> >
{totalSelected === 0 {totalSelected === 0
? 'Clear all' ? t('mitre.matrix.clearAll')
: `Apply ${totalSelected} item${totalSelected !== 1 ? 's' : ''}`} : t(totalSelected === 1 ? 'mitre.matrix.applyItem_one' : 'mitre.matrix.applyItem_other', { count: totalSelected })}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { MitreTechnique } from '@/api/types'; import type { MitreTechnique } from '@/api/types';
import { useMitreSearch } from '@/hooks/useMitre'; import { useMitreSearch } from '@/hooks/useMitre';
@@ -14,6 +15,7 @@ export function MitreTechniquePicker({
onSelect, onSelect,
disabled = false, disabled = false,
}: MitreTechniquePickerProps): JSX.Element { }: MitreTechniquePickerProps): JSX.Element {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -95,31 +97,31 @@ export function MitreTechniquePicker({
aria-expanded={open} aria-expanded={open}
aria-controls={listboxId} aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined} aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined}
aria-label="Search MITRE technique" aria-label={t('mitre.field.search')}
className="text-input" className="text-input"
value={inputValue} value={inputValue}
onChange={(e) => handleInputChange(e.target.value)} onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
disabled={disabled} disabled={disabled}
placeholder="Search by ID or name (e.g. T1059)" placeholder={t('mitre.field.search')}
autoComplete="off" autoComplete="off"
/> />
{open && ( {open && (
<div className="absolute z-20 w-full mt-xxs bg-paper border border-steel rounded-none overflow-hidden"> <div className="absolute z-20 w-full mt-xxs bg-paper border border-steel rounded-none overflow-hidden">
{isFetching && ( {isFetching && (
<div className="px-md py-sm text-[14px] text-graphite">Searching</div> <div className="px-md py-sm text-[14px] text-graphite">{t('state.loading')}</div>
)} )}
{isError && !isFetching && ( {isError && !isFetching && (
<div className="px-md py-sm text-[14px] text-bloom-deep" role="alert"> <div className="px-md py-sm text-[14px] text-bloom-deep" role="alert">
{extractApiError(error, 'MITRE search unavailable')} {extractApiError(error, t('mitre.matrix.error'))}
</div> </div>
)} )}
{!isFetching && !isError && items.length === 0 && query.trim().length > 0 && ( {!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
<div className="px-md py-sm text-[14px] text-graphite">No results</div> <div className="px-md py-sm text-[14px] text-graphite">{t('state.empty.default')}</div>
)} )}
{!isFetching && items.length > 0 && ( {!isFetching && items.length > 0 && (

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { MitreTechnique, MitreTacticRef } from '@/api/types'; import type { MitreTechnique, MitreTacticRef } from '@/api/types';
interface TechniqueTagProps { interface TechniqueTagProps {
@@ -18,6 +19,7 @@ export function MitreTechniqueTag({
onRemove, onRemove,
disabled = false, disabled = false,
}: TechniqueTagProps): JSX.Element { }: TechniqueTagProps): JSX.Element {
const { t } = useTranslation();
return ( return (
<span <span
data-testid="mitre-technique-tag" data-testid="mitre-technique-tag"
@@ -28,7 +30,7 @@ export function MitreTechniqueTag({
{!disabled && ( {!disabled && (
<button <button
type="button" type="button"
aria-label={`Remove ${technique.id}`} aria-label={`${t('mitre.tag.remove')} ${technique.id}`}
onClick={onRemove} onClick={onRemove}
className="text-primary-deep opacity-60 hover:opacity-100 leading-none" className="text-primary-deep opacity-60 hover:opacity-100 leading-none"
> >
@@ -45,6 +47,7 @@ export function MitreTacticTag({
onRemove, onRemove,
disabled = false, disabled = false,
}: TacticTagProps): JSX.Element { }: TacticTagProps): JSX.Element {
const { t } = useTranslation();
return ( return (
<span <span
data-testid="mitre-tactic-tag" data-testid="mitre-tactic-tag"
@@ -55,7 +58,7 @@ export function MitreTacticTag({
{!disabled && ( {!disabled && (
<button <button
type="button" type="button"
aria-label={`Remove ${tactic.id}`} aria-label={`${t('mitre.tag.remove')} ${tactic.id}`}
onClick={onRemove} onClick={onRemove}
className="text-white opacity-60 hover:opacity-100 leading-none" className="text-white opacity-60 hover:opacity-100 leading-none"
> >

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Grid2x2 } from 'lucide-react'; import { Grid2x2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { MitreTechnique, MitreTacticRef } from '@/api/types'; import type { MitreTechnique, MitreTacticRef } from '@/api/types';
import { useUpdateSimulation } from '@/hooks/useSimulations'; import { useUpdateSimulation } from '@/hooks/useSimulations';
@@ -24,6 +25,7 @@ export function MitreTechniquesField({
engagementId, engagementId,
disabled = false, disabled = false,
}: MitreTechniquesFieldProps): JSX.Element { }: MitreTechniquesFieldProps): JSX.Element {
const { t } = useTranslation();
const [showMatrix, setShowMatrix] = useState(false); const [showMatrix, setShowMatrix] = useState(false);
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
@@ -33,34 +35,34 @@ export function MitreTechniquesField({
const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => { const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => {
try { try {
await updateMutation.mutateAsync({ await updateMutation.mutateAsync({
technique_ids: techniques.map((t) => t.id), technique_ids: techniques.map((tech) => tech.id),
tactic_ids: nextTactics.map((t) => t.id), tactic_ids: nextTactics.map((tac) => tac.id),
}); });
push('Techniques updated', 'success'); push(t('mitre.field.savedToast'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not update techniques'), 'error'); push(extractApiError(err, t('mitre.field.errorToast')), 'error');
} }
}; };
const handleRemoveTechnique = (id: string) => { const handleRemoveTechnique = (id: string) => {
void save(value.filter((t) => t.id !== id), tactics); void save(value.filter((tech) => tech.id !== id), tactics);
}; };
const handleRemoveTactic = (id: string) => { const handleRemoveTactic = (id: string) => {
void save(value, tactics.filter((t) => t.id !== id)); void save(value, tactics.filter((tac) => tac.id !== id));
}; };
const handleSelect = (technique: MitreTechnique) => { const handleSelect = (technique: MitreTechnique) => {
if (value.some((t) => t.id === technique.id)) return; if (value.some((tech) => tech.id === technique.id)) return;
void save([...value, technique], tactics); void save([...value, technique], tactics);
setShowPicker(false); setShowPicker(false);
}; };
const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => { const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => {
setShowMatrix(false); setShowMatrix(false);
const merged = techniques.map((s) => { const merged = techniques.map((sel) => {
const existing = value.find((v) => v.id === s.id); const existing = value.find((v) => v.id === sel.id);
return existing ?? s; return existing ?? sel;
}); });
void save(merged, newTactics); void save(merged, newTactics);
}; };
@@ -72,22 +74,22 @@ export function MitreTechniquesField({
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
{/* Chips area */} {/* Chips area */}
{isEmpty ? ( {isEmpty ? (
<p className="text-[13px] text-graphite">No techniques selected</p> <p className="text-[13px] text-graphite">{t('mitre.field.empty')}</p>
) : ( ) : (
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list"> <div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
{tactics.map((t) => ( {tactics.map((tac) => (
<MitreTacticTag <MitreTacticTag
key={t.id} key={tac.id}
tactic={t} tactic={tac}
onRemove={() => handleRemoveTactic(t.id)} onRemove={() => handleRemoveTactic(tac.id)}
disabled={disabled || isPending} disabled={disabled || isPending}
/> />
))} ))}
{value.map((t) => ( {value.map((tech) => (
<MitreTechniqueTag <MitreTechniqueTag
key={t.id} key={tech.id}
technique={t} technique={tech}
onRemove={() => handleRemoveTechnique(t.id)} onRemove={() => handleRemoveTechnique(tech.id)}
disabled={disabled || isPending} disabled={disabled || isPending}
/> />
))} ))}
@@ -107,20 +109,20 @@ export function MitreTechniquesField({
onClick={() => setShowPicker(true)} onClick={() => setShowPicker(true)}
disabled={isPending} disabled={isPending}
> >
Search technique (e.g. T1059) {t('mitre.field.search')}
</button> </button>
)} )}
</div> </div>
<button <button
type="button" type="button"
aria-label="Open MITRE matrix" aria-label={t('mitre.field.openMatrix')}
onClick={() => { setShowPicker(false); setShowMatrix(true); }} onClick={() => { setShowPicker(false); setShowMatrix(true); }}
disabled={isPending} disabled={isPending}
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink" className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink"
> >
<Grid2x2 size={16} /> <Grid2x2 size={16} />
</button> </button>
{isPending && <span className="text-[12px] text-graphite">Saving</span>} {isPending && <span className="text-[12px] text-graphite">{t('common.saving')}</span>}
</div> </div>
)} )}

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import type { Role } from '@/api/types'; import type { Role } from '@/api/types';
@@ -23,6 +24,7 @@ export function ProtectedRoute({
}: ProtectedRouteProps): JSX.Element { }: ProtectedRouteProps): JSX.Element {
const { user, status } = useAuth(); const { user, status } = useAuth();
const { push } = useToast(); const { push } = useToast();
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const roleDenied = Boolean( const roleDenied = Boolean(
@@ -31,12 +33,12 @@ export function ProtectedRoute({
useEffect(() => { useEffect(() => {
if (roleDenied) { if (roleDenied) {
push('Accès refusé', 'error'); push(t('auth.forbidden'), 'error');
} }
}, [roleDenied, push]); }, [roleDenied, push, t]);
if (status === 'loading') { if (status === 'loading') {
return <LoadingState label="Loading session…" />; return <LoadingState label={t('auth.loadingSession')} />;
} }
if (status === 'unauthenticated' || !user) { if (status === 'unauthenticated' || !user) {

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { ChevronDown, Plus } from 'lucide-react'; import { ChevronDown, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { SimulationTemplate } from '@/api/types'; import type { SimulationTemplate } from '@/api/types';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations'; import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { formatDateTime } from '@/lib/format';
import { LoadingState } from './LoadingState'; import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState'; import { ErrorState } from './ErrorState';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
@@ -16,12 +18,8 @@ interface SimulationListProps {
engagementId: number; engagementId: number;
} }
function formatDate(value: string | null): string {
if (!value) return '—';
return value.replace('T', ' ').slice(0, 16);
}
function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element { function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { push } = useToast(); const { push } = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -61,10 +59,10 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
try { try {
const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id }); const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id });
setShowPicker(false); setShowPicker(false);
push(`Created "${sim.name}" from template`, 'success'); push(t('simulation.form.toast.created'), 'success');
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`); navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not create simulation from template'), 'error'); push(extractApiError(err, t('simulation.form.error.create')), 'error');
} }
}; };
@@ -77,11 +75,11 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
onClick={handleBlank} onClick={handleBlank}
data-testid="new-simulation-btn" data-testid="new-simulation-btn"
> >
<Plus size={14} aria-hidden /> New <Plus size={14} aria-hidden /> {t('simulation.list.new')}
</button> </button>
<button <button
type="button" type="button"
aria-label="More options" aria-label={t('simulation.list.rowActions')}
aria-expanded={open} aria-expanded={open}
className="btn-primary px-sm" className="btn-primary px-sm"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
@@ -133,16 +131,17 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
} }
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element { export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
const { t } = useTranslation();
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId); const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
const { canEditEngagements } = useAuth(); const { canEditEngagements } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
if (isLoading) return <LoadingState label="Loading simulations…" />; if (isLoading) return <LoadingState label={t('common.loading')} />;
if (isError) { if (isError) {
return ( return (
<ErrorState <ErrorState
message={extractApiError(error, 'Could not load simulations')} message={extractApiError(error, t('simulation.list.error'))}
onRetry={() => refetch()} onRetry={() => refetch()}
/> />
); );
@@ -151,8 +150,8 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
<EmptyState <EmptyState
title="No simulations yet" title={t('simulation.list.empty.title')}
description="Create the first simulation to start tracking red team tests." description={t('simulation.list.empty.desc')}
action={ action={
canEditEngagements ? ( canEditEngagements ? (
<NewSimulationDropdown engagementId={engagementId} /> <NewSimulationDropdown engagementId={engagementId} />
@@ -165,32 +164,32 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
return ( return (
<div className="flex flex-col gap-md"> <div className="flex flex-col gap-md">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-[24px] font-medium text-ink">Simulations</h2> <h2 className="text-[24px] font-medium text-ink">{t('engagement.detail.tabs.simulations')}</h2>
{canEditEngagements ? ( {canEditEngagements ? (
<NewSimulationDropdown engagementId={engagementId} /> <NewSimulationDropdown engagementId={engagementId} />
) : null} ) : null}
</div> </div>
<div className="card-product overflow-hidden p-0"> <div className="card-product overflow-hidden p-0">
<table className="w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr>
<th className="px-xl py-md">Name</th> <th>{t('simulation.list.col.name')}</th>
<th className="px-xl py-md">MITRE</th> <th>{t('simulation.list.col.mitre')}</th>
<th className="px-xl py-md">Status</th> <th>{t('simulation.list.col.status')}</th>
<th className="px-xl py-md">Executed at</th> <th>{t('simulation.list.col.executedAt')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.map((sim) => ( {data.map((sim) => (
<tr <tr
key={sim.id} key={sim.id}
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer" className="hover:bg-cloud cursor-pointer"
onClick={() => onClick={() =>
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`) navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`)
} }
> >
<td className="px-xl py-md"> <td>
<Link <Link
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`} to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
className="text-ink font-medium hover:underline" className="text-ink font-medium hover:underline"
@@ -199,22 +198,22 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
{sim.name} {sim.name}
</Link> </Link>
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px] font-mono"> <td className="text-charcoal font-mono">
{(() => { {(() => {
const items = [ const items = [
...(sim.tactics ?? []).map((t) => t.id), ...(sim.tactics ?? []).map((tactic) => tactic.id),
...sim.techniques.map((t) => t.id), ...sim.techniques.map((tech) => tech.id),
]; ];
if (items.length === 0) return '—'; if (items.length === 0) return '—';
if (items.length === 1) return items[0]; if (items.length === 1) return items[0];
return `${items[0]} +${items.length - 1}`; return `${items[0]} +${items.length - 1}`;
})()} })()}
</td> </td>
<td className="px-xl py-md"> <td>
<SimulationStatusBadge status={sim.status} /> <SimulationStatusBadge status={sim.status} />
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px] font-mono"> <td className="text-charcoal font-mono">
{formatDate(sim.executed_at)} {sim.executed_at ? formatDateTime(sim.executed_at) : '—'}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -1,12 +1,6 @@
import { useTranslation } from 'react-i18next';
import type { SimulationStatus } from '@/api/types'; import type { SimulationStatus } from '@/api/types';
const LABELS: Record<SimulationStatus, string> = {
pending: 'Pending',
in_progress: 'In progress',
review_required: 'Review required',
done: 'Done',
};
const STYLES: Record<SimulationStatus, string> = { const STYLES: Record<SimulationStatus, string> = {
pending: 'bg-cloud text-graphite border border-hairline', pending: 'bg-cloud text-graphite border border-hairline',
in_progress: 'bg-primary-soft text-primary-deep', in_progress: 'bg-primary-soft text-primary-deep',
@@ -15,13 +9,14 @@ const STYLES: Record<SimulationStatus, string> = {
}; };
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element { export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
const { t } = useTranslation();
return ( return (
<span <span
className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`} className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
data-testid="simulation-status-badge" data-testid="simulation-status-badge"
data-status={status} data-status={status}
> >
{LABELS[status]} {t(`status.simulation.${status}`)}
</span> </span>
); );
} }

View File

@@ -1,11 +1,6 @@
import { useTranslation } from 'react-i18next';
import type { EngagementStatus } from '@/api/types'; import type { EngagementStatus } from '@/api/types';
const LABELS: Record<EngagementStatus, string> = {
planned: 'Planned',
active: 'Active',
closed: 'Closed',
};
const STYLES: Record<EngagementStatus, string> = { const STYLES: Record<EngagementStatus, string> = {
planned: 'bg-warn-soft text-warn border border-warn', planned: 'bg-warn-soft text-warn border border-warn',
active: 'bg-primary-soft text-primary-deep', active: 'bg-primary-soft text-primary-deep',
@@ -13,13 +8,14 @@ const STYLES: Record<EngagementStatus, string> = {
}; };
export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element { export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element {
const { t } = useTranslation();
return ( return (
<span <span
className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`} className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
data-testid="status-badge" data-testid="status-badge"
data-status={status} data-status={status}
> >
{LABELS[status]} {t(`status.engagement.${status}`)}
</span> </span>
); );
} }

View File

@@ -0,0 +1,72 @@
import type { KeyboardEvent } from 'react';
interface TabItem {
id: string;
label: string;
count?: number;
disabled?: boolean;
}
interface TabsProps {
items: TabItem[];
activeId: string;
onChange: (id: string) => void;
}
export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
function nextEnabled(from: number, direction: 1 | -1): string {
const len = items.length;
let i = (from + direction + len) % len;
while (i !== from) {
if (!items[i].disabled) return items[i].id;
i = (i + direction + len) % len;
}
return items[from].id;
}
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
if (e.key === 'ArrowRight') {
e.preventDefault();
onChange(nextEnabled(index, 1));
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
onChange(nextEnabled(index, -1));
}
}
return (
<div
role="tablist"
className="flex items-end border-b border-hairline gap-xs"
>
{items.map((item, index) => {
const isActive = item.id === activeId;
const isDisabled = item.disabled ?? false;
return (
<button
key={item.id}
id={`tab-${item.id}`}
role="tab"
aria-selected={isActive}
aria-controls={`tabpanel-${item.id}`}
aria-disabled={isDisabled || undefined}
disabled={isDisabled}
type="button"
onClick={() => { if (!isDisabled) onChange(item.id); }}
onKeyDown={(e) => handleKeyDown(e, index)}
className={`tab-underline${isActive ? ' tab-underline-active' : ''}${isDisabled ? ' tab-underline-disabled' : ''} pb-xs`}
>
{item.label}
{item.count !== undefined ? (
<span
className={`ml-xxs tab-count-pill${isActive ? ' tab-count-pill-active' : ''}`}
>
{item.count}
</span>
) : null}
</button>
);
})}
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { SimulationTemplate } from '@/api/types'; import type { SimulationTemplate } from '@/api/types';
import { useTemplates } from '@/hooks/useTemplates'; import { useTemplates } from '@/hooks/useTemplates';
@@ -23,6 +24,7 @@ export function TemplatePickerModal({
onSelectTemplate, onSelectTemplate,
isPending = false, isPending = false,
}: TemplatePickerModalProps): JSX.Element { }: TemplatePickerModalProps): JSX.Element {
const { t } = useTranslation();
const { data, isLoading, isError, error, refetch } = useTemplates(); const { data, isLoading, isError, error, refetch } = useTemplates();
return ( return (
@@ -37,11 +39,11 @@ export function TemplatePickerModal({
<div className="relative card-product max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden"> <div className="relative card-product max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink"> <h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink">
From template {t('template.picker.title')}
</h2> </h2>
<button <button
type="button" type="button"
aria-label="Close" aria-label={t('common.close')}
onClick={onClose} onClick={onClose}
className="text-graphite hover:text-ink text-[20px] leading-none" className="text-graphite hover:text-ink text-[20px] leading-none"
> >
@@ -50,19 +52,18 @@ export function TemplatePickerModal({
</div> </div>
<div className="overflow-y-auto flex-1 -mx-xl px-xl"> <div className="overflow-y-auto flex-1 -mx-xl px-xl">
{isLoading ? <LoadingState label="Loading templates…" /> : null} {isLoading ? <LoadingState label={t('template.picker.loading')} /> : null}
{isError ? ( {isError ? (
<ErrorState <ErrorState
message={extractApiError(error, 'Could not load templates')} message={extractApiError(error, t('template.form.errorLoad'))}
onRetry={() => refetch()} onRetry={() => refetch()}
/> />
) : null} ) : null}
{!isLoading && !isError && data && data.length === 0 ? ( {!isLoading && !isError && data && data.length === 0 ? (
<EmptyState <EmptyState
title="No templates available" title={t('template.picker.empty')}
description="Create one from the Templates page."
/> />
) : null} ) : null}
@@ -70,22 +71,22 @@ export function TemplatePickerModal({
<table className="w-full text-left" data-testid="template-picker-table"> <table className="w-full text-left" data-testid="template-picker-table">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
<th className="px-md py-sm">Name</th> <th className="px-md py-sm">{t('template.list.col.name')}</th>
<th className="px-md py-sm">MITRE</th> <th className="px-md py-sm">{t('simulation.list.col.mitre')}</th>
<th className="px-md py-sm">Created by</th> <th className="px-md py-sm">{t('template.list.col.createdBy')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.map((t) => ( {data.map((template) => (
<tr <tr
key={t.id} key={template.id}
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer" className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
onClick={() => !isPending && onSelectTemplate(t)} onClick={() => !isPending && onSelectTemplate(template)}
data-testid={`template-row-${t.id}`} data-testid={`template-row-${template.id}`}
> >
<td className="px-md py-sm text-ink font-medium">{t.name}</td> <td className="px-md py-sm text-ink font-medium">{template.name}</td>
<td className="px-md py-sm text-charcoal text-[14px]">{mitreCount(t)}</td> <td className="px-md py-sm text-charcoal text-[14px]">{mitreCount(template)}</td>
<td className="px-md py-sm text-charcoal text-[14px]">{t.created_by.username}</td> <td className="px-md py-sm text-charcoal text-[14px]">{template.created_by.username}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -95,7 +96,7 @@ export function TemplatePickerModal({
<div className="border-t border-hairline pt-sm"> <div className="border-t border-hairline pt-sm">
<button type="button" className="btn-outline-ink" onClick={onClose}> <button type="button" className="btn-outline-ink" onClick={onClose}>
Cancel {t('common.cancel')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
export function ToastViewport(): JSX.Element { export function ToastViewport(): JSX.Element {
const { t } = useTranslation();
const { toasts, dismiss } = useToast(); const { toasts, dismiss } = useToast();
return ( return (
<div <div
@@ -8,9 +10,9 @@ export function ToastViewport(): JSX.Element {
aria-atomic="true" aria-atomic="true"
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none" className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
> >
{toasts.map((t) => { {toasts.map((toast) => {
const isError = t.kind === 'error'; const isError = toast.kind === 'error';
const isSuccess = t.kind === 'success'; const isSuccess = toast.kind === 'success';
const surface = isError const surface = isError
? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep' ? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep'
: isSuccess : isSuccess
@@ -18,18 +20,18 @@ export function ToastViewport(): JSX.Element {
: 'bg-paper text-ink border border-hairline border-l-4 border-l-primary'; : 'bg-paper text-ink border border-hairline border-l-4 border-l-primary';
return ( return (
<div <div
key={t.id} key={toast.id}
role="status" role="status"
data-testid="toast" data-testid="toast"
data-kind={t.kind} data-kind={toast.kind}
className={`pointer-events-auto rounded-none px-md py-sm text-[14px] leading-[1.4] ${surface}`} className={`pointer-events-auto rounded-none px-md py-sm text-[14px] leading-[1.4] ${surface}`}
> >
<div className="flex items-start justify-between gap-sm"> <div className="flex items-start justify-between gap-sm">
<span className="flex-1">{t.message}</span> <span className="flex-1">{toast.message}</span>
<button <button
type="button" type="button"
onClick={() => dismiss(t.id)} onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification" aria-label={t('toast.dismiss')}
className="text-current opacity-70 hover:opacity-100" className="text-current opacity-70 hover:opacity-100"
> >
× ×

View File

@@ -0,0 +1,28 @@
import { useCallback, useEffect, useState } from 'react';
function readHash(defaultId: string, validIds?: string[]): string {
const hash = window.location.hash.slice(1); // strip leading '#'
if (!hash) return defaultId;
if (validIds && !validIds.includes(hash)) return defaultId;
return hash;
}
export function useHashTab(defaultId: string, validIds?: string[]): [string, (id: string) => void] {
const [activeId, setActiveId] = useState<string>(() => readHash(defaultId, validIds));
useEffect(() => {
function onHashChange() {
setActiveId(readHash(defaultId, validIds));
}
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [defaultId, validIds]);
const navigate = useCallback((id: string) => {
// replaceState: no history entry (Back button unaffected), no anchor-jump scroll
history.replaceState(null, '', '#' + id);
setActiveId(id);
}, []);
return [activeId, navigate];
}

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

@@ -0,0 +1,525 @@
{
"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",
"yes": "Oui",
"no": "Non",
"expand": "Déplier {{id}}",
"collapse": "Replier {{id}}",
"select": "Sélectionner"
},
"nav": {
"engagements": "Engagements",
"templates": "Templates",
"users": "Utilisateurs",
"signOut": "Se déconnecter",
"brand": "Mimic",
"brandHome": "Accueil Mimic",
"themeAria": "Thème : {{label}} — cliquer pour changer",
"footer": "Mimic — Outillage interne Purple Team. Engagements autorisés uniquement."
},
"export": {
"btn": "Exporter",
"btnLoading": "Export en cours…",
"aria": "Options d'export"
},
"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…",
"tab": {
"redTeam": "Red Team",
"soc": "SOC",
"c2": "Tâche C2"
}
},
"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",
"rowActions": "Plus d'options"
}
},
"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é pour {{username}}",
"roleUpdated": "Rôle mis à jour pour {{username}}"
},
"error": {
"create": "Impossible de créer l'utilisateur",
"delete": "Impossible de supprimer l'utilisateur",
"passwordReset": "Impossible de réinitialiser le mot de passe",
"roleUpdate": "Impossible de modifier le rôle",
"selfDelete": "Vous ne pouvez pas supprimer votre propre compte",
"passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères"
},
"deleteConfirm": "Supprimer l'utilisateur « {{username}} » ?",
"newPasswordFor": "Nouveau mot de passe pour {{username}}",
"newPassword": "Nouveau mot de passe",
"passwordHint": "≥ 8 caractères",
"createSection": "Créer un compte",
"allSection": "Tous les comptes",
"createSubtitle": "Les administrateurs peuvent créer des comptes Red Team ou SOC.",
"resetPassword": "Réinitialiser le 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",
"expand": "Déplier"
},
"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": "{{count}} tâche(s) C2 lancée(s)"
},
"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.",
"col": {
"timestamp": "Horodatage",
"completed": "Terminé"
},
"toast": {
"imported": "{{count}} tâche(s) importée(s)",
"partial": "{{imported}} importée(s), {{skipped}} déjà attachée(s)"
},
"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 :",
"col": {
"displayId": "ID",
"active": "Actif",
"host": "Hôte",
"user": "Utilisateur",
"domain": "Domaine",
"lastCheckin": "Dernière connexion"
},
"status": {
"active": "Actif",
"inactive": "Inactif"
}
}
}
},
"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",
"noMatch": "Aucun résultat"
},
"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

@@ -1,84 +1,109 @@
import { Link, useParams } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useEngagement } from '@/hooks/useEngagements'; import { useEngagement } from '@/hooks/useEngagements';
import { useHashTab } from '@/hooks/useHashTab';
import { useEngagementSimulations } from '@/hooks/useSimulations';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { StatusBadge } from '@/components/StatusBadge'; import { StatusBadge } from '@/components/StatusBadge';
import { SimulationList } from '@/components/SimulationList'; import { SimulationList } from '@/components/SimulationList';
import { ExportEngagementButton } from '@/components/ExportEngagementButton'; import { ExportEngagementButton } from '@/components/ExportEngagementButton';
import { BackLink } from '@/components/BackLink';
import { Tabs } from '@/components/Tabs';
import { formatDate } from '@/lib/format';
type TabId = 'description' | 'simulations';
export function EngagementDetailPage(): JSX.Element { export function EngagementDetailPage(): JSX.Element {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const numericId = id ? Number(id) : undefined; const numericId = id ? Number(id) : undefined;
const { canEditEngagements } = useAuth(); const { canEditEngagements } = useAuth();
const detail = useEngagement(numericId); const detail = useEngagement(numericId);
const simsQuery = useEngagementSimulations(numericId);
if (detail.isLoading) return <LoadingState label="Loading engagement…" />; const [activeTabRaw, setActiveTab] = useHashTab('description');
const activeTab = activeTabRaw as TabId;
if (detail.isLoading) return <LoadingState label={t('engagement.detail.loading')} />;
if (detail.isError) { if (detail.isError) {
return ( return (
<ErrorState <ErrorState
message={extractApiError(detail.error, 'Could not load engagement')} message={extractApiError(detail.error, t('engagement.detail.errorLoad'))}
onRetry={() => detail.refetch()} onRetry={() => detail.refetch()}
/> />
); );
} }
if (!detail.data) return <ErrorState message="Engagement not found" />; if (!detail.data) return <ErrorState message={t('engagement.detail.notFound')} />;
const eng = detail.data; const eng = detail.data;
const simCount = simsQuery.data?.length;
const tabs = [
{ id: 'description', label: t('engagement.detail.tabs.description') },
{ id: 'simulations', label: t('engagement.detail.tabs.simulations'), count: simCount },
];
return ( return (
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<Link to="/engagements" className="btn-text-link text-[14px]"> <BackLink to="/engagements">{t('engagement.detail.backTo')}</BackLink>
Back to engagements
</Link>
<h1 className="text-[32px] font-medium leading-none">{eng.name}</h1> <h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<StatusBadge status={eng.status} /> <StatusBadge status={eng.status} />
<span className="text-[14px] text-graphite"> <span className="text-[14px] text-graphite">
Created by <span className="text-ink">{eng.created_by.username}</span> {t('engagement.detail.createdBy')}{' '}
<span className="text-ink">{eng.created_by.username}</span>
</span> </span>
</div> </div>
</div> </div>
{canEditEngagements ? ( {canEditEngagements ? (
<div className="flex items-center gap-sm"> <ExportEngagementButton engagementId={eng.id} />
<ExportEngagementButton engagementId={eng.id} />
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
Edit
</Link>
</div>
) : null} ) : null}
</header> </header>
<section className="grid grid-cols-1 md:grid-cols-2 gap-md"> <Tabs items={tabs} activeId={activeTab} onChange={setActiveTab} />
<div className="card-product">
<h2 className="text-[20px] font-medium mb-md">Schedule</h2>
<dl className="grid grid-cols-2 gap-md text-[14px]">
<dt className="text-graphite">Start date</dt>
<dd className="text-ink font-mono">{eng.start_date}</dd>
<dt className="text-graphite">End date</dt>
<dd className="text-ink font-mono">{eng.end_date ?? '—'}</dd>
<dt className="text-graphite">Status</dt>
<dd className="text-ink capitalize">{eng.status}</dd>
<dt className="text-graphite">Created at</dt>
<dd className="text-ink font-mono">{eng.created_at}</dd>
</dl>
</div>
<div className="card-product"> <div
<h2 className="text-[20px] font-medium mb-md">Description</h2> role="tabpanel"
<p className="text-[16px] text-charcoal whitespace-pre-line"> id={`tabpanel-${activeTab}`}
{eng.description?.trim() ? eng.description : 'No description provided.'} aria-labelledby={`tab-${activeTab}`}
</p> >
</div> {activeTab === 'description' && (
</section> <div className="card-product flex flex-col gap-md">
<header className="flex items-start justify-between gap-md">
<dl className="grid grid-cols-2 gap-x-lg gap-y-xxs text-caption-md">
<dt className="text-graphite">{t('engagement.detail.schedule.startDate')}</dt>
<dd className="font-mono">{formatDate(eng.start_date)}</dd>
<dt className="text-graphite">{t('engagement.detail.schedule.endDate')}</dt>
<dd className="font-mono">{eng.end_date ? formatDate(eng.end_date) : '—'}</dd>
<dt className="text-graphite">{t('engagement.detail.schedule.status')}</dt>
<dd><StatusBadge status={eng.status} /></dd>
<dt className="text-graphite">{t('engagement.detail.schedule.createdAt')}</dt>
<dd className="font-mono">{formatDate(eng.created_at)}</dd>
</dl>
{canEditEngagements ? (
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline shrink-0">
{t('engagement.detail.edit')}
</Link>
) : null}
</header>
<hr className="border-hairline" />
<h2 className="text-display-sm">{t('engagement.detail.tabs.description')}</h2>
<p className="text-[16px] text-charcoal whitespace-pre-line">
{eng.description?.trim() ? eng.description : t('engagement.detail.noDescription')}
</p>
</div>
)}
<section> {activeTab === 'simulations' && (
<SimulationList engagementId={eng.id} /> <SimulationList engagementId={eng.id} />
</section> )}
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { EngagementInput, EngagementStatus } from '@/api/types'; import type { EngagementInput, EngagementStatus } from '@/api/types';
import { import {
@@ -13,12 +14,7 @@ import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { C2ConfigCard } from '@/components/C2ConfigCard'; import { C2ConfigCard } from '@/components/C2ConfigCard';
import { engagementStatusLabel } from '@/i18n/status';
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
{ value: 'planned', label: 'Planned' },
{ value: 'active', label: 'Active' },
{ value: 'closed', label: 'Closed' },
];
interface FormState { interface FormState {
name: string; name: string;
@@ -36,17 +32,8 @@ const EMPTY: FormState = {
status: 'planned', status: 'planned',
}; };
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
const errors: Partial<Record<keyof FormState, string>> = {};
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 { export function EngagementFormPage(): JSX.Element {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const editing = Boolean(id); const editing = Boolean(id);
const numericId = id ? Number(id) : undefined; const numericId = id ? Number(id) : undefined;
@@ -62,6 +49,22 @@ export function EngagementFormPage(): JSX.Element {
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(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<Record<keyof FormState, string>> {
const errs: Partial<Record<keyof FormState, string>> = {};
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. // Hydrate edit form when data arrives.
useEffect(() => { useEffect(() => {
if (editing && detail.data) { if (editing && detail.data) {
@@ -75,11 +78,11 @@ export function EngagementFormPage(): JSX.Element {
} }
}, [editing, detail.data]); }, [editing, detail.data]);
if (editing && detail.isLoading) return <LoadingState label="Loading engagement…" />; if (editing && detail.isLoading) return <LoadingState label={t('engagement.form.loading')} />;
if (editing && detail.isError) { if (editing && detail.isError) {
return ( return (
<ErrorState <ErrorState
message={extractApiError(detail.error, 'Could not load engagement')} message={extractApiError(detail.error, t('engagement.form.error.load'))}
onRetry={() => detail.refetch()} onRetry={() => detail.refetch()}
/> />
); );
@@ -109,15 +112,15 @@ export function EngagementFormPage(): JSX.Element {
try { try {
if (editing && numericId) { if (editing && numericId) {
await patchMutation.mutateAsync(payload); await patchMutation.mutateAsync(payload);
push('Engagement updated', 'success'); push(t('engagement.form.toast.updated'), 'success');
navigate(`/engagements/${numericId}`); navigate(`/engagements/${numericId}`);
} else { } else {
const created = await createMutation.mutateAsync(payload); const created = await createMutation.mutateAsync(payload);
push('Engagement created', 'success'); push(t('engagement.form.toast.created'), 'success');
navigate(`/engagements/${created.id}`); navigate(`/engagements/${created.id}`);
} }
} catch (err) { } 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 {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header> <header>
<h1 className="text-[32px] font-medium leading-none"> <h1 className="text-[32px] font-medium leading-none">
{editing ? 'Edit engagement' : 'New engagement'} {editing ? t('engagement.form.title.edit') : t('engagement.form.title.new')}
</h1> </h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
{editing {editing ? t('engagement.form.subtitle.edit') : t('engagement.form.subtitle.new')}
? 'Update the engagement metadata.'
: 'Create a new red team mission to host simulations.'}
</p> </p>
</header> </header>
@@ -144,7 +145,7 @@ export function EngagementFormPage(): JSX.Element {
} }
> >
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md"> <form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
<FormField label="Name" htmlFor="eng-name" required error={errors.name}> <FormField label={t('engagement.form.field.name')} htmlFor="eng-name" required error={errors.name}>
<TextInput <TextInput
id="eng-name" id="eng-name"
name="name" name="name"
@@ -154,7 +155,7 @@ export function EngagementFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Description" htmlFor="eng-description"> <FormField label={t('engagement.form.field.description')} htmlFor="eng-description">
<TextArea <TextArea
id="eng-description" id="eng-description"
name="description" name="description"
@@ -165,7 +166,7 @@ export function EngagementFormPage(): JSX.Element {
<div className="grid grid-cols-1 md:grid-cols-2 gap-md"> <div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField <FormField
label="Start date" label={t('engagement.form.field.startDate')}
htmlFor="eng-start" htmlFor="eng-start"
required required
error={errors.start_date} error={errors.start_date}
@@ -181,9 +182,9 @@ export function EngagementFormPage(): JSX.Element {
</FormField> </FormField>
<FormField <FormField
label="End date" label={t('engagement.form.field.endDate')}
htmlFor="eng-end" htmlFor="eng-end"
hint="Leave empty to clear / leave open-ended" hint={t('engagement.form.field.endDateHint')}
error={errors.end_date} error={errors.end_date}
> >
<TextInput <TextInput
@@ -196,7 +197,7 @@ export function EngagementFormPage(): JSX.Element {
</FormField> </FormField>
</div> </div>
<FormField label="Status" htmlFor="eng-status" required> <FormField label={t('engagement.form.field.status')} htmlFor="eng-status" required>
<Select <Select
id="eng-status" id="eng-status"
name="status" name="status"
@@ -213,14 +214,18 @@ export function EngagementFormPage(): JSX.Element {
) : null} ) : null}
<div className="flex items-center gap-md pt-sm"> <div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}> <button type="submit" className="btn-primary" disabled={submitting} data-testid="btn-submit">
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'} {submitting
? t('engagement.form.btn.saving')
: editing
? t('engagement.form.btn.save')
: t('engagement.form.btn.create')}
</button> </button>
<Link <Link
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'} to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
className="btn-outline-ink" className="btn-outline-ink"
> >
Cancel {t('engagement.form.btn.cancel')}
</Link> </Link>
</div> </div>
</form> </form>

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { Engagement } from '@/api/types'; import type { Engagement } from '@/api/types';
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements'; import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
@@ -9,25 +10,22 @@ import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { EmptyState } from '@/components/EmptyState'; import { EmptyState } from '@/components/EmptyState';
import { StatusBadge } from '@/components/StatusBadge'; import { StatusBadge } from '@/components/StatusBadge';
import { formatDate } from '@/lib/format';
function formatDate(value: string | null): string {
if (!value) return '—';
return value;
}
export function EngagementsListPage(): JSX.Element { export function EngagementsListPage(): JSX.Element {
const { t } = useTranslation();
const { data, isLoading, isError, error, refetch } = useEngagementsList(); const { data, isLoading, isError, error, refetch } = useEngagementsList();
const { canEditEngagements } = useAuth(); const { canEditEngagements } = useAuth();
const { push } = useToast(); const { push } = useToast();
const deleteMutation = useDeleteEngagement(); const deleteMutation = useDeleteEngagement();
const onDelete = async (eng: Engagement) => { const onDelete = async (eng: Engagement) => {
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return; if (!window.confirm(t('engagement.list.deleteConfirm', { name: eng.name }))) return;
try { try {
await deleteMutation.mutateAsync(eng.id); await deleteMutation.mutateAsync(eng.id);
push('Engagement deleted', 'success'); push(t('engagement.list.toast.deleted'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not delete engagement'), 'error'); push(extractApiError(err, t('engagement.list.error.delete')), 'error');
} }
}; };
@@ -35,32 +33,32 @@ export function EngagementsListPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-end justify-between gap-md"> <header className="flex items-end justify-between gap-md">
<div> <div>
<h1 className="text-[32px] font-medium leading-none">Engagements</h1> <h1 className="text-[32px] font-medium leading-none">{t('engagement.list.title')}</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Red team missions and their lifecycle status. {t('engagement.list.subtitle')}
</p> </p>
</div> </div>
{canEditEngagements ? ( {canEditEngagements ? (
<Link to="/engagements/new" className="btn-primary"> <Link to="/engagements/new" className="btn-primary">
<Plus size={14} aria-hidden /> New <Plus size={14} aria-hidden /> {t('engagement.list.new')}
</Link> </Link>
) : null} ) : null}
</header> </header>
{isLoading ? <LoadingState label="Loading engagements…" /> : null} {isLoading ? <LoadingState label={t('common.loading')} /> : null}
{isError ? ( {isError ? (
<ErrorState message={extractApiError(error, 'Could not load engagements')} onRetry={() => refetch()} /> <ErrorState message={extractApiError(error, t('engagement.list.error.load'))} onRetry={() => refetch()} />
) : null} ) : null}
{!isLoading && !isError && data && data.length === 0 ? ( {!isLoading && !isError && data && data.length === 0 ? (
<EmptyState <EmptyState
title="No engagements yet" title={t('engagement.list.empty.title')}
description="Create your first engagement to start tracking red team missions." description={t('engagement.list.empty.desc')}
action={ action={
canEditEngagements ? ( canEditEngagements ? (
<Link to="/engagements/new" className="btn-primary"> <Link to="/engagements/new" className="btn-primary">
<Plus size={14} aria-hidden /> New <Plus size={14} aria-hidden /> {t('engagement.list.new')}
</Link> </Link>
) : undefined ) : undefined
} }
@@ -69,40 +67,40 @@ export function EngagementsListPage(): JSX.Element {
{!isLoading && !isError && data && data.length > 0 ? ( {!isLoading && !isError && data && data.length > 0 ? (
<div className="card-product overflow-hidden p-0"> <div className="card-product overflow-hidden p-0">
<table className="w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr>
<th className="px-xl py-md">Name</th> <th>{t('engagement.list.col.name')}</th>
<th className="px-xl py-md">Status</th> <th>{t('engagement.list.col.status')}</th>
<th className="px-xl py-md">Start</th> <th>{t('engagement.list.col.start')}</th>
<th className="px-xl py-md">End</th> <th>{t('engagement.list.col.end')}</th>
<th className="px-xl py-md">Created by</th> <th>{t('engagement.list.col.createdBy')}</th>
<th className="px-xl py-md text-right">Actions</th> <th className="text-right">{t('engagement.list.col.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.map((eng) => ( {data.map((eng) => (
<tr key={eng.id} className="border-b border-hairline last:border-0"> <tr key={eng.id}>
<td className="px-xl py-md"> <td>
<Link to={`/engagements/${eng.id}`} className="text-ink font-medium hover:underline"> <Link to={`/engagements/${eng.id}`} className="text-ink font-medium hover:underline">
{eng.name} {eng.name}
</Link> </Link>
</td> </td>
<td className="px-xl py-md"> <td>
<StatusBadge status={eng.status} /> <StatusBadge status={eng.status} />
</td> </td>
<td className="px-xl py-md text-charcoal font-mono">{formatDate(eng.start_date)}</td> <td className="text-charcoal font-mono">{formatDate(eng.start_date)}</td>
<td className="px-xl py-md text-charcoal font-mono">{formatDate(eng.end_date)}</td> <td className="text-charcoal font-mono">{formatDate(eng.end_date)}</td>
<td className="px-xl py-md text-charcoal">{eng.created_by.username}</td> <td className="text-charcoal">{eng.created_by.username}</td>
<td className="px-xl py-md text-right"> <td className="text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<Link to={`/engagements/${eng.id}`} className="btn-text-link"> <Link to={`/engagements/${eng.id}`} className="btn-text-link">
View {t('engagement.list.view')}
</Link> </Link>
{canEditEngagements ? ( {canEditEngagements ? (
<> <>
<Link to={`/engagements/${eng.id}/edit`} className="btn-text-link"> <Link to={`/engagements/${eng.id}/edit`} className="btn-text-link">
Edit {t('engagement.list.edit')}
</Link> </Link>
<button <button
type="button" type="button"
@@ -110,7 +108,7 @@ export function EngagementsListPage(): JSX.Element {
onClick={() => onDelete(eng)} onClick={() => onDelete(eng)}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
Delete {t('engagement.list.delete')}
</button> </button>
</> </>
) : null} ) : null}

View File

@@ -1,5 +1,6 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { FormField, TextInput } from '@/components/FormField'; import { FormField, TextInput } from '@/components/FormField';
@@ -14,6 +15,7 @@ export function LoginPage(): JSX.Element {
const location = useLocation(); const location = useLocation();
const fromPath = (location.state as LocationState | null)?.from; const fromPath = (location.state as LocationState | null)?.from;
const { t } = useTranslation();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -34,7 +36,7 @@ export function LoginPage(): JSX.Element {
await login(username, password); await login(username, password);
navigate(fromPath ?? '/engagements', { replace: true }); navigate(fromPath ?? '/engagements', { replace: true });
} catch (err) { } catch (err) {
setError(extractApiError(err, 'Invalid credentials')); setError(extractApiError(err, t('auth.login.invalid')));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -48,10 +50,10 @@ export function LoginPage(): JSX.Element {
<span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden /> <span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden />
<h1 className="text-[28px] font-medium leading-none">Mimic</h1> <h1 className="text-[28px] font-medium leading-none">Mimic</h1>
</div> </div>
<p className="text-[16px] text-charcoal">Sign in to access your engagements.</p> <p className="text-[16px] text-charcoal">{t('auth.login.subtitle')}</p>
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-md"> <form onSubmit={onSubmit} noValidate className="flex flex-col gap-md">
<FormField label="Username" htmlFor="login-username" required> <FormField label={t('auth.login.username')} htmlFor="login-username" required>
<TextInput <TextInput
id="login-username" id="login-username"
name="username" name="username"
@@ -62,7 +64,7 @@ export function LoginPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Password" htmlFor="login-password" required> <FormField label={t('auth.login.password')} htmlFor="login-password" required>
<TextInput <TextInput
id="login-password" id="login-password"
type="password" type="password"
@@ -81,7 +83,7 @@ export function LoginPage(): JSX.Element {
) : null} ) : null}
<button type="submit" className="btn-primary" disabled={submitting}> <button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'} {submitting ? t('auth.login.signingIn') : t('auth.login.signIn')}
</button> </button>
</form> </form>
</div> </div>

View File

@@ -1,10 +1,12 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { Save, RotateCcw } from 'lucide-react'; import { Save, RotateCcw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { SimulationPatchInput } from '@/api/types'; import type { SimulationPatchInput } from '@/api/types';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useHashTab } from '@/hooks/useHashTab';
import { import {
useCreateSimulation, useCreateSimulation,
useDeleteSimulation, useDeleteSimulation,
@@ -22,6 +24,11 @@ import { MitreTechniquesField } from '@/components/MitreTechniquesField';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal'; import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal'; import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
import { C2TasksPanel } from '@/components/C2TasksPanel'; import { C2TasksPanel } from '@/components/C2TasksPanel';
import { AlertBanner } from '@/components/AlertBanner';
import { BackLink } from '@/components/BackLink';
import { Tabs } from '@/components/Tabs';
type TabId = 'red-team' | 'soc' | 'c2';
interface RedteamFormState { interface RedteamFormState {
name: string; name: string;
@@ -56,6 +63,7 @@ const EMPTY_SOC: SocFormState = {
}; };
export function SimulationFormPage(): JSX.Element { export function SimulationFormPage(): JSX.Element {
const { t } = useTranslation();
const { eid, sid } = useParams<{ eid: string; sid: string }>(); const { eid, sid } = useParams<{ eid: string; sid: string }>();
const engagementId = eid ? Number(eid) : undefined; const engagementId = eid ? Number(eid) : undefined;
const simulationId = sid ? Number(sid) : undefined; const simulationId = sid ? Number(sid) : undefined;
@@ -75,9 +83,7 @@ export function SimulationFormPage(): JSX.Element {
const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, { const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, {
enabled: !isNew && canEditRT, enabled: !isNew && canEditRT,
}); });
const hasTasks = (c2TasksQuery.data?.tasks?.length ?? 0) > 0; const c2TaskCount = c2TasksQuery.data?.tasks?.length ?? 0;
// Show panel when: has C2 config (so Execute button is visible) OR already has tasks
const showTasksPanel = !isNew && canEditRT && (hasC2Config || hasTasks);
const detail = useSimulation(isNew ? undefined : simulationId); const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0); const createMutation = useCreateSimulation(engagementId ?? 0);
@@ -93,6 +99,26 @@ export function SimulationFormPage(): JSX.Element {
const [showC2Modal, setShowC2Modal] = useState(false); const [showC2Modal, setShowC2Modal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
// All hooks must be called unconditionally before any early returns.
const [activeTab, setActiveTab] = useHashTab('red-team');
// Compute disabled flags early using detail.data (may be undefined during load — that's fine).
const loadedStatus = detail.data?.status;
const earlySOCDisabled = isNew || (loadedStatus !== 'review_required' && loadedStatus !== 'done');
const earlyC2Disabled = isNew || isSoc;
const earlyHash = activeTab;
const earlySafeTab =
(earlyHash === 'soc' && earlySOCDisabled) || (earlyHash === 'c2' && earlyC2Disabled)
? 'red-team'
: earlyHash;
// Sync window.location.hash when the active hash points to a disabled tab
useEffect(() => {
if (earlySafeTab !== activeTab) {
setActiveTab(earlySafeTab);
}
}, [earlySafeTab, activeTab, setActiveTab]);
useEffect(() => { useEffect(() => {
if (!isNew && detail.data) { if (!isNew && detail.data) {
const s = detail.data; const s = detail.data;
@@ -113,11 +139,11 @@ export function SimulationFormPage(): JSX.Element {
} }
}, [isNew, detail.data]); }, [isNew, detail.data]);
if (!isNew && detail.isLoading) return <LoadingState label="Loading simulation…" />; if (!isNew && detail.isLoading) return <LoadingState label={t('simulation.form.loading')} />;
if (!isNew && detail.isError) { if (!isNew && detail.isError) {
return ( return (
<ErrorState <ErrorState
message={extractApiError(detail.error, 'Could not load simulation')} message={extractApiError(detail.error, t('simulation.form.error.load'))}
onRetry={() => detail.refetch()} onRetry={() => detail.refetch()}
/> />
); );
@@ -126,12 +152,8 @@ export function SimulationFormPage(): JSX.Element {
const simulation = detail.data; const simulation = detail.data;
const status = simulation?.status; const status = simulation?.status;
// US-18: Done = fully read-only, Reopen only
const isDone = status === 'done'; const isDone = status === 'done';
const socCanEdit = isSoc && (status === 'review_required' || status === 'done'); const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements); const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
const rtDisabled = !canEditRT || isDone; const rtDisabled = !canEditRT || isDone;
const socDisabled = isDone || (!canEditEngagements && !socCanEdit); const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
@@ -142,17 +164,22 @@ export function SimulationFormPage(): JSX.Element {
!isDone && (canEditEngagements || isSoc) && status === 'review_required'; !isDone && (canEditEngagements || isSoc) && status === 'review_required';
const showReopen = isDone && (isAdmin || isRedteam || isSoc); const showReopen = isDone && (isAdmin || isRedteam || isSoc);
// Post-load versions (same logic, now with confirmed status)
const socTabDisabled = earlySOCDisabled;
const c2TabDisabled = earlyC2Disabled;
const safeTab = earlySafeTab;
const onSubmitNew = async (e: FormEvent) => { const onSubmitNew = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setNameError(null); setNameError(null);
setSubmitError(null); setSubmitError(null);
if (!rt.name.trim()) { setNameError('Name is required'); return; } if (!rt.name.trim()) { setNameError(t('simulation.form.validation.nameRequired')); return; }
try { try {
const created = await createMutation.mutateAsync({ name: rt.name.trim() }); const created = await createMutation.mutateAsync({ name: rt.name.trim() });
push('Simulation created', 'success'); push(t('simulation.form.toast.created'), 'success');
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`); navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not create simulation')); setSubmitError(extractApiError(err, t('simulation.form.error.create')));
} }
}; };
@@ -160,7 +187,7 @@ export function SimulationFormPage(): JSX.Element {
e.preventDefault(); e.preventDefault();
setNameError(null); setNameError(null);
setSubmitError(null); setSubmitError(null);
if (!rt.name.trim()) { setNameError('Name is required'); return; } if (!rt.name.trim()) { setNameError(t('simulation.form.validation.nameRequired')); return; }
const patch: SimulationPatchInput = { const patch: SimulationPatchInput = {
name: rt.name.trim(), name: rt.name.trim(),
description: rt.description.trim() || null, description: rt.description.trim() || null,
@@ -171,9 +198,9 @@ export function SimulationFormPage(): JSX.Element {
}; };
try { try {
await updateMutation.mutateAsync(patch); await updateMutation.mutateAsync(patch);
push('Simulation updated', 'success'); push(t('simulation.form.toast.updated'), 'success');
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not update simulation')); setSubmitError(extractApiError(err, t('simulation.form.error.update')));
} }
}; };
@@ -188,36 +215,36 @@ export function SimulationFormPage(): JSX.Element {
}; };
try { try {
await updateMutation.mutateAsync(patch); await updateMutation.mutateAsync(patch);
push('SOC report updated', 'success'); push(t('simulation.form.toast.socUpdated'), 'success');
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not update SOC fields')); setSubmitError(extractApiError(err, t('simulation.form.error.soc')));
} }
}; };
const onMarkReview = async () => { const onMarkReview = async () => {
try { try {
await transitionMutation.mutateAsync('review_required'); await transitionMutation.mutateAsync('review_required');
push('Simulation marked for review', 'success'); push(t('simulation.form.toast.markedReview'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Transition failed'), 'error'); push(extractApiError(err, t('simulation.form.error.transition')), 'error');
} }
}; };
const onClose = async () => { const onClose = async () => {
try { try {
await transitionMutation.mutateAsync('done'); await transitionMutation.mutateAsync('done');
push('Simulation closed', 'success'); push(t('simulation.form.toast.closed'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Transition failed'), 'error'); push(extractApiError(err, t('simulation.form.error.transition')), 'error');
} }
}; };
const onReopen = async () => { const onReopen = async () => {
try { try {
await transitionMutation.mutateAsync('review_required'); await transitionMutation.mutateAsync('review_required');
push('Simulation reopened', 'success'); push(t('simulation.form.toast.reopened'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Transition failed'), 'error'); push(extractApiError(err, t('simulation.form.error.transition')), 'error');
} }
}; };
@@ -225,27 +252,45 @@ export function SimulationFormPage(): JSX.Element {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
try { try {
await deleteMutation.mutateAsync(simulationId as number); await deleteMutation.mutateAsync(simulationId as number);
push('Simulation deleted', 'success'); push(t('simulation.form.toast.deleted'), 'success');
navigate(`/engagements/${engagementId}`); navigate(`/engagements/${engagementId}`);
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not delete simulation'), 'error'); push(extractApiError(err, t('simulation.form.error.delete')), 'error');
} }
}; };
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
const tabItems = [
{ id: 'red-team' as TabId, label: t('simulation.form.tab.redTeam') },
{
id: 'soc' as TabId,
label: t('simulation.form.tab.soc'),
disabled: socTabDisabled,
},
{
id: 'c2' as TabId,
label: t('simulation.form.tab.c2'),
count: !isNew && c2TaskCount > 0 ? c2TaskCount : undefined,
disabled: c2TabDisabled,
},
];
// New simulation form // New simulation form
if (isNew) { if (isNew) {
const submitting = createMutation.isPending; const creating = createMutation.isPending;
return ( return (
<div className="flex flex-col gap-xl max-w-2xl"> <div className="flex flex-col gap-xl max-w-2xl">
<header> <header>
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <BackLink to={`/engagements/${engagementId}`}>{t('engagement.detail.backTo')}</BackLink>
Back to engagement <h1 className="text-[32px] font-medium leading-none mt-sm">{t('simulation.form.title.new')}</h1>
</Link>
<h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1>
</header> </header>
<Tabs items={tabItems} activeId="red-team" onChange={() => {}} />
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md"> <form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
<FormField label="Name" htmlFor="sim-name" required error={nameError}> <FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
<TextInput <TextInput
id="sim-name" id="sim-name"
name="name" name="name"
@@ -255,40 +300,47 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
<MitreTechniquesField
value={[]}
tactics={[]}
simulationId={0}
engagementId={engagementId ?? 0}
disabled
/>
</div>
{submitError ? ( {submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div> <div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
) : null} ) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create simulation'}
</button>
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
Cancel
</Link>
</div>
</form> </form>
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
<button type="button" className="btn-primary" disabled={creating} data-testid="create-sim-btn" onClick={onSubmitNew}>
{creating ? t('simulation.form.btn.creating') : t('simulation.form.btn.create')}
</button>
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
{t('simulation.form.btn.cancel')}
</Link>
</div>
</div> </div>
); );
} }
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
return ( return (
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <BackLink to={`/engagements/${engagementId}`}>{t('engagement.detail.backTo')}</BackLink>
Back to engagement
</Link>
<h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1> <h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? ( {status ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<SimulationStatusBadge status={status} /> <SimulationStatusBadge status={status} />
{simulation?.created_by && ( {simulation?.created_by && (
<span className="text-[14px] text-graphite"> <span className="text-[14px] text-graphite">
Created by <span className="text-ink">{simulation.created_by.username}</span> {t('engagement.detail.createdBy')}{' '}
<span className="text-ink">{simulation.created_by.username}</span>
</span> </span>
)} )}
</div> </div>
@@ -296,265 +348,264 @@ export function SimulationFormPage(): JSX.Element {
</div> </div>
</header> </header>
{/* Done banner */} {/* Done banner — above tabs, global status info */}
{isDone && ( {isDone && (
<div <AlertBanner variant="success">
role="status" {t('simulation.form.banner.done')}
className="rounded-none px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal" </AlertBanner>
>
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
</div>
)} )}
{/* SOC banner */} <Tabs items={tabItems} activeId={safeTab} onChange={setActiveTab} />
{socBlocked && (
<div
role="alert"
data-testid="soc-blocked-banner"
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
>
Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section.
</div>
)}
{/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */} <div
<div className="grid gap-xl lg:grid-cols-2 items-start"> role="tabpanel"
{/* Left column: RT card + C2 tasks panel */} id={`tabpanel-${safeTab}`}
<div className="flex flex-col gap-xl"> aria-labelledby={`tab-${safeTab}`}
{/* Red Team card */} >
<form {/* ── Red Team tab ──────────────────────────────────────── */}
id="rt-form" {safeTab === 'red-team' && (
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()} <form
noValidate id="rt-form"
className="card-product flex flex-col gap-md" onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
> noValidate
<h2 className="text-[20px] font-medium text-ink">Red Team</h2> className="card-product flex flex-col gap-md"
>
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
<TextInput
id="sim-name"
name="name"
value={rt.name}
onChange={(e) => setRt({ ...rt, name: e.target.value })}
disabled={rtDisabled}
required
/>
</FormField>
<FormField label="Name" htmlFor="sim-name" required error={nameError}> <div className="flex flex-col gap-xs">
<TextInput <span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
id="sim-name" <MitreTechniquesField
name="name" value={simulation?.techniques ?? []}
value={rt.name} tactics={simulation?.tactics ?? []}
onChange={(e) => setRt({ ...rt, name: e.target.value })} simulationId={simulationId as number}
disabled={rtDisabled} engagementId={engagementId as number}
required disabled={rtDisabled}
/> />
</FormField>
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">MITRE Techniques &amp; Tactics</span>
<MitreTechniquesField
value={simulation?.techniques ?? []}
tactics={simulation?.tactics ?? []}
simulationId={simulationId as number}
engagementId={engagementId as number}
disabled={rtDisabled}
/>
</div>
<FormField label="Description" htmlFor="sim-description">
<TextArea
id="sim-description"
name="description"
value={rt.description}
onChange={(e) => setRt({ ...rt, description: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Commands" htmlFor="sim-commands" hint="One command per line">
<TextArea
id="sim-commands"
name="commands"
value={rt.commands}
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
disabled={rtDisabled}
className="min-h-[160px] font-mono text-[14px]"
/>
</FormField>
<FormField label="Prerequisites" htmlFor="sim-prerequisites">
<TextArea
id="sim-prerequisites"
name="prerequisites"
value={rt.prerequisites}
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Executed at" htmlFor="sim-executed-at">
<TextInput
id="sim-executed-at"
type="datetime-local"
name="executed_at"
value={rt.executed_at}
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Execution result" htmlFor="sim-exec-result">
<TextArea
id="sim-exec-result"
name="execution_result"
value={rt.execution_result}
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
disabled={rtDisabled}
rows={5}
/>
</FormField>
{!isDone && canEditRT && hasC2Config && (
<div className="pt-xs flex items-center gap-md flex-wrap">
<button
type="button"
data-testid="c2-execute-btn"
className="btn-outline"
onClick={() => setShowC2Modal(true)}
>
Execute via C2
</button>
<button
type="button"
data-testid="c2-import-trigger-btn"
className="btn-outline"
onClick={() => setShowImportModal(true)}
>
Import C2 history
</button>
</div> </div>
)}
</form>
{/* C2 tasks panel — under RT card, same left column */} <FormField label={t('simulation.form.field.description')} htmlFor="sim-description">
{showTasksPanel && simulationId && ( <TextArea
id="sim-description"
name="description"
value={rt.description}
onChange={(e) => setRt({ ...rt, description: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label={t('simulation.form.field.commands')} htmlFor="sim-commands" hint={t('simulation.form.field.commandsHint')}>
<TextArea
id="sim-commands"
name="commands"
value={rt.commands}
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
disabled={rtDisabled}
className="min-h-[160px] font-mono text-[14px]"
/>
</FormField>
<FormField label={t('simulation.form.field.prerequisites')} htmlFor="sim-prerequisites">
<TextArea
id="sim-prerequisites"
name="prerequisites"
value={rt.prerequisites}
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label={t('simulation.form.field.executedAt')} htmlFor="sim-executed-at">
<TextInput
id="sim-executed-at"
type="datetime-local"
name="executed_at"
value={rt.executed_at}
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label={t('simulation.form.field.executionResult')} htmlFor="sim-exec-result">
<TextArea
id="sim-exec-result"
name="execution_result"
value={rt.execution_result}
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
disabled={rtDisabled}
rows={5}
/>
</FormField>
{!isDone && canEditRT && hasC2Config && (
<div className="pt-xs flex items-center gap-md flex-wrap">
<button
type="button"
data-testid="c2-execute-btn"
className="btn-outline"
onClick={() => setShowC2Modal(true)}
>
{t('simulation.form.btn.executeC2')}
</button>
<button
type="button"
data-testid="c2-import-trigger-btn"
className="btn-outline"
onClick={() => setShowImportModal(true)}
>
{t('simulation.form.btn.importC2History')}
</button>
</div>
)}
</form>
)}
{/* ── SOC tab ───────────────────────────────────────────── */}
{safeTab === 'soc' && (
<form
id="soc-form"
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<FormField label={t('simulation.form.field.logSource')} htmlFor="sim-log-source">
<TextInput
id="sim-log-source"
name="log_source"
value={soc.log_source}
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label={t('simulation.form.field.logs')} htmlFor="sim-logs">
<TextArea
id="sim-logs"
name="logs"
value={soc.logs}
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label={t('simulation.form.field.socComment')} htmlFor="sim-soc-comment">
<TextArea
id="sim-soc-comment"
name="soc_comment"
value={soc.soc_comment}
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label={t('simulation.form.field.incidentNumber')} htmlFor="sim-incident">
<TextInput
id="sim-incident"
name="incident_number"
value={soc.incident_number}
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })}
disabled={socDisabled}
/>
</FormField>
</form>
)}
{/* ── C2 tab ────────────────────────────────────────────── */}
{safeTab === 'c2' && simulationId && (
<C2TasksPanel simulationId={simulationId} /> <C2TasksPanel simulationId={simulationId} />
)} )}
</div>{/* end left column */}
{/* SOC card */}
<form
id="soc-form"
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">SOC</h2>
<FormField label="Log source" htmlFor="sim-log-source">
<TextInput
id="sim-log-source"
name="log_source"
value={soc.log_source}
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Logs" htmlFor="sim-logs">
<TextArea
id="sim-logs"
name="logs"
value={soc.logs}
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="SOC comment" htmlFor="sim-soc-comment">
<TextArea
id="sim-soc-comment"
name="soc_comment"
value={soc.soc_comment}
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Incident number" htmlFor="sim-incident">
<TextInput
id="sim-incident"
name="incident_number"
value={soc.incident_number}
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })}
disabled={socDisabled}
/>
</FormField>
</form>
</div> </div>
{submitError ? ( {submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div> <div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
) : null} ) : null}
{/* Unified sticky action bar */} {/* Contextual sticky bar — not rendered at all for C2 tab */}
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md"> {safeTab !== 'c2' && (
{/* Done state: Reopen only */} <div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
{showReopen && ( {safeTab === 'red-team' && (
<button <>
type="button" {!isDone && canEditRT && (
className="btn-outline" <button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
onClick={onReopen} <Save size={14} aria-hidden />
disabled={transitionMutation.isPending} {updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.save')}
data-testid="reopen-btn" </button>
> )}
<RotateCcw size={14} aria-hidden /> {showMarkReview && (
Reopen <button
</button> type="button"
)} className="btn-outline"
onClick={onMarkReview}
disabled={transitionMutation.isPending}
data-testid="mark-review-btn"
>
{t('simulation.form.btn.markReview')}
</button>
)}
{!isDone && canEditEngagements && simulationId && (
<button
type="button"
className="btn-text-link text-bloom-deep ml-auto"
onClick={() => setShowDeleteConfirm(true)}
disabled={submitting}
data-testid="delete-btn"
>
{t('simulation.form.btn.delete')}
</button>
)}
</>
)}
{/* Normal state buttons */} {safeTab === 'soc' && (
{!isDone && canEditRT && ( <>
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}> {!isDone && canSaveSoc && (
<Save size={14} aria-hidden /> <button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save'} <Save size={14} aria-hidden />
</button> {updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.saveSoc')}
)} </button>
{!isDone && canSaveSoc && ( )}
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}> {showClose && (
<Save size={14} aria-hidden /> <button
{updateMutation.isPending ? 'Saving…' : 'Save SOC'} type="button"
</button> className="btn-outline"
)} onClick={onClose}
{showMarkReview && ( disabled={transitionMutation.isPending}
<button data-testid="close-btn"
type="button" >
className="btn-outline" {t('simulation.form.btn.close')}
onClick={onMarkReview} </button>
disabled={transitionMutation.isPending} )}
> {showReopen && (
Mark for review <button
</button> type="button"
)} className="btn-outline"
{showClose && ( onClick={onReopen}
<button disabled={transitionMutation.isPending}
type="button" data-testid="reopen-btn"
className="btn-outline" >
onClick={onClose} <RotateCcw size={14} aria-hidden />
disabled={transitionMutation.isPending} {t('simulation.form.btn.reopen')}
> </button>
Close )}
</button> </>
)} )}
{!isDone && canEditEngagements && simulationId && ( </div>
<button )}
type="button"
className="btn-text-link text-bloom-deep ml-auto"
onClick={() => setShowDeleteConfirm(true)}
disabled={submitting}
>
Delete
</button>
)}
</div>
{showDeleteConfirm && ( {showDeleteConfirm && (
<ConfirmDialog <ConfirmDialog
title="Delete simulation" title={t('simulation.form.deleteConfirm.title')}
description="This action is permanent. The simulation will be deleted forever." description={t('simulation.form.deleteConfirm.desc')}
confirmLabel="Delete" confirmLabel={t('simulation.form.deleteConfirm.confirm')}
cancelLabel="Cancel" cancelLabel={t('simulation.form.deleteConfirm.cancel')}
destructive destructive
onConfirm={onDelete} onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { Save, Grid2x2 } from 'lucide-react'; import { Save, Grid2x2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { MitreTechnique, MitreTacticRef } from '@/api/types'; import type { MitreTechnique, MitreTacticRef } from '@/api/types';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@@ -9,6 +10,7 @@ import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { BackLink } from '@/components/BackLink';
import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag'; import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag';
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker'; import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
import { MitreMatrixModal } from '@/components/MitreMatrixModal'; import { MitreMatrixModal } from '@/components/MitreMatrixModal';
@@ -24,6 +26,7 @@ interface FormState {
const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' }; const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' };
export function TemplateFormPage(): JSX.Element { export function TemplateFormPage(): JSX.Element {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const templateId = id ? Number(id) : undefined; const templateId = id ? Number(id) : undefined;
const isNew = !templateId; const isNew = !templateId;
@@ -46,15 +49,15 @@ export function TemplateFormPage(): JSX.Element {
useEffect(() => { useEffect(() => {
if (existing.data) { if (existing.data) {
const t = existing.data; const tpl = existing.data;
setForm({ setForm({
name: t.name, name: tpl.name,
description: t.description ?? '', description: tpl.description ?? '',
commands: t.commands ?? '', commands: tpl.commands ?? '',
prerequisites: t.prerequisites ?? '', prerequisites: tpl.prerequisites ?? '',
}); });
setTechniques(t.techniques); setTechniques(tpl.techniques);
setTactics(t.tactics); setTactics(tpl.tactics);
} }
}, [existing.data]); }, [existing.data]);
@@ -64,7 +67,7 @@ export function TemplateFormPage(): JSX.Element {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
if (!form.name.trim()) { if (!form.name.trim()) {
setFormError('Name is required'); setFormError(t('template.form.validation.nameRequired'));
return; return;
} }
const payload = { const payload = {
@@ -72,20 +75,20 @@ export function TemplateFormPage(): JSX.Element {
description: form.description.trim() || null, description: form.description.trim() || null,
commands: form.commands.trim() || null, commands: form.commands.trim() || null,
prerequisites: form.prerequisites.trim() || null, prerequisites: form.prerequisites.trim() || null,
technique_ids: techniques.map((t) => t.id), technique_ids: techniques.map((tech) => tech.id),
tactic_ids: tactics.map((t) => t.id), tactic_ids: tactics.map((tac) => tac.id),
}; };
try { try {
if (isNew) { if (isNew) {
const created = await createMutation.mutateAsync(payload); const created = await createMutation.mutateAsync(payload);
push('Template created', 'success'); push(t('template.form.toast.created'), 'success');
navigate(`/admin/templates/${created.id}/edit`, { replace: true }); navigate(`/admin/templates/${created.id}/edit`, { replace: true });
} else { } else {
await updateMutation.mutateAsync(payload); await updateMutation.mutateAsync(payload);
push('Template saved', 'success'); push(t('template.form.toast.saved'), 'success');
} }
} catch (err) { } catch (err) {
setFormError(extractApiError(err, 'Could not save template')); setFormError(extractApiError(err, t('template.form.errorSave')));
} }
}; };
@@ -93,10 +96,10 @@ export function TemplateFormPage(): JSX.Element {
if (!templateId) return; if (!templateId) return;
try { try {
await deleteMutation.mutateAsync(templateId); await deleteMutation.mutateAsync(templateId);
push('Template deleted', 'success'); push(t('template.form.toast.deleted'), 'success');
navigate('/admin/templates', { replace: true }); navigate('/admin/templates', { replace: true });
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not delete template'), 'error'); push(extractApiError(err, t('template.form.errorDelete')), 'error');
} }
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
}; };
@@ -108,16 +111,16 @@ export function TemplateFormPage(): JSX.Element {
}; };
const handlePickerSelect = (technique: MitreTechnique) => { const handlePickerSelect = (technique: MitreTechnique) => {
if (techniques.some((t) => t.id === technique.id)) return; if (techniques.some((tech) => tech.id === technique.id)) return;
setTechniques((prev) => [...prev, technique]); setTechniques((prev) => [...prev, technique]);
setShowPicker(false); setShowPicker(false);
}; };
if (!isNew && existing.isLoading) return <LoadingState label="Loading template…" />; if (!isNew && existing.isLoading) return <LoadingState label={t('template.form.loading')} />;
if (!isNew && existing.isError) { if (!isNew && existing.isError) {
return ( return (
<ErrorState <ErrorState
message={extractApiError(existing.error, 'Could not load template')} message={extractApiError(existing.error, t('template.form.errorLoad'))}
onRetry={() => existing.refetch()} onRetry={() => existing.refetch()}
/> />
); );
@@ -127,11 +130,9 @@ export function TemplateFormPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<Link to="/admin/templates" className="btn-text-link text-[14px]"> <BackLink to="/admin/templates">{t('engagement.detail.backTo')}</BackLink>
Back to templates
</Link>
<h1 className="text-[32px] font-medium leading-none"> <h1 className="text-[32px] font-medium leading-none">
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')} {isNew ? t('template.form.title.new') : (existing.data?.name ?? t('template.form.title.edit'))}
</h1> </h1>
</div> </div>
{!isNew ? ( {!isNew ? (
@@ -141,13 +142,13 @@ export function TemplateFormPage(): JSX.Element {
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
Delete {t('template.form.btn.delete')}
</button> </button>
) : null} ) : null}
</header> </header>
<form onSubmit={onSubmit} className="card-product flex flex-col gap-lg max-w-2xl"> <form onSubmit={onSubmit} className="card-product flex flex-col gap-lg max-w-2xl">
<FormField label="Name" htmlFor="tpl-name" required error={formError}> <FormField label={t('template.form.field.name')} htmlFor="tpl-name" required error={formError}>
<TextInput <TextInput
id="tpl-name" id="tpl-name"
value={form.name} value={form.name}
@@ -156,56 +157,56 @@ export function TemplateFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Description" htmlFor="tpl-desc"> <FormField label={t('template.form.field.description')} htmlFor="tpl-desc">
<TextArea <TextArea
id="tpl-desc" id="tpl-desc"
value={form.description} value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} onChange={(e) => setForm({ ...form, description: e.target.value })}
disabled={isPending} disabled={isPending}
placeholder="What does this simulation cover?" placeholder={t('template.form.field.descriptionPlaceholder')}
/> />
</FormField> </FormField>
<FormField label="Commands" htmlFor="tpl-commands" hint="One command per line"> <FormField label={t('template.form.field.commands')} htmlFor="tpl-commands" hint={t('template.form.field.commandsHint')}>
<TextArea <TextArea
id="tpl-commands" id="tpl-commands"
value={form.commands} value={form.commands}
onChange={(e) => setForm({ ...form, commands: e.target.value })} onChange={(e) => setForm({ ...form, commands: e.target.value })}
disabled={isPending} disabled={isPending}
placeholder="e.g. mimikatz.exe&#10;sekurlsa::logonpasswords" placeholder={t('template.form.field.commandsPlaceholder')}
className="font-mono text-[14px]" className="font-mono text-[14px]"
/> />
</FormField> </FormField>
<FormField label="Prerequisites" htmlFor="tpl-prereqs"> <FormField label={t('template.form.field.prerequisites')} htmlFor="tpl-prereqs">
<TextArea <TextArea
id="tpl-prereqs" id="tpl-prereqs"
value={form.prerequisites} value={form.prerequisites}
onChange={(e) => setForm({ ...form, prerequisites: e.target.value })} onChange={(e) => setForm({ ...form, prerequisites: e.target.value })}
disabled={isPending} disabled={isPending}
placeholder="e.g. Local admin access required" placeholder={t('template.form.field.prerequisitesPlaceholder')}
/> />
</FormField> </FormField>
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<span className="text-[14px] font-medium text-ink">MITRE Techniques &amp; Tactics</span> <span className="text-[14px] font-medium text-ink">{t('template.form.field.mitre')}</span>
{techniques.length === 0 && tactics.length === 0 ? ( {techniques.length === 0 && tactics.length === 0 ? (
<p className="text-[13px] text-graphite">No techniques selected</p> <p className="text-[13px] text-graphite">{t('template.form.field.mitreEmpty')}</p>
) : ( ) : (
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list"> <div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
{tactics.map((t) => ( {tactics.map((tac) => (
<MitreTacticTag <MitreTacticTag
key={t.id} key={tac.id}
tactic={t} tactic={tac}
onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== t.id))} onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== tac.id))}
/> />
))} ))}
{techniques.map((t) => ( {techniques.map((tech) => (
<MitreTechniqueTag <MitreTechniqueTag
key={t.id} key={tech.id}
technique={t} technique={tech}
onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== t.id))} onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== tech.id))}
/> />
))} ))}
</div> </div>
@@ -221,13 +222,13 @@ export function TemplateFormPage(): JSX.Element {
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full" className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
onClick={() => setShowPicker(true)} onClick={() => setShowPicker(true)}
> >
Search technique (e.g. T1059) {t('template.form.field.mitreSearch')}
</button> </button>
)} )}
</div> </div>
<button <button
type="button" type="button"
aria-label="Open MITRE matrix" aria-label={t('template.form.field.mitreOpenMatrix')}
onClick={() => { setShowPicker(false); setShowMatrix(true); }} onClick={() => { setShowPicker(false); setShowMatrix(true); }}
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink" className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink"
> >
@@ -238,10 +239,10 @@ export function TemplateFormPage(): JSX.Element {
<div className="flex items-center gap-md pt-xs border-t border-hairline"> <div className="flex items-center gap-md pt-xs border-t border-hairline">
<button type="submit" className="btn-primary" disabled={isPending}> <button type="submit" className="btn-primary" disabled={isPending}>
<Save size={14} aria-hidden /> {isPending ? 'Saving' : 'Save'} <Save size={14} aria-hidden /> {isPending ? t('template.form.btn.saving') : t('template.form.btn.save')}
</button> </button>
<Link to="/admin/templates" className="btn-outline-ink"> <Link to="/admin/templates" className="btn-outline-ink">
Cancel {t('template.form.btn.cancel')}
</Link> </Link>
</div> </div>
</form> </form>
@@ -256,9 +257,10 @@ export function TemplateFormPage(): JSX.Element {
{showDeleteConfirm ? ( {showDeleteConfirm ? (
<ConfirmDialog <ConfirmDialog
title="Delete template" title={t('template.form.deleteConfirm.title')}
description={`Delete "${existing.data?.name ?? 'this template'}"? This cannot be undone. Simulations already created from it are unaffected.`} description={t('template.form.deleteConfirm.desc')}
confirmLabel="Delete" confirmLabel={t('template.form.deleteConfirm.confirm')}
cancelLabel={t('template.form.deleteConfirm.cancel')}
onConfirm={onDelete} onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}
destructive destructive

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { SimulationTemplate } from '@/api/types'; import type { SimulationTemplate } from '@/api/types';
import { useDeleteTemplate, useTemplates } from '@/hooks/useTemplates'; import { useDeleteTemplate, useTemplates } from '@/hooks/useTemplates';
@@ -7,28 +8,25 @@ import { useToast } from '@/hooks/useToast';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { EmptyState } from '@/components/EmptyState'; import { EmptyState } from '@/components/EmptyState';
import { formatDate } from '@/lib/format';
function mitreCount(t: SimulationTemplate): number { function mitreCount(t: SimulationTemplate): number {
return t.techniques.length + t.tactics.length; return t.techniques.length + t.tactics.length;
} }
function formatDate(value: string | null): string {
if (!value) return '—';
return value.slice(0, 10);
}
export function TemplatesListPage(): JSX.Element { export function TemplatesListPage(): JSX.Element {
const { t } = useTranslation();
const { data, isLoading, isError, error, refetch } = useTemplates(); const { data, isLoading, isError, error, refetch } = useTemplates();
const deleteMutation = useDeleteTemplate(); const deleteMutation = useDeleteTemplate();
const { push } = useToast(); const { push } = useToast();
const onDelete = async (t: SimulationTemplate) => { const onDelete = async (template: SimulationTemplate) => {
if (!window.confirm(`Delete template "${t.name}"? This cannot be undone.`)) return; if (!window.confirm(`${t('template.list.title')} « ${template.name} » ?`)) return;
try { try {
await deleteMutation.mutateAsync(t.id); await deleteMutation.mutateAsync(template.id);
push('Template deleted', 'success'); push(t('template.form.toast.deleted'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not delete template'), 'error'); push(extractApiError(err, t('template.form.errorDelete')), 'error');
} }
}; };
@@ -36,32 +34,32 @@ export function TemplatesListPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-end justify-between gap-md"> <header className="flex items-end justify-between gap-md">
<div> <div>
<h1 className="text-[32px] font-medium leading-none">Templates</h1> <h1 className="text-[32px] font-medium leading-none">{t('template.list.title')}</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Reusable simulation blueprints for red team operations. {t('template.list.subtitle')}
</p> </p>
</div> </div>
<Link to="/admin/templates/new" className="btn-primary"> <Link to="/admin/templates/new" className="btn-primary">
<Plus size={14} aria-hidden /> New <Plus size={14} aria-hidden /> {t('template.list.new')}
</Link> </Link>
</header> </header>
{isLoading ? <LoadingState label="Loading templates…" /> : null} {isLoading ? <LoadingState label={t('common.loading')} /> : null}
{isError ? ( {isError ? (
<ErrorState <ErrorState
message={extractApiError(error, 'Could not load templates')} message={extractApiError(error, t('template.form.errorLoad'))}
onRetry={() => refetch()} onRetry={() => refetch()}
/> />
) : null} ) : null}
{!isLoading && !isError && data && data.length === 0 ? ( {!isLoading && !isError && data && data.length === 0 ? (
<EmptyState <EmptyState
title="No templates yet" title={t('template.list.empty.title')}
description="Create your first template to speed up simulation setup." description={t('template.list.empty.desc')}
action={ action={
<Link to="/admin/templates/new" className="btn-primary"> <Link to="/admin/templates/new" className="btn-primary">
<Plus size={14} aria-hidden /> New template <Plus size={14} aria-hidden /> {t('template.list.new')}
</Link> </Link>
} }
/> />
@@ -69,44 +67,44 @@ export function TemplatesListPage(): JSX.Element {
{!isLoading && !isError && data && data.length > 0 ? ( {!isLoading && !isError && data && data.length > 0 ? (
<div className="card-product overflow-hidden p-0"> <div className="card-product overflow-hidden p-0">
<table className="w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr>
<th className="px-xl py-md">Name</th> <th>{t('template.list.col.name')}</th>
<th className="px-xl py-md">MITRE</th> <th>{t('template.list.col.mitre')}</th>
<th className="px-xl py-md">Created by</th> <th>{t('template.list.col.createdBy')}</th>
<th className="px-xl py-md">Updated</th> <th>{t('template.list.col.updated')}</th>
<th className="px-xl py-md text-right">Actions</th> <th className="text-right">{t('engagement.list.col.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.map((t) => ( {data.map((template) => (
<tr key={t.id} className="border-b border-hairline last:border-0"> <tr key={template.id}>
<td className="px-xl py-md"> <td>
<Link <Link
to={`/admin/templates/${t.id}/edit`} to={`/admin/templates/${template.id}/edit`}
className="text-ink font-medium hover:underline" className="text-ink font-medium hover:underline"
> >
{t.name} {template.name}
</Link> </Link>
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px]"> <td className="text-charcoal">
{mitreCount(t) === 0 ? '—' : mitreCount(t)} {mitreCount(template) === 0 ? '—' : mitreCount(template)}
</td> </td>
<td className="px-xl py-md text-charcoal">{t.created_by.username}</td> <td className="text-charcoal">{template.created_by.username}</td>
<td className="px-xl py-md text-charcoal font-mono">{formatDate(t.updated_at)}</td> <td className="text-charcoal font-mono">{formatDate(template.updated_at)}</td>
<td className="px-xl py-md text-right"> <td className="text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link"> <Link to={`/admin/templates/${template.id}/edit`} className="btn-text-link">
Edit {t('template.list.edit')}
</Link> </Link>
<button <button
type="button" type="button"
className="btn-text-link text-bloom-deep" className="btn-text-link text-bloom-deep"
onClick={() => onDelete(t)} onClick={() => onDelete(template)}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
Delete {t('template.list.delete')}
</button> </button>
</div> </div>
</td> </td>

View File

@@ -1,4 +1,6 @@
import { Fragment, useState, type FormEvent } from 'react'; import { Fragment, useState, type FormEvent } from 'react';
import { formatDate } from '@/lib/format';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { Role, User } from '@/api/types'; import type { Role, User } from '@/api/types';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@@ -14,12 +16,6 @@ import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { EmptyState } from '@/components/EmptyState'; import { EmptyState } from '@/components/EmptyState';
const ROLE_OPTIONS: { value: Role; label: string }[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'redteam', label: 'Red Team' },
{ value: 'soc', label: 'SOC' },
];
interface CreateFormState { interface CreateFormState {
username: string; username: string;
password: string; password: string;
@@ -29,6 +25,7 @@ interface CreateFormState {
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' }; const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
export function UsersAdminPage(): JSX.Element { export function UsersAdminPage(): JSX.Element {
const { t } = useTranslation();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { push } = useToast(); const { push } = useToast();
const list = useUsersList(); const list = useUsersList();
@@ -36,6 +33,12 @@ export function UsersAdminPage(): JSX.Element {
const patchMutation = usePatchUser(); const patchMutation = usePatchUser();
const deleteMutation = useDeleteUser(); const deleteMutation = useDeleteUser();
const ROLE_OPTIONS: { value: Role; label: string }[] = [
{ value: 'admin', label: t('user.admin.role.admin') },
{ value: 'redteam', label: t('user.admin.role.redteam') },
{ value: 'soc', label: t('user.admin.role.soc') },
];
const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE); const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
@@ -47,15 +50,15 @@ export function UsersAdminPage(): JSX.Element {
e.preventDefault(); e.preventDefault();
setCreateError(null); setCreateError(null);
if (createForm.password.length < 8) { if (createForm.password.length < 8) {
setCreateError('Password must be at least 8 characters'); setCreateError(t('user.admin.error.passwordMinLength'));
return; return;
} }
try { try {
await createMutation.mutateAsync(createForm); await createMutation.mutateAsync(createForm);
setCreateForm(EMPTY_CREATE); setCreateForm(EMPTY_CREATE);
push('User created', 'success'); push(t('user.admin.toast.created'), 'success');
} catch (err) { } catch (err) {
setCreateError(extractApiError(err, 'Could not create user')); setCreateError(extractApiError(err, t('user.admin.error.create')));
} }
}; };
@@ -63,53 +66,53 @@ export function UsersAdminPage(): JSX.Element {
if (u.role === role) return; if (u.role === role) return;
try { try {
await patchMutation.mutateAsync({ id: u.id, input: { role } }); await patchMutation.mutateAsync({ id: u.id, input: { role } });
push(`Role updated for ${u.username}`, 'success'); push(t('user.admin.toast.roleUpdated', { username: u.username }), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not update role'), 'error'); push(extractApiError(err, t('user.admin.error.roleUpdate')), 'error');
} }
}; };
const onResetPassword = async (u: User, e: FormEvent) => { const onResetPassword = async (u: User, e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (resetPassword.length < 8) { if (resetPassword.length < 8) {
push('Password must be at least 8 characters', 'error'); push(t('user.admin.error.passwordMinLength'), 'error');
return; return;
} }
try { try {
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } }); await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
push(`Password reset for ${u.username}`, 'success'); push(t('user.admin.toast.passwordReset', { username: u.username }), 'success');
setResetOpen(null); setResetOpen(null);
setResetPassword(''); setResetPassword('');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not reset password'), 'error'); push(extractApiError(err, t('user.admin.error.passwordReset')), 'error');
} }
}; };
const onDelete = async (u: User) => { const onDelete = async (u: User) => {
if (currentUser?.id === u.id) { if (currentUser?.id === u.id) {
push('You cannot delete your own account', 'error'); push(t('user.admin.error.selfDelete'), 'error');
return; return;
} }
if (!window.confirm(`Delete user "${u.username}"?`)) return; if (!window.confirm(t('user.admin.deleteConfirm', { username: u.username }))) return;
try { try {
await deleteMutation.mutateAsync(u.id); await deleteMutation.mutateAsync(u.id);
push('User deleted', 'success'); push(t('user.admin.toast.deleted'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not delete user'), 'error'); push(extractApiError(err, t('user.admin.error.delete')), 'error');
} }
}; };
return ( return (
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header> <header>
<h1 className="text-[32px] font-medium leading-none">User accounts</h1> <h1 className="text-[32px] font-medium leading-none">{t('user.admin.title')}</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Manage local accounts. Admins can create new red team or SOC analysts. {t('user.admin.createSubtitle')}
</p> </p>
</header> </header>
<section className="card-product flex flex-col gap-md"> <section className="card-product flex flex-col gap-md">
<h2 className="text-[20px] font-medium">Create account</h2> <h2 className="text-[20px] font-medium">{t('user.admin.createSection')}</h2>
{/* {/*
Option A structural fix (AC-17.3): labels / inputs / hints in 3 explicit grid rows Option A structural fix (AC-17.3): labels / inputs / hints in 3 explicit grid rows
so the browser can never misalign them by collapsing different-height cells. so the browser can never misalign them by collapsing different-height cells.
@@ -121,13 +124,13 @@ export function UsersAdminPage(): JSX.Element {
> >
{/* Row 1 — labels */} {/* Row 1 — labels */}
<label htmlFor="new-username" className="text-[14px] font-medium text-ink"> <label htmlFor="new-username" className="text-[14px] font-medium text-ink">
Username <span className="text-bloom-deep">*</span> {t('user.admin.field.username')} <span className="text-bloom-deep">*</span>
</label> </label>
<label htmlFor="new-password" className="text-[14px] font-medium text-ink"> <label htmlFor="new-password" className="text-[14px] font-medium text-ink">
Password <span className="text-bloom-deep">*</span> {t('user.admin.field.password')} <span className="text-bloom-deep">*</span>
</label> </label>
<label htmlFor="new-role" className="text-[14px] font-medium text-ink"> <label htmlFor="new-role" className="text-[14px] font-medium text-ink">
Role <span className="text-bloom-deep">*</span> {t('user.admin.col.role')} <span className="text-bloom-deep">*</span>
</label> </label>
<div aria-hidden="true" /> <div aria-hidden="true" />
@@ -148,17 +151,18 @@ export function UsersAdminPage(): JSX.Element {
/> />
<Select <Select
id="new-role" id="new-role"
data-testid="new-role-select"
value={createForm.role} value={createForm.role}
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })} onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
options={ROLE_OPTIONS} options={ROLE_OPTIONS}
/> />
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}> <button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating' : 'Create'} {createMutation.isPending ? t('common.creating') : t('user.admin.btn.create')}
</button> </button>
{/* Row 3 — hints */} {/* Row 3 — hints */}
<div aria-hidden="true" /> <div aria-hidden="true" />
<span className="text-[12px] text-graphite"> 8 characters</span> <span className="text-[12px] text-graphite">{t('user.admin.passwordHint')}</span>
<div aria-hidden="true" /> <div aria-hidden="true" />
<div aria-hidden="true" /> <div aria-hidden="true" />
</form> </form>
@@ -170,31 +174,31 @@ export function UsersAdminPage(): JSX.Element {
</section> </section>
<section className="flex flex-col gap-md"> <section className="flex flex-col gap-md">
<h2 className="text-[20px] font-medium">All accounts</h2> <h2 className="text-[20px] font-medium">{t('user.admin.allSection')}</h2>
{list.isLoading ? <LoadingState label="Loading users…" /> : null} {list.isLoading ? <LoadingState label={t('state.loading')} /> : null}
{list.isError ? ( {list.isError ? (
<ErrorState <ErrorState
message={extractApiError(list.error, 'Could not load users')} message={extractApiError(list.error, t('user.admin.error.create'))}
onRetry={() => list.refetch()} onRetry={() => list.refetch()}
/> />
) : null} ) : null}
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? ( {!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
<EmptyState <EmptyState
title="No users yet" title={t('user.admin.empty.title')}
description="Create the first account using the form above." description={t('user.admin.empty.desc')}
/> />
) : null} ) : null}
{!list.isLoading && !list.isError && list.data && list.data.length > 0 ? ( {!list.isLoading && !list.isError && list.data && list.data.length > 0 ? (
<div className="card-product overflow-hidden p-0"> <div className="card-product overflow-hidden p-0">
<table className="w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite"> <tr>
<th className="px-xl py-md">Username</th> <th>{t('user.admin.col.username')}</th>
<th className="px-xl py-md">Role</th> <th>{t('user.admin.col.role')}</th>
<th className="px-xl py-md">Created</th> <th>{t('user.admin.col.createdAt')}</th>
<th className="px-xl py-md text-right">Actions</th> <th className="text-right">{t('user.admin.col.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -204,26 +208,26 @@ export function UsersAdminPage(): JSX.Element {
// Fragment must carry the key — `<>` cannot, which broke // Fragment must carry the key — `<>` cannot, which broke
// per-row reconciliation (reset-password state leaked across rows). // per-row reconciliation (reset-password state leaked across rows).
<Fragment key={u.id}> <Fragment key={u.id}>
<tr className="border-b border-hairline last:border-0"> <tr>
<td className="px-xl py-md text-ink"> <td className="text-ink">
<span className="font-mono font-medium">{u.username}</span> <span className="font-mono font-medium">{u.username}</span>
{isSelf ? ( {isSelf ? (
<span className="ml-sm font-sans text-[12px] text-graphite"> <span className="ml-sm font-sans text-[12px] text-graphite">
(you) {t('user.admin.you')}
</span> </span>
) : null} ) : null}
</td> </td>
<td className="px-xl py-md"> <td>
<Select <Select
value={u.role} value={u.role}
onChange={(e) => onRoleChange(u, e.target.value as Role)} onChange={(e) => onRoleChange(u, e.target.value as Role)}
options={ROLE_OPTIONS} options={ROLE_OPTIONS}
aria-label={`Change role for ${u.username}`} aria-label={`${t('user.admin.col.role')} ${u.username}`}
disabled={patchMutation.isPending} disabled={patchMutation.isPending}
/> />
</td> </td>
<td className="px-xl py-md text-charcoal font-mono">{u.created_at}</td> <td className="text-charcoal font-mono">{formatDate(u.created_at)}</td>
<td className="px-xl py-md text-right"> <td className="text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<button <button
type="button" type="button"
@@ -233,7 +237,7 @@ export function UsersAdminPage(): JSX.Element {
setResetPassword(''); setResetPassword('');
}} }}
> >
Reset password {t('user.admin.resetPassword')}
</button> </button>
<button <button
type="button" type="button"
@@ -241,22 +245,23 @@ export function UsersAdminPage(): JSX.Element {
disabled={isSelf || deleteMutation.isPending} disabled={isSelf || deleteMutation.isPending}
onClick={() => onDelete(u)} onClick={() => onDelete(u)}
> >
Delete {t('user.admin.btn.delete')}
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
{resetOpen === u.id ? ( {resetOpen === u.id ? (
<tr className="border-b border-hairline last:border-0 bg-cloud"> <tr className="border-b border-hairline last:border-0 bg-cloud">
{/* Aerated row — inline form needs room; compact-density tradeoff intentional */}
<td colSpan={4} className="px-xl py-md"> <td colSpan={4} className="px-xl py-md">
<form <form
onSubmit={(e) => onResetPassword(u, e)} onSubmit={(e) => onResetPassword(u, e)}
className="flex items-end gap-md" className="flex items-end gap-md"
> >
<FormField <FormField
label={`New password for ${u.username}`} label={t('user.admin.newPasswordFor', { username: u.username })}
htmlFor={`reset-${u.id}`} htmlFor={`reset-${u.id}`}
hint="≥ 8 characters" hint={t('user.admin.passwordHint')}
> >
<TextInput <TextInput
id={`reset-${u.id}`} id={`reset-${u.id}`}
@@ -268,7 +273,7 @@ export function UsersAdminPage(): JSX.Element {
/> />
</FormField> </FormField>
<button type="submit" className="btn-primary"> <button type="submit" className="btn-primary">
Save password {t('user.admin.btn.setPassword')}
</button> </button>
<button <button
type="button" type="button"
@@ -278,7 +283,7 @@ export function UsersAdminPage(): JSX.Element {
setResetPassword(''); setResetPassword('');
}} }}
> >
Cancel {t('user.admin.btn.cancel')}
</button> </button>
</form> </form>
</td> </td>

View File

@@ -147,4 +147,52 @@
.tag-mitre { .tag-mitre {
@apply inline-flex items-center bg-cloud text-ink border border-hairline rounded-none px-2 py-[2px] font-mono text-[12px] leading-[1.33]; @apply inline-flex items-center bg-cloud text-ink border border-hairline rounded-none px-2 py-[2px] font-mono text-[12px] leading-[1.33];
} }
/* ─── Sub-page tabs (L1) ─────────────────────────────────────────────── */
.tab-underline {
@apply text-graphite text-caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs;
}
.tab-underline-active {
@apply text-primary border-primary;
}
/* Count pill — rounded-pill is the allowed exception per DESIGN.md */
.tab-count-pill {
@apply inline-flex items-center rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono;
}
.tab-count-pill-active {
@apply bg-primary-soft text-primary;
}
/* Disabled variant — consistent with btn-outline:disabled; overrides hover from .tab-underline */
.tab-underline-disabled {
@apply text-steel cursor-not-allowed;
}
/* ─── Inline alert banners (L2) ─────────────────────────────────────── */
.alert-error {
@apply bg-paper border border-hairline border-l-4 border-l-bloom-deep rounded-none px-md py-sm flex items-start gap-sm;
}
.alert-warn {
@apply bg-paper border border-hairline border-l-4 border-l-warn rounded-none px-md py-sm flex items-start gap-sm;
}
.alert-success {
@apply bg-paper border border-hairline border-l-4 border-l-success rounded-none px-md py-sm flex items-start gap-sm;
}
.alert-info {
@apply bg-paper border border-hairline border-l-4 border-l-primary rounded-none px-md py-sm flex items-start gap-sm;
}
/* ─── Compact table density (L4) — 32px row height, global ──────────── */
.table-compact thead tr {
@apply text-[12px] uppercase tracking-[0.5px] text-graphite;
}
.table-compact th {
@apply px-md py-xxs;
}
.table-compact td {
@apply px-md py-xxs text-caption-md;
height: 32px;
}
.table-compact tbody tr {
@apply border-b border-hairline last:border-0;
}
} }

View File

@@ -90,7 +90,7 @@ describe('EngagementFormPage — C2 config card visibility', () => {
routerProps: { initialEntries: ['/engagements/5/edit'] }, routerProps: { initialEntries: ['/engagements/5/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument(); expect(screen.getByTestId('btn-submit')).toBeInTheDocument();
}); });
expect(screen.queryByTestId('c2-config-card')).toBeNull(); expect(screen.queryByTestId('c2-config-card')).toBeNull();
}); });
@@ -101,7 +101,7 @@ describe('EngagementFormPage — C2 config card visibility', () => {
routerProps: { initialEntries: ['/engagements/new'] }, routerProps: { initialEntries: ['/engagements/new'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /create engagement/i })).toBeInTheDocument(); expect(screen.getByTestId('btn-submit')).toBeInTheDocument();
}); });
expect(screen.queryByTestId('c2-config-card')).toBeNull(); expect(screen.queryByTestId('c2-config-card')).toBeNull();
}); });

View File

@@ -47,7 +47,7 @@ describe('ExportEngagementButton', () => {
renderWithProviders(<ExportEngagementButton engagementId={1} />); renderWithProviders(<ExportEngagementButton engagementId={1} />);
expect(screen.getByTestId('export-btn')).toBeInTheDocument(); expect(screen.getByTestId('export-btn')).toBeInTheDocument();
expect(screen.getByTestId('export-dropdown-toggle')).toBeInTheDocument(); expect(screen.getByTestId('export-dropdown-toggle')).toBeInTheDocument();
expect(screen.getByText('Export')).toBeInTheDocument(); expect(screen.getByText('Exporter')).toBeInTheDocument();
}); });
it('clicking primary opens dropdown with three formats', async () => { it('clicking primary opens dropdown with three formats', async () => {

View File

@@ -124,7 +124,7 @@ describe('MitreMatrixModal', () => {
); );
await user.click(t1078Btn!); await user.click(t1078Btn!);
await user.click(screen.getByRole('button', { name: /Apply/i })); await user.click(screen.getByRole('button', { name: /Appliquer/i }));
expect(onApply).toHaveBeenCalledWith( expect(onApply).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -148,7 +148,7 @@ describe('MitreMatrixModal', () => {
/>, />,
); );
await user.click(screen.getByRole('button', { name: /Cancel/i })); await user.click(screen.getByRole('button', { name: /Fermer/i }));
expect(onCancel).toHaveBeenCalled(); expect(onCancel).toHaveBeenCalled();
expect(onApply).not.toHaveBeenCalled(); expect(onApply).not.toHaveBeenCalled();
@@ -202,7 +202,7 @@ describe('MitreMatrixModal', () => {
await waitFor(() => screen.getByText('T1078')); await waitFor(() => screen.getByText('T1078'));
const searchInput = screen.getByPlaceholderText(/Filter techniques/i); const searchInput = screen.getByPlaceholderText(/Filtrer/i);
await user.type(searchInput, 'T1059'); await user.type(searchInput, 'T1059');
expect(screen.queryByText('T1078')).toBeNull(); expect(screen.queryByText('T1078')).toBeNull();
@@ -224,7 +224,7 @@ describe('MitreMatrixModal', () => {
await waitFor(() => screen.getByText('T1078')); await waitFor(() => screen.getByText('T1078'));
expect(screen.queryByText(/Default Accounts/)).toBeNull(); expect(screen.queryByText(/Default Accounts/)).toBeNull();
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i }); const expandBtn = screen.getByRole('button', { name: /Déplier T1078/i });
await user.click(expandBtn); await user.click(expandBtn);
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument(); expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
@@ -242,7 +242,7 @@ describe('MitreMatrixModal', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Apply 1 item/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Appliquer \(1 élément\)/i })).toBeInTheDocument();
}); });
}); });
@@ -259,11 +259,11 @@ describe('MitreMatrixModal', () => {
await waitFor(() => screen.getByText('T1078')); await waitFor(() => screen.getByText('T1078'));
const applyBtn = screen.getByRole('button', { name: /Clear all/i }); const applyBtn = screen.getByRole('button', { name: /Tout effacer/i });
expect(applyBtn).toBeDisabled(); expect(applyBtn).toBeDisabled();
}); });
it('Apply button shows "Clear all" and is enabled when initial selection is deselected', async () => { it('Apply button shows "Tout effacer" and is enabled when initial selection is deselected', async () => {
const onApply = vi.fn(); const onApply = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
@@ -284,7 +284,7 @@ describe('MitreMatrixModal', () => {
); );
await user.click(t1078Btn!); await user.click(t1078Btn!);
const applyBtn = screen.getByRole('button', { name: /Clear all/i }); const applyBtn = screen.getByRole('button', { name: /Tout effacer/i });
expect(applyBtn).not.toBeDisabled(); expect(applyBtn).not.toBeDisabled();
await user.click(applyBtn); await user.click(applyBtn);
expect(onApply).toHaveBeenCalledWith( expect(onApply).toHaveBeenCalledWith(

View File

@@ -30,7 +30,7 @@ describe('MitreTechniquePicker', () => {
vi.useRealTimers(); vi.useRealTimers();
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />); renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />);
expect(screen.getByRole('combobox')).toBeInTheDocument(); expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Search by ID or name/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/Rechercher une technique/i)).toBeInTheDocument();
}); });
it('is disabled when disabled prop is true', () => { it('is disabled when disabled prop is true', () => {

View File

@@ -23,7 +23,7 @@ describe('MitreTechniqueTag', () => {
renderWithProviders( renderWithProviders(
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />, <MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
); );
expect(screen.getByRole('button', { name: /Remove T1059/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Retirer T1059/i })).toBeInTheDocument();
}); });
it('clicking × calls onRemove', async () => { it('clicking × calls onRemove', async () => {
@@ -32,7 +32,7 @@ describe('MitreTechniqueTag', () => {
renderWithProviders( renderWithProviders(
<MitreTechniqueTag technique={TECHNIQUE} onRemove={onRemove} />, <MitreTechniqueTag technique={TECHNIQUE} onRemove={onRemove} />,
); );
await user.click(screen.getByRole('button', { name: /Remove T1059/i })); await user.click(screen.getByRole('button', { name: /Retirer T1059/i }));
expect(onRemove).toHaveBeenCalledOnce(); expect(onRemove).toHaveBeenCalledOnce();
}); });
@@ -40,7 +40,7 @@ describe('MitreTechniqueTag', () => {
renderWithProviders( renderWithProviders(
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} disabled />, <MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} disabled />,
); );
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull(); expect(screen.queryByRole('button', { name: /Retirer/i })).toBeNull();
}); });
}); });
@@ -57,7 +57,7 @@ describe('MitreTacticTag', () => {
renderWithProviders( renderWithProviders(
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} />, <MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} />,
); );
expect(screen.getByRole('button', { name: /Remove TA0007/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Retirer TA0007/i })).toBeInTheDocument();
}); });
it('clicking × calls onRemove', async () => { it('clicking × calls onRemove', async () => {
@@ -66,7 +66,7 @@ describe('MitreTacticTag', () => {
renderWithProviders( renderWithProviders(
<MitreTacticTag tactic={TACTIC} onRemove={onRemove} />, <MitreTacticTag tactic={TACTIC} onRemove={onRemove} />,
); );
await user.click(screen.getByRole('button', { name: /Remove TA0007/i })); await user.click(screen.getByRole('button', { name: /Retirer TA0007/i }));
expect(onRemove).toHaveBeenCalledOnce(); expect(onRemove).toHaveBeenCalledOnce();
}); });
@@ -74,6 +74,6 @@ describe('MitreTacticTag', () => {
renderWithProviders( renderWithProviders(
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} disabled />, <MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} disabled />,
); );
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull(); expect(screen.queryByRole('button', { name: /Retirer/i })).toBeNull();
}); });
}); });

View File

@@ -48,7 +48,7 @@ describe('MitreTechniquesField', () => {
renderWithProviders( renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />, <MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
); );
expect(screen.getByText(/No techniques selected/i)).toBeInTheDocument(); expect(screen.getByText(/Aucune technique associée/i)).toBeInTheDocument();
}); });
it('renders technique tags for each technique', () => { it('renders technique tags for each technique', () => {
@@ -72,16 +72,16 @@ describe('MitreTechniquesField', () => {
renderWithProviders( renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />, <MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
); );
expect(screen.getByRole('button', { name: /Open MITRE matrix/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Ouvrir la matrice/i })).toBeInTheDocument();
// The search placeholder button // The search placeholder button
expect(screen.getByRole('button', { name: /Search technique/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Rechercher une technique/i })).toBeInTheDocument();
}); });
it('hides input row when disabled', () => { it('hides input row when disabled', () => {
renderWithProviders( renderWithProviders(
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} disabled />, <MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} disabled />,
); );
expect(screen.queryByRole('button', { name: /Open MITRE matrix/i })).toBeNull(); expect(screen.queryByRole('button', { name: /Ouvrir la matrice/i })).toBeNull();
}); });
it('× button on technique tag calls PATCH with technique removed', async () => { it('× button on technique tag calls PATCH with technique removed', async () => {
@@ -94,7 +94,7 @@ describe('MitreTechniquesField', () => {
<MitreTechniquesField value={[T1059, T1078]} tactics={[]} simulationId={7} engagementId={42} />, <MitreTechniquesField value={[T1059, T1078]} tactics={[]} simulationId={7} engagementId={42} />,
); );
const removeBtn = screen.getByRole('button', { name: /Remove T1059/i }); const removeBtn = screen.getByRole('button', { name: /Retirer T1059/i });
await user.click(removeBtn); await user.click(removeBtn);
await waitFor(() => { await waitFor(() => {
@@ -115,7 +115,7 @@ describe('MitreTechniquesField', () => {
<MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />, <MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />,
); );
const removeBtn = screen.getByRole('button', { name: /Remove TA0007/i }); const removeBtn = screen.getByRole('button', { name: /Retirer TA0007/i });
await user.click(removeBtn); await user.click(removeBtn);
await waitFor(() => { await waitFor(() => {
@@ -131,7 +131,7 @@ describe('MitreTechniquesField', () => {
renderWithProviders( renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />, <MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
); );
await user.click(screen.getByRole('button', { name: /Search technique/i })); await user.click(screen.getByRole('button', { name: /Rechercher une technique/i }));
expect(screen.getByRole('combobox')).toBeInTheDocument(); expect(screen.getByRole('combobox')).toBeInTheDocument();
}); });
@@ -142,7 +142,7 @@ describe('MitreTechniquesField', () => {
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} />, <MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} />,
); );
await user.click(screen.getByRole('button', { name: /Search technique/i })); await user.click(screen.getByRole('button', { name: /Rechercher une technique/i }));
const combobox = screen.getByRole('combobox'); const combobox = screen.getByRole('combobox');
await user.type(combobox, 'T1059'); await user.type(combobox, 'T1059');
@@ -161,7 +161,7 @@ describe('MitreTechniquesField', () => {
renderWithProviders( renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />, <MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
); );
await user.click(screen.getByRole('button', { name: /Open MITRE matrix/i })); await user.click(screen.getByRole('button', { name: /Ouvrir la matrice/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
}); });
}); });

View File

@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor, fireEvent } from '@testing-library/react';
// Reset hash between tests so useHashTab doesn't bleed state
afterEach(() => { history.replaceState(null, '', '/'); });
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
@@ -43,7 +45,6 @@ vi.mock('@/hooks/useAuth', () => ({
}), }),
})); }));
// Wrap the page in a Route so useParams gets eid and sid
function EditPage() { function EditPage() {
return ( return (
<Routes> <Routes>
@@ -68,6 +69,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM); mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404); mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
}); });
afterEach(() => { afterEach(() => {
@@ -88,12 +90,12 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
}); });
expect(screen.getByLabelText(/Description/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Description/i)).not.toBeDisabled();
expect(screen.getByLabelText(/Commands/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Commandes/i)).not.toBeDisabled();
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Exécuté le/i)).not.toBeDisabled();
}); });
it('shows "Mark for review" button when status is pending', async () => { it('shows "Mark for review" button when status is pending', async () => {
@@ -102,7 +104,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument(); expect(screen.getByTestId('mark-review-btn')).toBeInTheDocument();
}); });
}); });
@@ -111,8 +113,8 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => screen.getByRole('button', { name: /Mark for review/i })); await waitFor(() => screen.getByTestId('mark-review-btn'));
expect(screen.queryByRole('button', { name: /^Close$/i })).toBeNull(); expect(screen.queryByTestId('close-btn')).toBeNull();
}); });
it('shows "Mark for review" for in_progress status', async () => { it('shows "Mark for review" for in_progress status', async () => {
@@ -122,18 +124,22 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument(); expect(screen.getByTestId('mark-review-btn')).toBeInTheDocument();
}); });
}); });
it('shows "Close" button when status is review_required', async () => { it('shows "Close" button on SOC tab when status is review_required', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' }); mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
// Switch to SOC tab to see Close button
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument(); expect(screen.getByTestId('close-btn')).toBeInTheDocument();
}); });
}); });
@@ -143,46 +149,173 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /^Delete$/i })).toBeInTheDocument(); expect(screen.getByTestId('delete-btn')).toBeInTheDocument();
}); });
}); });
}); });
describe('SimulationFormPage — SOC role + pending (blocked)', () => { describe('SimulationFormPage — tabs', () => {
let mock: MockAdapter; let mock: MockAdapter;
beforeEach(() => { beforeEach(() => {
mockRole = 'soc'; mockRole = 'redteam';
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM); mock.onGet('/simulations/7').reply(200, BASE_SIM);
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
mock.onGet('/engagements/42/c2-config').reply(404); mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
}); });
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
}); });
it('shows the SOC blocked banner', async () => { it('renders 3 tabs: Red Team, SOC, Tâche C2', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
expect(screen.getByRole('tab', { name: /Red Team/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /SOC/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeInTheDocument();
});
it('SOC tab is disabled when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).toBeDisabled();
});
it('SOC tab is enabled when status is review_required', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument(); const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
}); });
}); });
it('SOC inputs are disabled when status is pending', async () => { it('SOC tab is enabled when status is done', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).toBeDisabled(); const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
});
});
it('C2 tab is enabled for redteam in edit mode', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
expect(screen.getByLabelText(/Incident number/i)).toBeDisabled(); await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
expect(screen.getByRole('tab', { name: /Tâche C2/i })).not.toBeDisabled();
});
it('C2 tab is disabled for SOC role (backend 403 on /c2/tasks)', async () => {
mockRole = 'soc';
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeDisabled());
});
it('SOC fields are visible after switching to SOC tab', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => {
expect(screen.getByLabelText(/^Source de log/i)).toBeInTheDocument();
});
expect(screen.getByLabelText(/^Numéro d'incident/i)).toBeInTheDocument();
});
it('sticky bar is not rendered on C2 tab', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /Tâche C2/i }));
await waitFor(() => expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument());
// Save button should not appear on C2 tab
expect(screen.queryByRole('button', { name: /Enregistrer/i })).toBeNull();
});
it('C2 tasks count pill shows when tasks exist', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'SYSTEM',
mapping_applied: false,
source: 'import',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
// Count pill shows "1" on the C2 tab
expect(screen.getByText('1')).toBeInTheDocument();
});
});
});
describe('SimulationFormPage — SOC role + pending (tab disabled)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'soc';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
});
afterEach(() => {
mock.restore();
});
it('SOC tab is disabled for SOC role when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).toBeDisabled());
}); });
it('Red Team inputs are disabled for SOC', async () => { it('Red Team inputs are disabled for SOC', async () => {
@@ -191,10 +324,10 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).toBeDisabled();
}); });
expect(screen.getByLabelText(/Description/i)).toBeDisabled(); expect(screen.getByLabelText(/^Description/i)).toBeDisabled();
}); });
}); });
@@ -206,6 +339,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' }); mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
mock.onGet('/engagements/42/c2-config').reply(404); mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
}); });
afterEach(() => { afterEach(() => {
@@ -217,11 +351,14 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { // Switch to SOC tab
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled(); await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
}); fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
expect(screen.getByLabelText(/Incident number/i)).not.toBeDisabled(); await waitFor(() => {
expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled();
});
expect(screen.getByLabelText(/^Numéro d'incident/i)).not.toBeDisabled();
}); });
it('Red Team inputs remain disabled for SOC even when review_required', async () => { it('Red Team inputs remain disabled for SOC even when review_required', async () => {
@@ -230,29 +367,21 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).toBeDisabled();
}); });
}); });
it('does not show the blocked banner when status is review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
});
it('shows "Close" for SOC when review_required', async () => { it('shows "Close" for SOC when review_required', async () => {
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
// Switch to SOC tab
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument(); expect(screen.getByTestId('close-btn')).toBeInTheDocument();
}); });
}); });
}); });
@@ -273,8 +402,17 @@ describe('SimulationFormPage — new simulation', () => {
renderWithProviders(<NewPage />, { renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/new'] }, routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
}); });
expect(screen.getByLabelText(/^Name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument(); expect(screen.getByTestId('create-sim-btn')).toBeInTheDocument();
});
it('SOC and C2 tabs are disabled in create mode', () => {
renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
});
expect(screen.getByRole('tab', { name: /SOC/i })).toBeDisabled();
expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeDisabled();
}); });
}); });
@@ -297,6 +435,7 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
url: 'https://mythic.lab:7443', url: 'https://mythic.lab:7443',
verify_tls: true, verify_tls: true,
}); });
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
@@ -307,11 +446,12 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
it('hides Execute via C2 button when no c2 config (404)', async () => { it('hides Execute via C2 button when no c2 config (404)', async () => {
mock.onGet('/engagements/42/c2-config').reply(404); mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
}); });
expect(screen.queryByTestId('c2-execute-btn')).toBeNull(); expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
}); });
@@ -323,100 +463,21 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
url: 'https://mythic.lab:7443', url: 'https://mythic.lab:7443',
verify_tls: true, verify_tls: true,
}); });
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
// Wait for RT tab to load; done sim disables RT editing
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).toBeDisabled());
// Execute via C2 not shown on done simulation
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
// Reopen is on the SOC tab — switch to confirm
const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
fireEvent.click(socTab);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument(); expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
}); });
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
});
});
describe('SimulationFormPage — C2 tasks panel visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows C2 tasks panel when c2 config exists (even with no tasks)', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
});
it('hides C2 tasks panel when no c2 config and no tasks', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
// Wait for page data to load then confirm no panel
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
});
it('shows C2 tasks panel when tasks exist even without c2 config', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'SYSTEM',
mapping_applied: false,
source: 'import',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
});
it('SOC role never sees C2 tasks panel', async () => {
mockRole = 'soc';
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
}); });
it('shows Import C2 history button when c2 config exists', async () => { it('shows Import C2 history button when c2 config exists', async () => {

View File

@@ -4,10 +4,10 @@ import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import type { SimulationStatus } from '@/api/types'; import type { SimulationStatus } from '@/api/types';
const CASES: { status: SimulationStatus; label: string }[] = [ const CASES: { status: SimulationStatus; label: string }[] = [
{ status: 'pending', label: 'Pending' }, { status: 'pending', label: 'En attente' },
{ status: 'in_progress', label: 'In progress' }, { status: 'in_progress', label: 'En cours' },
{ status: 'review_required', label: 'Review required' }, { status: 'review_required', label: 'Révision requise' },
{ status: 'done', label: 'Done' }, { status: 'done', label: 'Terminé' },
]; ];
describe('SimulationStatusBadge', () => { describe('SimulationStatusBadge', () => {

View File

@@ -2,12 +2,18 @@ import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { StatusBadge } from '@/components/StatusBadge'; import { StatusBadge } from '@/components/StatusBadge';
const LABELS: Record<string, string> = {
planned: 'Planifié',
active: 'Actif',
closed: 'Clôturé',
};
describe('StatusBadge', () => { describe('StatusBadge', () => {
it.each(['planned', 'active', 'closed'] as const)('renders %s label and data attr', (status) => { it.each(['planned', 'active', 'closed'] as const)('renders %s label and data attr', (status) => {
render(<StatusBadge status={status} />); render(<StatusBadge status={status} />);
const badge = screen.getByTestId('status-badge'); const badge = screen.getByTestId('status-badge');
expect(badge).toHaveAttribute('data-status', status); expect(badge).toHaveAttribute('data-status', status);
expect(badge.textContent?.toLowerCase()).toBe(status); expect(badge.textContent).toBe(LABELS[status]);
}); });
it('applies warn-soft surface for planned', () => { it('applies warn-soft surface for planned', () => {

View File

@@ -92,22 +92,20 @@ describe('TemplateFormPage — new mode', () => {
it('renders the form with name field in empty state', () => { it('renders the form with name field in empty state', () => {
renderNew(); renderNew();
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Description/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^Description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Commands/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^Commandes/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Prerequisites/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^Prérequis/i)).toBeInTheDocument();
// All inputs should be empty expect(screen.getByLabelText(/^Nom/i)).toHaveValue('');
expect(screen.getByLabelText(/Name/i)).toHaveValue('');
}); });
it('shows validation error when name is empty on submit', async () => { it('shows validation error when name is empty on submit', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderNew(); renderNew();
// Name field is empty by default — click Save directly const saveBtn = screen.getByRole('button', { name: /Enregistrer/i });
const saveBtn = screen.getByRole('button', { name: /Save/i });
await user.click(saveBtn); await user.click(saveBtn);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument(); expect(screen.getByText('Le nom est obligatoire')).toBeInTheDocument();
}); });
}); });
@@ -115,8 +113,8 @@ describe('TemplateFormPage — new mode', () => {
mock.onPost('/templates').reply(201, { ...TEMPLATE, id: 99 }); mock.onPost('/templates').reply(201, { ...TEMPLATE, id: 99 });
const user = userEvent.setup(); const user = userEvent.setup();
renderNew(); renderNew();
await user.type(screen.getByLabelText(/Name/i), 'My Template'); await user.type(screen.getByLabelText(/^Nom/i), 'My Template');
await user.click(screen.getByRole('button', { name: /Save/i })); await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
await waitFor(() => { await waitFor(() => {
expect(mock.history.post.length).toBe(1); expect(mock.history.post.length).toBe(1);
}); });
@@ -128,8 +126,8 @@ describe('TemplateFormPage — new mode', () => {
mock.onPost('/templates').reply(409, { error: 'template name already exists' }); mock.onPost('/templates').reply(409, { error: 'template name already exists' });
const user = userEvent.setup(); const user = userEvent.setup();
renderNew(); renderNew();
await user.type(screen.getByLabelText(/Name/i), 'Duplicate'); await user.type(screen.getByLabelText(/^Nom/i), 'Duplicate');
await user.click(screen.getByRole('button', { name: /Save/i })); await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('template name already exists')).toBeInTheDocument(); expect(screen.getByText('template name already exists')).toBeInTheDocument();
}); });
@@ -137,7 +135,7 @@ describe('TemplateFormPage — new mode', () => {
it('does not show Delete button in new mode', () => { it('does not show Delete button in new mode', () => {
renderNew(); renderNew();
expect(screen.queryByText('Delete')).toBeNull(); expect(screen.queryByText('Supprimer')).toBeNull();
}); });
}); });
@@ -176,7 +174,7 @@ describe('TemplateFormPage — edit mode', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument(); expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
}); });
expect(screen.getByText('Delete')).toBeInTheDocument(); expect(screen.getByText('Supprimer')).toBeInTheDocument();
}); });
it('submits PATCH on save', async () => { it('submits PATCH on save', async () => {
@@ -187,7 +185,7 @@ describe('TemplateFormPage — edit mode', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument(); expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
}); });
await user.click(screen.getByRole('button', { name: /Save/i })); await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
await waitFor(() => { await waitFor(() => {
expect(mock.history.patch.length).toBe(1); expect(mock.history.patch.length).toBe(1);
}); });
@@ -201,14 +199,14 @@ describe('TemplateFormPage — edit mode', () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderEdit(5); renderEdit(5);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument(); expect(screen.getByText('Supprimer')).toBeInTheDocument();
}); });
await user.click(screen.getByText('Delete')); await user.click(screen.getByText('Supprimer'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
}); });
// Click the Delete button inside the dialog // Click the Supprimer button inside the dialog
const dialogDeleteBtn = screen.getAllByText('Delete').find( const dialogDeleteBtn = screen.getAllByText('Supprimer').find(
(el) => el.tagName === 'BUTTON' && el.closest('[role="dialog"]') (el) => el.tagName === 'BUTTON' && el.closest('[role="dialog"]')
) as HTMLElement; ) as HTMLElement;
await user.click(dialogDeleteBtn); await user.click(dialogDeleteBtn);

View File

@@ -76,7 +76,7 @@ describe('TemplatePickerModal', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument(); expect(screen.getByTestId('empty-state')).toBeInTheDocument();
}); });
expect(screen.getByText(/No templates available/i)).toBeInTheDocument(); expect(screen.getByText(/Aucun template disponible/i)).toBeInTheDocument();
}); });
it('lists templates with name and MITRE count', async () => { it('lists templates with name and MITRE count', async () => {
@@ -128,9 +128,9 @@ describe('TemplatePickerModal', () => {
/> />
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument(); expect(screen.getByText('Annuler')).toBeInTheDocument();
}); });
await user.click(screen.getByText('Cancel')); await user.click(screen.getByText('Annuler'));
expect(onClose).toHaveBeenCalledOnce(); expect(onClose).toHaveBeenCalledOnce();
}); });

View File

@@ -100,7 +100,7 @@ describe('TemplatesListPage', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument(); expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
}); });
expect(screen.getAllByText(/New/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/Nouveau/i).length).toBeGreaterThan(0);
}); });
it('shows Edit and Delete actions', async () => { it('shows Edit and Delete actions', async () => {
@@ -109,8 +109,8 @@ describe('TemplatesListPage', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument(); expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
}); });
expect(screen.getAllByText('Edit').length).toBe(2); expect(screen.getAllByText('Modifier').length).toBe(2);
expect(screen.getAllByText('Delete').length).toBe(2); expect(screen.getAllByText('Supprimer').length).toBe(2);
}); });
it('calls delete endpoint on confirm', async () => { it('calls delete endpoint on confirm', async () => {
@@ -124,10 +124,10 @@ describe('TemplatesListPage', () => {
renderWithProviders(<TemplatesListPage />); renderWithProviders(<TemplatesListPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument(); expect(screen.getAllByText('Supprimer')[0]).toBeInTheDocument();
}); });
const deleteButtons = screen.getAllByText('Delete'); const deleteButtons = screen.getAllByText('Supprimer');
await user.click(deleteButtons[0]); await user.click(deleteButtons[0]);
await waitFor(() => { await waitFor(() => {

View File

@@ -28,7 +28,7 @@ describe('Toast', () => {
expect(toast).toHaveTextContent('Session expirée'); expect(toast).toHaveTextContent('Session expirée');
expect(toast).toHaveAttribute('data-kind', 'error'); expect(toast).toHaveAttribute('data-kind', 'error');
await user.click(screen.getByRole('button', { name: /dismiss/i })); await user.click(screen.getByRole('button', { name: /fermer/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByTestId('toast')).toBeNull(); expect(screen.queryByTestId('toast')).toBeNull();
}); });

View File

@@ -56,15 +56,15 @@ describe('UsersAdminPage', () => {
await screen.findByText('alice'); await screen.findByText('alice');
await user.type(screen.getByLabelText(/^username/i), 'dan'); await user.type(screen.getByLabelText(/^nom d'utilisateur/i), 'dan');
await user.type(screen.getByLabelText(/^password/i), 'sup3rs4fe!'); await user.type(screen.getByLabelText(/^mot de passe/i), 'sup3rs4fe!');
// role default is 'redteam'; switch to 'soc' to match newUser // role default is 'redteam'; switch to 'soc' to match newUser
await user.selectOptions(screen.getByLabelText(/^role/i), 'soc'); await user.selectOptions(screen.getByTestId('new-role-select'), 'soc');
// After POST, hooks invalidate and the list refetches → return the new list // After POST, hooks invalidate and the list refetches → return the new list
mock.onGet('/users').reply(200, [...USERS, newUser]); mock.onGet('/users').reply(200, [...USERS, newUser]);
await user.click(screen.getByRole('button', { name: /^create$/i })); await user.click(screen.getByRole('button', { name: /^créer$/i }));
await waitFor(() => { await waitFor(() => {
expect(postSpy).toHaveBeenCalledWith({ expect(postSpy).toHaveBeenCalledWith({
@@ -85,12 +85,12 @@ describe('UsersAdminPage', () => {
// The "Reset password" button for bob lives in bob's row. // The "Reset password" button for bob lives in bob's row.
const bobRow = screen.getByText('bob').closest('tr'); const bobRow = screen.getByText('bob').closest('tr');
expect(bobRow).not.toBeNull(); expect(bobRow).not.toBeNull();
await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /reset password/i })); await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /réinitialiser le mot de passe/i }));
// The reset form for bob (and bob only) must appear. // The reset form for bob (and bob only) must appear.
expect(await screen.findByLabelText(/new password for bob/i)).toBeInTheDocument(); expect(await screen.findByLabelText(/nouveau mot de passe pour bob/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/new password for carol/i)).toBeNull(); expect(screen.queryByLabelText(/nouveau mot de passe pour carol/i)).toBeNull();
expect(screen.queryByLabelText(/new password for alice/i)).toBeNull(); expect(screen.queryByLabelText(/nouveau mot de passe pour alice/i)).toBeNull();
}); });
it('disables the delete button on the current user own row', async () => { it('disables the delete button on the current user own row', async () => {
@@ -100,7 +100,7 @@ describe('UsersAdminPage', () => {
const aliceRow = screen.getByText('alice').closest('tr') as HTMLElement; const aliceRow = screen.getByText('alice').closest('tr') as HTMLElement;
const bobRow = screen.getByText('bob').closest('tr') as HTMLElement; const bobRow = screen.getByText('bob').closest('tr') as HTMLElement;
expect(within(aliceRow).getByRole('button', { name: /delete/i })).toBeDisabled(); expect(within(aliceRow).getByRole('button', { name: /supprimer/i })).toBeDisabled();
expect(within(bobRow).getByRole('button', { name: /delete/i })).toBeEnabled(); expect(within(bobRow).getByRole('button', { name: /supprimer/i })).toBeEnabled();
}); });
}); });

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { AlertBanner } from '@/components/AlertBanner';
describe('AlertBanner', () => {
it('renders error variant with alert role and alert-error class', () => {
const { container } = render(<AlertBanner variant="error">Error message</AlertBanner>);
const el = container.firstChild as HTMLElement;
expect(el).toHaveAttribute('role', 'alert');
expect(el).toHaveClass('alert-error');
expect(screen.getByText('Error message')).toBeInTheDocument();
});
it('renders warn variant with alert role and alert-warn class', () => {
const { container } = render(<AlertBanner variant="warn">Warning message</AlertBanner>);
const el = container.firstChild as HTMLElement;
expect(el).toHaveAttribute('role', 'alert');
expect(el).toHaveClass('alert-warn');
});
it('renders success variant with status role and alert-success class', () => {
const { container } = render(<AlertBanner variant="success">Done!</AlertBanner>);
const el = container.firstChild as HTMLElement;
expect(el).toHaveAttribute('role', 'status');
expect(el).toHaveClass('alert-success');
});
it('renders info variant with status role and alert-info class', () => {
const { container } = render(<AlertBanner variant="info">FYI</AlertBanner>);
const el = container.firstChild as HTMLElement;
expect(el).toHaveAttribute('role', 'status');
expect(el).toHaveClass('alert-info');
});
it('renders optional title when provided', () => {
render(<AlertBanner variant="info" title="Heads up">Detail text</AlertBanner>);
expect(screen.getByText('Heads up')).toBeInTheDocument();
expect(screen.getByText('Detail text')).toBeInTheDocument();
});
it('has no rounded-md, transition-*, or shadow-* (brutalism)', () => {
const { container } = render(<AlertBanner variant="error">Test</AlertBanner>);
const el = container.firstChild as HTMLElement;
expect(el).not.toHaveClass('rounded-md');
expect(el.className).not.toMatch(/transition-/);
expect(el.className).not.toMatch(/shadow-/);
});
});

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { BackLink } from '@/components/BackLink';
function renderBackLink(to: string, label: string) {
return render(
<MemoryRouter>
<BackLink to={to}>{label}</BackLink>
</MemoryRouter>,
);
}
describe('BackLink', () => {
it('renders children as link text', () => {
renderBackLink('/engagements', 'Back to engagements');
expect(screen.getByText('Back to engagements')).toBeInTheDocument();
});
it('renders as an anchor with the correct href', () => {
renderBackLink('/engagements', 'Back to engagements');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/engagements');
});
it('has no rounded-md, transition-*, or shadow-* (brutalism)', () => {
renderBackLink('/engagements', 'Back');
const link = screen.getByRole('link');
expect(link).not.toHaveClass('rounded-md');
expect(link.className).not.toMatch(/transition-/);
expect(link.className).not.toMatch(/shadow-/);
});
it('contains the ArrowLeft icon (svg element)', () => {
const { container } = renderBackLink('/engagements', 'Back');
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});

View File

@@ -57,7 +57,7 @@ describe('C2ConfigCard — with config (has_token=true)', () => {
it('shows Replace token affordance when has_token=true', async () => { it('shows Replace token affordance when has_token=true', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />); renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument(); expect(screen.getByText('Remplacer le token')).toBeInTheDocument();
}); });
// Token input shows placeholder bullets (readOnly) // Token input shows placeholder bullets (readOnly)
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement; const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
@@ -75,9 +75,9 @@ describe('C2ConfigCard — with config (has_token=true)', () => {
it('clicking Replace token makes input editable', async () => { it('clicking Replace token makes input editable', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />); renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument(); expect(screen.getByText('Remplacer le token')).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText('Replace token')); fireEvent.click(screen.getByText('Remplacer le token'));
await waitFor(() => { await waitFor(() => {
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement; const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
expect(tokenInput.readOnly).toBeFalsy(); expect(tokenInput.readOnly).toBeFalsy();
@@ -99,7 +99,7 @@ describe('C2ConfigCard — with config (has_token=true)', () => {
}); });
fireEvent.click(screen.getByTestId('c2-test-btn')); fireEvent.click(screen.getByTestId('c2-test-btn'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument(); expect(screen.getByText('Connecté')).toBeInTheDocument();
}); });
}); });
@@ -124,7 +124,7 @@ describe('C2ConfigCard — 503 disabled state', () => {
renderWithProviders(<C2ConfigCard engagementId={1} />); renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText(/C2 features are disabled/i), screen.getByText(/fonctionnalités C2 sont désactivées/i),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
expect(screen.getByTestId('c2-save-btn')).toBeDisabled(); expect(screen.getByTestId('c2-save-btn')).toBeDisabled();

View File

@@ -66,7 +66,7 @@ describe('C2TasksPanel — empty state', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument(); expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
}); });
expect(screen.getByText(/No C2 tasks yet/i)).toBeInTheDocument(); expect(screen.getByText(/Aucune tâche C2/i)).toBeInTheDocument();
expect(screen.queryByTestId('c2-task-row')).toBeNull(); expect(screen.queryByTestId('c2-task-row')).toBeNull();
}); });
}); });

View File

@@ -66,7 +66,7 @@ describe('ExecuteViaC2Modal', () => {
it('renders modal with title and callback table', async () => { it('renders modal with title and callback table', async () => {
renderModal(); renderModal();
expect(screen.getByTestId('c2-modal')).toBeInTheDocument(); expect(screen.getByTestId('c2-modal')).toBeInTheDocument();
expect(screen.getByText('Execute via C2')).toBeInTheDocument(); expect(screen.getByText('Exécuter via C2')).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2); expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
}); });
@@ -148,7 +148,7 @@ describe('ExecuteViaC2Modal', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2); expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
}); });
fireEvent.click(screen.getByRole('button', { name: /cancel/i })); fireEvent.click(screen.getByRole('button', { name: /Annuler/i }));
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });

View File

@@ -88,7 +88,7 @@ describe('ImportC2HistoryModal — step 1: callback picker', () => {
it('renders modal with title and callback rows', async () => { it('renders modal with title and callback rows', async () => {
renderModal(); renderModal();
expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument(); expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument();
expect(screen.getByText('Import C2 history')).toBeInTheDocument(); expect(screen.getByText("Importer l'historique C2")).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
}); });
@@ -322,7 +322,7 @@ describe('ImportC2HistoryModal — toast wording', () => {
fireEvent.click(screen.getByTestId('c2-import-submit-btn')); fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Imported 1 task(s)')).toBeInTheDocument(); expect(screen.getByText('1 tâche(s) importée(s)')).toBeInTheDocument();
}); });
}); });
@@ -342,7 +342,7 @@ describe('ImportC2HistoryModal — toast wording', () => {
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText('Imported 0 task(s), 1 already attached'), screen.getByText('0 importée(s), 1 déjà attachée(s)'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
@@ -374,7 +374,7 @@ describe('ImportC2HistoryModal — Cancel button', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
}); });
fireEvent.click(screen.getByRole('button', { name: /cancel/i })); fireEvent.click(screen.getByRole('button', { name: /Annuler/i }));
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
}); });

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Tabs } from '@/components/Tabs';
const ITEMS = [
{ id: 'schedule', label: 'Schedule' },
{ id: 'description', label: 'Description' },
{ id: 'simulations', label: 'Simulations', count: 5 },
];
describe('Tabs', () => {
it('renders all tab labels', () => {
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
expect(screen.getByText('Schedule')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Simulations')).toBeInTheDocument();
});
it('marks the active tab with aria-selected=true', () => {
render(<Tabs items={ITEMS} activeId="description" onChange={vi.fn()} />);
const descBtn = screen.getByRole('tab', { name: /Description/i });
expect(descBtn).toHaveAttribute('aria-selected', 'true');
const schedBtn = screen.getByRole('tab', { name: /Schedule/i });
expect(schedBtn).toHaveAttribute('aria-selected', 'false');
});
it('applies tab-underline-active class to the active tab', () => {
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
const activeBtn = screen.getByRole('tab', { name: /Schedule/i });
expect(activeBtn).toHaveClass('tab-underline-active');
});
it('renders count pill for the simulations tab', () => {
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
const pill = screen.getByText('5');
expect(pill).toHaveClass('tab-count-pill');
});
it('applies tab-count-pill-active when the tab with count is active', () => {
render(<Tabs items={ITEMS} activeId="simulations" onChange={vi.fn()} />);
const pill = screen.getByText('5');
expect(pill).toHaveClass('tab-count-pill-active');
});
it('calls onChange with the tab id when clicked', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
fireEvent.click(screen.getByRole('tab', { name: /Description/i }));
expect(onChange).toHaveBeenCalledWith('description');
});
it('sets aria-controls and id on each tab button', () => {
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
const schedBtn = screen.getByRole('tab', { name: /Schedule/i });
expect(schedBtn).toHaveAttribute('id', 'tab-schedule');
expect(schedBtn).toHaveAttribute('aria-controls', 'tabpanel-schedule');
const simsBtn = screen.getByRole('tab', { name: /Simulations/i });
expect(simsBtn).toHaveAttribute('id', 'tab-simulations');
expect(simsBtn).toHaveAttribute('aria-controls', 'tabpanel-simulations');
});
it('ArrowRight moves focus to the next tab', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
fireEvent.keyDown(screen.getByRole('tab', { name: /Schedule/i }), { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith('description');
});
it('ArrowLeft wraps around to the last tab', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
fireEvent.keyDown(screen.getByRole('tab', { name: /Schedule/i }), { key: 'ArrowLeft' });
expect(onChange).toHaveBeenCalledWith('simulations');
});
it('has no rounded-md, transition-*, or shadow-* on tab buttons (brutalism)', () => {
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
for (const btn of screen.getAllByRole('tab')) {
expect(btn).not.toHaveClass('rounded-md');
expect(btn.className).not.toMatch(/transition-/);
expect(btn.className).not.toMatch(/shadow-/);
}
});
});
describe('Tabs — disabled state', () => {
const ITEMS_WITH_DISABLED = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta', disabled: true },
{ id: 'c', label: 'Gamma' },
];
it('click on disabled tab does not fire onChange', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={onChange} />);
fireEvent.click(screen.getByRole('tab', { name: /Beta/i }));
expect(onChange).not.toHaveBeenCalled();
});
it('has aria-disabled="true" on disabled tab', () => {
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={vi.fn()} />);
const btn = screen.getByRole('tab', { name: /Beta/i });
expect(btn).toHaveAttribute('aria-disabled', 'true');
});
it('applies tab-underline-disabled class on disabled tab', () => {
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={vi.fn()} />);
const btn = screen.getByRole('tab', { name: /Beta/i });
expect(btn).toHaveClass('tab-underline-disabled');
});
it('ArrowRight skips disabled tab (a → c, skipping b)', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={onChange} />);
fireEvent.keyDown(screen.getByRole('tab', { name: /Alpha/i }), { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith('c');
});
it('ArrowLeft skips disabled tab (c → a, skipping b)', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="c" onChange={onChange} />);
fireEvent.keyDown(screen.getByRole('tab', { name: /Gamma/i }), { key: 'ArrowLeft' });
expect(onChange).toHaveBeenCalledWith('a');
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useHashTab } from '@/hooks/useHashTab';
beforeEach(() => {
window.location.hash = '';
});
afterEach(() => {
window.location.hash = '';
});
describe('useHashTab', () => {
it('returns the default id when hash is empty', () => {
const { result } = renderHook(() => useHashTab('schedule'));
expect(result.current[0]).toBe('schedule');
});
it('reads the hash on mount', () => {
window.location.hash = 'simulations';
const { result } = renderHook(() => useHashTab('schedule'));
expect(result.current[0]).toBe('simulations');
});
it('navigate() updates activeId and sets hash via replaceState (no history entry)', () => {
const { result } = renderHook(() => useHashTab('schedule'));
act(() => {
result.current[1]('description');
});
expect(result.current[0]).toBe('description');
expect(window.location.hash).toBe('#description');
});
it('falls back to defaultId when hash becomes empty', () => {
window.location.hash = 'simulations';
const { result } = renderHook(() => useHashTab('schedule'));
act(() => {
window.location.hash = '';
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(result.current[0]).toBe('schedule');
});
});

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

View File

@@ -22,13 +22,13 @@ describe('ErrorState', () => {
const onRetry = vi.fn(); const onRetry = vi.fn();
render(<ErrorState message="Boom" onRetry={onRetry} />); render(<ErrorState message="Boom" onRetry={onRetry} />);
expect(screen.getByTestId('error-state')).toHaveTextContent('Boom'); expect(screen.getByTestId('error-state')).toHaveTextContent('Boom');
await userEvent.click(screen.getByRole('button', { name: /retry/i })); await userEvent.click(screen.getByRole('button', { name: /réessayer/i }));
expect(onRetry).toHaveBeenCalledTimes(1); expect(onRetry).toHaveBeenCalledTimes(1);
}); });
it('omits retry button when no handler given', () => { it('omits retry button when no handler given', () => {
render(<ErrorState message="Boom" />); render(<ErrorState message="Boom" />);
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull(); expect(screen.queryByRole('button', { name: /réessayer/i })).toBeNull();
}); });
}); });

View File

@@ -1 +1,2 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import './src/i18n';

View File

@@ -1,115 +1,208 @@
# Sprint 10C2 TLS verify: redteam-friendly defaults + warning suppression # Sprint 13SimulationFormPage : layout en 3 tabs (Red Team / SOC / Tâche C2)
**Base**: `origin/main` (PR #11 merged — sprint 8 + 9 are in). **Base** : `sprint/12-i18n-fr` (PR #14 ouverte, pas encore mergée). Sprint 13 enchaîne directement.
**Branch**: `sprint/10-c2-tls-default`. **Worktree** : `.claude/worktrees/sprint-13-sim-tabs/` (dédié — conforme à `feedback_worktree_per_sprint`).
**Scope**: tiny correctness sprint. No new feature. Flip a security-relevant default to match the actual operator usage of this tool. **Branch** : `sprint/13-sim-tabs`.
**Scope** : frontend uniquement. Backend intouché. Aligné sur le pattern Tabs du sprint 11 (EngagementDetailPage 3→2 tabs) appliqué à SimulationFormPage.
--- ---
## Symptom (user report) ## Décisions binding (lockées par user)
> "Impossible de se connecter à mon C2 à cause de la vérification du certificat SSL (self signed)" 1. **Mode création** : 3 tabs visibles mais SOC + Tâche C2 **désactivés** (`aria-disabled="true"`, non-clickables, opacité réduite). Pas de banner ni d'empty-state — juste tab disabled.
2. **Tab SOC** : désactivé tant que la sim n'est pas en `review_required` (ou plus). Force l'opérateur à passer par le RT pour avancer (Mark for review depuis sticky bar RT).
3. **Sticky bar entièrement contextuelle** par tab :
- **RT tab** : `[Save, Mark for review]` (et `[Delete]` global, voir plus bas)
- **SOC tab** : `[Save SOC, Close]`
- **C2 tab** : aucune sticky bar — actions inline (`Execute via C2`, `Import C2 history`) déjà dans le tab content
4. **Count pill sur tab Tâche C2** : nombre exact de tâches C2 (depuis le hook `useSimulationC2Tasks` existant). Caché si 0 ou en mode création.
Operator opens C2 config card, fills URL + token, hits **Test connection** without noticing the checkbox → SSL VERIFY FAILED against self-signed Mythic. ## Décisions secondaires (à appliquer par défaut)
## Root cause (workflow diagnosis confirmed) - **Default active tab** : Red Team (premier opérateur surface).
- **Wiring URL** : `useHashTab('red-team')` (réutilise sprint 11). Hashes : `#red-team`, `#soc`, `#c2`.
- **Delete + Reopen** : pas mentionnés dans la sticky bar contextuelle user. Décision team-lead :
- **Delete** sur le tab **RT** (action destructrice côté RT — c'est l'auteur).
- **Reopen** sur le tab **SOC** (cohérent avec Close, même actor flow). Visible quand status `done`.
- **AlertBanner** (sprint 12) : `Done banner` reste visible au-dessus des tabs (status info, pas tab-spécifique). `SOC-blocked banner` disparaît (remplacé par le mécanisme de tab désactivé).
- **C2TasksPanel** (sprint 8) : déplace son rendering DANS le panel du tab C2. Plus en-dessous de la 2-col.
- **Header** (back link, title, status badge) : reste **au-dessus** des tabs, intouché.
The `verify_tls` chain is **fully intact** end-to-end : ## Contraintes constantes
React `verifyTls` state → `C2ConfigInput.verify_tls` → PUT body → `C2Config.verify_tls` column → `cfg.verify_tls` in API loader → `get_adapter(verify_tls=)``MythicAdapter._verify``requests.post(verify=self._verify)`.
The bug is the **default** at every layer is `True` : - Primary `#024ad8` Electric Blue intouchée.
- `backend/app/models/c2_config.py:22``default=True` - Brutalisme intact : `rounded-none` (exceptions documentées), zéro `transition-*`, zéro `shadow-*`.
- `backend/migrations/versions/0006_c2_layer.py:29``server_default=sa.true()` - DESIGN.md amendments additives uniquement (sub-section "Sub-page tabs with disabled state" si besoin).
- `backend/app/api/c2.py:87``data.get("verify_tls", True)` - Tous les nouveaux labels passent par `i18n/fr.json` (cf. sprint 12) — pas de string EN hardcodée.
- `backend/app/services/c2/mythic.py:116``verify_tls: bool = True` - `pnpm build` MANDATORY avant push (leçon `feedback_css_apply_tokens`).
- `backend/app/services/c2/factory.py:9``verify_tls=True`
- `frontend/src/components/C2ConfigCard.tsx:27``useState(true)`
- `frontend/src/components/C2ConfigCard.tsx:74` — reset on delete `setVerifyTls(true)`
Mimic is a BAS / red-team lab tool ; the dominant case is a **self-signed Mythic instance**, not a publicly-trusted cert chain. Defaulting `verify=True` is hostile to the actual workflow.
Secondary defect : `urllib3.exceptions.InsecureRequestWarning` is never suppressed (zero hits for `disable_warnings` / `urllib3` in `backend/`). When operators correctly uncheck verify, stderr gets spammed once per HTTP call.
--- ---
## Decisions (locked) ## Task 1 — Extend Tabs primitive : support `disabled` per item
1. **Flip default to `False` at every layer** — model, API fallback, adapter, factory, React state, delete reset. **File** : `frontend/src/components/Tabs.tsx`
2. **New migration 0008** : flip `server_default` to `sa.false()`. Existing rows are NOT mutated (their stored boolean is preserved).
3. **Suppress `urllib3.InsecureRequestWarning` ONLY when `verify_tls=False`** — gated inside `MythicAdapter.__init__`. Keeps the warning live for any future code that legitimately verifies.
4. **Add helper text under checkbox** : "Leave unchecked for lab Mythic with self-signed certificates." Operators see why the box matters.
5. **No API contract change**`C2ConfigInput.verify_tls: boolean` stays required. The fallback in `data.get("verify_tls", False)` only matters for hand-crafted requests.
## Out of scope Current `TabItem` :
```tsx
interface TabItem<T extends string> { id: T; label: string; count?: number; }
```
- Don't touch existing C2 endpoints behavior (route paths, payload shapes). Target :
- Don't change the `verify_tls` field type or remove the column. ```tsx
- Don't change the FakeAdapter (it makes no HTTP calls). interface TabItem<T extends string> { id: T; label: string; count?: number; disabled?: boolean; }
```
Comportement quand `disabled` :
- `<button>` reçoit `disabled={true}` et `aria-disabled="true"`
- Class CSS `.tab-underline-disabled` ajoutée (nouvelle recipe ci-dessous)
- Le hover ne change pas la couleur (`hover:text-graphite` ou rien)
- ArrowLeft/ArrowRight nav skippent les tabs disabled (cycle vers le tab actif suivant non-disabled)
- Clicks ignorés (`onClick={() => !disabled && onChange(item.id)}`)
- `aria-selected` reste `false` quand disabled
**New recipe** dans `frontend/src/styles/index.css` :
```css
.tab-underline-disabled {
@apply text-graphite opacity-50 cursor-not-allowed;
}
```
(Override le hover du `.tab-underline` parent ; pas de `border-primary` sur l'active state quand disabled — n'arrivera jamais puisque `aria-selected=false`.)
**Tests** : extend `Tabs.test.tsx` :
- Tab avec `disabled: true` ne fire pas `onChange` au click
- ArrowRight/ArrowLeft skippent les tabs disabled
- `aria-disabled="true"` présent
- Classe `.tab-underline-disabled` appliquée
DESIGN.md : ajouter 1 ligne dans la sub-section `Sub-page tabs` documentant la variante `disabled`.
--- ---
## Task ABackend (backend-builder) ## Task 2SimulationFormPage : refactor en 3 tabs
**Files** : **File** : `frontend/src/pages/SimulationFormPage.tsx`
- `backend/app/models/c2_config.py` — line 22 : `default=True``default=False`
- `backend/app/api/c2.py` — line 87 : `data.get("verify_tls", True)``data.get("verify_tls", False)` **Layout cible (edit mode)** :
- `backend/app/services/c2/factory.py` — line 9 : `verify_tls: bool = True``verify_tls: bool = False` ```
- `backend/app/services/c2/mythic.py` — line 116 : `verify_tls: bool = True``verify_tls: bool = False`, AND add at top of file `import urllib3` + `from urllib3.exceptions import InsecureRequestWarning`, AND inside `__init__` after `self._verify = verify_tls`: [Header : ← Retour à l'engagement | titre sim | status badge]
```python [AlertBanner Done si done (au-dessus des tabs, status info global)]
if not verify_tls: [Tabs : Red Team | SOC (disabled si !=review_required+) | Tâche C2 (count pill)]
urllib3.disable_warnings(InsecureRequestWarning) [Panel du tab actif :
- Red Team : tous les fields RT (Name, MITRE Techniques, Description, Commands, Prerequisites, Executed at, Execution result) + actions inline (Execute via C2, Import C2 history déjà existants en sprint 8/12)
- SOC : tous les fields SOC (Log source, Logs, SOC comment, Incident number)
- Tâche C2 : C2TasksPanel (sprint 8) directement, sans wrapper
]
[Sticky bar bottom (contextuel selon tab actif) :
- RT : [Save] [Mark for review (si pending/in_progress)] ... [Delete (toujours)]
- SOC : [Save SOC] [Close (si review_required)] [Reopen (si done)]
- C2 : aucune sticky bar (display:none ou pas de render)
]
```
**Layout en mode création (new)** :
```
[Header : ← Retour à l'engagement | "Nouvelle simulation"]
[Tabs : Red Team (active) | SOC (disabled) | Tâche C2 (disabled, pas de count pill)]
[Panel Red Team : champs simplifiés (juste Name + MITRE Techniques minimum)]
[Sticky bar bottom : [Create simulation] [Cancel]]
```
**Wiring** :
- `useHashTab<TabId>('red-team')``TabId = 'red-team' | 'soc' | 'c2'`.
- Items du Tabs :
```tsx
const items: TabItem<TabId>[] = [
{ id: 'red-team', label: t('simulation.form.tab.redTeam') },
{
id: 'soc',
label: t('simulation.form.tab.soc'),
disabled: !editing || sim.status === 'pending' || sim.status === 'in_progress',
},
{
id: 'c2',
label: t('simulation.form.tab.c2'),
count: editing ? c2Tasks?.length : undefined,
disabled: !editing,
},
];
``` ```
- **NEW migration** `backend/migrations/versions/0008_c2_verify_tls_default_false.py` — flip `server_default` to `sa.false()`. Use `op.batch_alter_table("c2_config")` for SQLite compatibility (Mimic uses SQLite per sprint 1 SPEC). Down-migration restores `sa.true()`. - Le hook `useHashTab` doit fallback proprement : si `#soc` mais tab disabled, retomber sur `'red-team'`. **Vérifie le comportement actuel** (sprint 11 a peut-être déjà ça, sinon ajout dans la même PR).
- **Tests** : grep `backend/tests/` for `verify_tls` and flip every assertion that presupposed the old `True` default (likely in `test_c2_config*.py` PUT-without-verify-tls tests and GET-fresh-row tests). Don't add new tests — adapt existing ones.
**Constraints** : **Sticky bar contextuel** :
- `pytest` baseline 468/468 must hold (or grow ; never shrink). - Wrapper `<div className="sticky bottom-0 …">` avec contenu conditionnel `{activeTab === 'red-team' && <RtActions />}{activeTab === 'soc' && <SocActions />}{activeTab === 'c2' && null}`.
- `ruff` + `mypy --strict` clean. - Ne pas rendre le wrapper du tout si C2 actif (pas de sticky div vide).
- Migration 0008 must be reversible — round-trip `alembic upgrade head` then `alembic downgrade -1` then `alembic upgrade head` must work on a fresh SQLite DB. - Réutiliser les boutons existants (Save/Mark/Save SOC/Close/Reopen/Delete) — juste les regrouper.
- Don't restructure or refactor anything else. Minimum surface.
## Task B — Frontend (frontend-builder) **Banners** :
- `Done banner` (success) : garde au-dessus des tabs, indépendant du tab actif.
- `SOC-blocked banner` (warn) : **supprime**. Le mécanisme de tab désactivé remplace.
**Files** : **C2TasksPanel** : déplacer son rendering directement dans `{activeTab === 'c2' && <C2TasksPanel … />}`. Conserver toutes les props existantes. Le `[Execute via C2]` et `[Import C2 history]` qui étaient dans la carte Red Team du sprint 8 — décision : **les laisser dans le tab Red Team** (ce sont des actions RT qui déclenchent le C2). Le tab C2 montre juste le panel des tâches.
- `frontend/src/components/C2ConfigCard.tsx` :
- Line 27 : `useState(true)` → `useState(false)`
- Line 74 (delete handler) : `setVerifyTls(true)` → `setVerifyTls(false)`
- Under the checkbox JSX (around lines 169-182) : add a `<p>` with helper text :
```tsx
<p className="text-[12px] text-charcoal mt-xxs">
Leave unchecked for lab Mythic with self-signed certificates.
</p>
```
(Use the existing DESIGN.md tokens — `text-[12px] text-charcoal` matches the `hint` style on `FormField`. Confirm token name by reading neighboring components first.)
- **Vitest** : if `C2ConfigCard.test.tsx` exists, flip any "starts checked" assertion to "starts unchecked".
**Constraints** : **Tests** : `SimulationFormPage.test.tsx` — refactor important :
- `vitest` baseline 212/212 must hold. - Les anciens tests "RT card visible AND SOC card visible simultaneously" → maintenant on test "RT tab content visible, SOC tab content via switch-tab".
- `tsc --noEmit` + `eslint --max-warnings=0` clean. - Tests Mark/Close/Reopen : naviguer vers le bon tab avant d'asserter le bouton.
- No type change to `C2ConfigInput` (the field is already required `boolean`). - Tests `data-testid="soc-blocked-banner"` : ce testid disparaît — ajuster ou supprimer le test.
- Visual: same row, just one extra `<p>` hint below the checkbox-label row. Same brutalist treatment, no transition. - Add : tab SOC `disabled` quand status pending → test que click ne change pas le tab actif.
- Add : count pill sur tab C2 reflète le nombre de tasks.
## Task CSequencing ## Task 3i18n keys nouvelles + DESIGN.md
Both tasks have **zero shared files**. Dispatch backend-builder + frontend-builder **in parallel**. No ordering constraint. **`fr.json`** ajouter :
```json
"simulation.form.tab.redTeam": "Red Team",
"simulation.form.tab.soc": "SOC",
"simulation.form.tab.c2": "Tâche C2"
```
(`Red Team` reste idiom, `SOC` acronyme — cohérent avec les conventions sprint 12.)
After both report green : **DESIGN.md** § Navigation > Sub-page tabs : ajouter 1 paragraphe sur la variante `disabled` (visual : opacity-50 + cursor-not-allowed, aria-disabled, skipped par arrow-keys).
- **code-reviewer** : sprint diff scan (focus : migration reversibility, urllib3 gating, no leftover hardcoded `True`).
- **design-reviewer** : helper-text placement, token compliance, focus ring still works, no regression on the card.
- **spec-reviewer** : verify SPEC.md § Intégration C2 still matches the new defaults (may need a one-line note about lab-mode default ; check before editing).
## Definition of Done ---
- Test connection against self-signed Mythic from a freshly-created C2 config works **without unchecking anything**. ## Counts attendus
- Existing rows are untouched (operators who saved verify_tls=true keep it until they re-save).
- `pytest` 468/468 → 468+ (no shrink), `vitest` 212/212.
- `ruff` + `mypy --strict` + `tsc --noEmit` + `eslint` clean.
- Migration 0008 round-trip OK.
- No `urllib3.InsecureRequestWarning` on stderr when `verify_tls=False`.
- Code-reviewer + design-reviewer + spec-reviewer APPROVED.
- PR opened on Gitea ; tasks/pr-body-sprint-10.md drafted by team-lead.
## Operator notes (for PR body) | Métrique | Valeur |
|---|---|
| Fichiers source modifiés | 3 (`Tabs.tsx`, `SimulationFormPage.tsx`, `index.css` recipe) |
| Fichiers source touched (juste i18n keys) | 1 (`fr.json`) |
| Fichiers tests modifiés | 2 (`Tabs.test.tsx`, `SimulationFormPage.test.tsx`) |
| Nouveaux fichiers | 0 |
| DESIGN.md amendments | 1 paragraphe additive |
- This sprint flips a security-relevant default. Production operators who legitimately use a publicly-trusted cert chain will need to explicitly check the box — but the dominant case in this BAS tool is lab Mythic with self-signed, so the new default matches the actual workflow. ---
- Existing engagements with `verify_tls=true` already stored remain unchanged. They keep their current behaviour. If the operator reported the bug because their existing row has `verify_tls=true`, they will still need to uncheck + save once after this sprint lands.
## Commits plan (~4 commits)
1. `feat(frontend): Tabs primitive supports per-item disabled state` (Tabs.tsx + recipe + Tabs.test.tsx)
2. `feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / C2 tasks)` (SimulationFormPage.tsx + tests + i18n keys + DESIGN.md amendment)
3. `refactor(frontend): contextual sticky bar per active tab on SimulationFormPage` (extraction des actions par tab, supprimer SOC-blocked banner)
4. `docs(changelog): sprint 13 simulation tabs entry`
Collapse à 2-3 si plus efficace.
---
## DoD
1. SimulationFormPage en edit mode : 3 tabs visibles (Red Team default, SOC, Tâche C2). Count pill sur C2 = `c2Tasks.length`.
2. Tabs SOC + C2 désactivés en mode création.
3. Tab SOC désactivé tant que status ∉ `{review_required, done}`.
4. Sticky bar contextuelle : RT [Save + Mark for review + Delete], SOC [Save SOC + Close + Reopen], C2 [aucune sticky bar].
5. `useHashTab` gère le fallback gracieux quand hash pointe vers un tab disabled (retombe sur `'red-team'`).
6. Tabs primitive : ArrowLeft/ArrowRight skippent les tabs disabled.
7. AlertBanner Done garde sa place au-dessus des tabs (status global). SOC-blocked banner supprimé.
8. C2TasksPanel rendu dans le tab C2 panel.
9. `[Execute via C2]` + `[Import C2 history]` restent dans le tab Red Team.
10. Tests : Tabs.test.tsx + SimulationFormPage.test.tsx adaptés, baseline ≥ 245 vitest, +N nouveaux tests sur disabled tabs + count pill + tab gating.
11. `tsc --noEmit` + `eslint --max-warnings=0` + `pnpm build` clean.
12. CHANGELOG.md entrée sprint 13.
## Out of scope (explicite)
- ❌ Refactor des sub-forms RT / SOC (juste déplacement dans le tab)
- ❌ Nouveau pattern Tabs ailleurs (uniquement SimulationFormPage cette sprint)
- ❌ Changes backend
- ❌ Color / brutalism / token changes
- ❌ Multi-langue (FR uniquement, infra sprint 12)