Compare commits
1 Commits
sprint/13-
...
88dfa74663
| Author | SHA1 | Date | |
|---|---|---|---|
| 88dfa74663 |
50
CHANGELOG.md
50
CHANGELOG.md
@@ -6,56 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
## [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)
|
||||
|
||||
@@ -225,8 +225,7 @@ Used inside pages that need content partitioning without a URL change (e.g. Enga
|
||||
- **`.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.
|
||||
- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` on each button.
|
||||
|
||||
### Data Tables
|
||||
|
||||
|
||||
87
frontend/package-lock.json
generated
87
frontend/package-lock.json
generated
@@ -11,11 +11,9 @@
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^26.3.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -342,6 +340,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -4233,15 +4232,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -4279,34 +4269,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": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -5974,33 +5936,6 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -7121,7 +7056,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -7198,15 +7133,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -7363,15 +7289,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -15,11 +15,9 @@
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^26.3.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { C2Callback } from '@/api/types';
|
||||
|
||||
@@ -21,22 +20,20 @@ export function C2CallbackPicker({
|
||||
onSelect,
|
||||
rowTestId = 'c2-callback-row',
|
||||
}: C2CallbackPickerProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-[14px] text-graphite">{t('state.loading')}</p>;
|
||||
return <p className="text-[14px] text-graphite">Loading callbacks…</p>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-[14px] text-bloom-deep">
|
||||
{extractApiError(error, t('state.error.desc'))}
|
||||
Could not load callbacks: {extractApiError(error, 'Unknown error')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (callbacks.length === 0) {
|
||||
return <p className="text-[14px] text-graphite">{t('c2.modal.picker.empty')}</p>;
|
||||
return <p className="text-[14px] text-graphite">No callbacks available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -44,12 +41,12 @@ export function C2CallbackPicker({
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<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">{t('c2.modal.picker.col.active')}</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">{t('c2.modal.picker.col.user')}</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">{t('c2.modal.picker.col.lastCheckin')}</th>
|
||||
<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">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">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">Last check-in</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -81,7 +78,7 @@ export function C2CallbackPicker({
|
||||
: 'bg-cloud text-graphite border border-hairline'
|
||||
}`}
|
||||
>
|
||||
{cb.active ? t('c2.modal.picker.status.active') : t('c2.modal.picker.status.inactive')}
|
||||
{cb.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono">{cb.host}</td>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
@@ -11,7 +10,6 @@ interface C2ConfigCardProps {
|
||||
}
|
||||
|
||||
export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { push } = useToast();
|
||||
|
||||
const configQuery = useC2Config(engagementId);
|
||||
@@ -57,11 +55,11 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync(input);
|
||||
push(t('c2.config.toast.saved'), 'success');
|
||||
push('C2 configuration saved', 'success');
|
||||
setToken('');
|
||||
setReplaceToken(false);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('c2.config.error.save')), 'error');
|
||||
push(extractApiError(err, 'Could not save C2 configuration'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,13 +68,13 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
setTestResult(null);
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
push(t('c2.config.toast.deleted'), 'success');
|
||||
push('C2 configuration removed', 'success');
|
||||
setUrl('');
|
||||
setToken('');
|
||||
setVerifyTls(false);
|
||||
setReplaceToken(false);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('c2.config.error.delete')), 'error');
|
||||
push(extractApiError(err, 'Could not remove C2 configuration'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,10 +84,10 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
const result = await testMutation.mutateAsync();
|
||||
setTestResult({
|
||||
ok: result.ok,
|
||||
message: result.ok ? t('c2.config.connected') : (result.error ?? t('c2.config.connectionFailed')),
|
||||
message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'),
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: extractApiError(err, t('c2.config.connectionFailed')) });
|
||||
setTestResult({ ok: false, message: extractApiError(err, 'Test failed') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,25 +98,25 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
data-testid="c2-config-card"
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<h2 className="text-[20px] font-medium text-ink">{t('c2.config.title')}</h2>
|
||||
<h2 className="text-[20px] font-medium text-ink">C2 configuration</h2>
|
||||
|
||||
{is503 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
|
||||
>
|
||||
{t('c2.config.disabled')}
|
||||
C2 features are disabled (server has no encryption key configured).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<p className="text-[14px] text-graphite">{t('c2.config.loading')}</p>
|
||||
<p className="text-[14px] text-graphite">Loading…</p>
|
||||
) : (
|
||||
<form onSubmit={onSave} noValidate className="flex flex-col gap-md">
|
||||
<FormField
|
||||
label={t('c2.config.field.url')}
|
||||
label="URL"
|
||||
htmlFor="c2-url"
|
||||
hint={t('c2.config.field.urlHint')}
|
||||
hint="HTTPS required (e.g. https://mythic.lab:7443)"
|
||||
>
|
||||
<TextInput
|
||||
id="c2-url"
|
||||
@@ -132,7 +130,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('c2.config.field.token')} htmlFor="c2-token">
|
||||
<FormField label="API token" htmlFor="c2-token">
|
||||
{config?.has_token && !replaceToken ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<TextInput
|
||||
@@ -151,7 +149,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
onClick={() => setReplaceToken(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('c2.config.btn.replaceToken')}
|
||||
Replace token
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -160,7 +158,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
data-testid="c2-token-input"
|
||||
type="password"
|
||||
name="api_token"
|
||||
placeholder={config?.has_token ? t('c2.config.field.tokenHint') : t('c2.config.field.token')}
|
||||
placeholder={config?.has_token ? 'Enter new token' : 'API token'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={disabled}
|
||||
@@ -180,11 +178,12 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
|
||||
{t('c2.config.field.verifyTls')}
|
||||
Verify TLS certificate
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[12px] text-graphite">
|
||||
{t('c2.config.field.verifyTlsHint')}
|
||||
Uncheck only for lab Mythic with self-signed certificates. Disabling
|
||||
verification exposes the API token to MITM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +194,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
className="btn-primary"
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
{updateMutation.isPending ? t('c2.config.btn.saving') : t('c2.config.btn.save')}
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -205,7 +204,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
onClick={onTest}
|
||||
disabled={disabled || testMutation.isPending || !config}
|
||||
>
|
||||
{testMutation.isPending ? t('c2.config.btn.testing') : t('c2.config.btn.test')}
|
||||
{testMutation.isPending ? 'Testing…' : 'Test connection'}
|
||||
</button>
|
||||
|
||||
{testResult !== null && (
|
||||
@@ -224,7 +223,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
{t('c2.config.btn.delete')}
|
||||
Delete configuration
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,10 +232,10 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title={t('c2.config.deleteConfirm.title')}
|
||||
description={t('c2.config.deleteConfirm.desc')}
|
||||
confirmLabel={t('c2.config.deleteConfirm.confirm')}
|
||||
cancelLabel={t('c2.config.deleteConfirm.cancel')}
|
||||
title="Delete C2 configuration"
|
||||
description="This will remove the C2 configuration for this engagement. The API token will be permanently deleted."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useC2Tasks } from '@/hooks/useC2';
|
||||
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
|
||||
|
||||
@@ -9,7 +8,6 @@ interface C2TasksPanelProps {
|
||||
}
|
||||
|
||||
export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const query = useC2Tasks(simulationId, { enabled: true });
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
@@ -35,13 +33,13 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-medium text-ink">{t('c2.tasks.title')}</h3>
|
||||
<h3 className="text-[16px] font-medium text-ink">C2 Tasks</h3>
|
||||
{isRefreshing && (
|
||||
<span
|
||||
data-testid="c2-task-refresh-indicator"
|
||||
className="text-[12px] text-graphite"
|
||||
>
|
||||
{t('c2.tasks.refreshing')}
|
||||
Refreshing…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -49,7 +47,7 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
||||
{tasks.length === 0 ? (
|
||||
<div className="border border-hairline rounded-none px-md py-md">
|
||||
<p className="text-[14px] text-graphite">
|
||||
{t('c2.tasks.empty')}
|
||||
No C2 tasks yet. Use Execute via C2 to launch commands.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -57,12 +55,12 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<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">{t('c2.tasks.col.task')}</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">{t('c2.tasks.col.source')}</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">{t('c2.tasks.col.completedAt')}</th>
|
||||
<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">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">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">Completed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ErrorStateProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorState({ title, message, onRetry }: ErrorStateProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('state.error.title');
|
||||
export function ErrorState({ title = 'Something went wrong', message, onRetry }: ErrorStateProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="error-state"
|
||||
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">{resolvedTitle}</h2>
|
||||
<h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2>
|
||||
<p className="text-[16px] text-charcoal">{message}</p>
|
||||
{onRetry ? (
|
||||
<button type="button" className="btn-outline" onClick={onRetry}>
|
||||
{t('state.retry')}
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
|
||||
import { C2CallbackPicker } from './C2CallbackPicker';
|
||||
@@ -18,7 +17,6 @@ export function ExecuteViaC2Modal({
|
||||
initialCommands,
|
||||
onClose,
|
||||
}: ExecuteViaC2ModalProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { push } = useToast();
|
||||
|
||||
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
|
||||
@@ -45,10 +43,10 @@ export function ExecuteViaC2Modal({
|
||||
callback_display_id: selectedId,
|
||||
commands: commandLines,
|
||||
});
|
||||
push(t('c2.modal.execute.toast.launched', { count: result.tasks.length }), 'success');
|
||||
push(`${result.tasks.length} task(s) submitted`, 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, t('c2.modal.execute.error.launch')));
|
||||
setSubmitError(extractApiError(err, 'Could not execute via C2'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,12 +62,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">
|
||||
<h2 id="c2-modal-title" className="text-[20px] font-medium text-ink">
|
||||
{t('c2.modal.execute.title')}
|
||||
Execute via C2
|
||||
</h2>
|
||||
|
||||
{/* Callback picker */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">{t('c2.modal.execute.callback')}</span>
|
||||
<span className="text-[14px] font-medium text-ink">Select callback</span>
|
||||
<C2CallbackPicker
|
||||
callbacks={callbacks}
|
||||
isLoading={callbacksQuery.isLoading}
|
||||
@@ -84,7 +82,7 @@ export function ExecuteViaC2Modal({
|
||||
{/* Commands */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<label htmlFor="c2-commands" className="text-[14px] font-medium text-ink">
|
||||
{t('simulation.field.commands')}
|
||||
Commands
|
||||
</label>
|
||||
<textarea
|
||||
id="c2-commands"
|
||||
@@ -92,10 +90,10 @@ export function ExecuteViaC2Modal({
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
className="text-input min-h-[112px] py-sm font-mono text-[14px]"
|
||||
placeholder={t('simulation.field.commands')}
|
||||
placeholder="One command per line"
|
||||
/>
|
||||
<span className="text-[12px] text-graphite">
|
||||
{t('c2.modal.execute.commandCount', { count: commandLines.length })}
|
||||
{commandLines.length} command{commandLines.length !== 1 ? 's' : ''} — one task per line
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +112,7 @@ export function ExecuteViaC2Modal({
|
||||
onClick={onLaunch}
|
||||
disabled={!canLaunch || executeMutation.isPending}
|
||||
>
|
||||
{executeMutation.isPending ? t('c2.modal.execute.btn.launching') : t('c2.modal.execute.btn.launch')}
|
||||
{executeMutation.isPending ? 'Launching…' : 'Launch'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -122,7 +120,7 @@ export function ExecuteViaC2Modal({
|
||||
onClick={onClose}
|
||||
disabled={executeMutation.isPending}
|
||||
>
|
||||
{t('c2.modal.execute.btn.cancel')}
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, Download } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
@@ -15,7 +14,6 @@ const FORMATS: { label: string; value: ExportFormat }[] = [
|
||||
];
|
||||
|
||||
export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { push } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState<ExportFormat | null>(null);
|
||||
@@ -60,11 +58,11 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
data-testid="export-btn"
|
||||
>
|
||||
<Download size={14} aria-hidden /> {t('export.btn')}
|
||||
<Download size={14} aria-hidden /> Export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('export.aria')}
|
||||
aria-label="Export options"
|
||||
aria-expanded={open}
|
||||
className="btn-outline px-sm"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
@@ -91,7 +89,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
|
||||
>
|
||||
{loading === value ? (
|
||||
<span className="font-mono text-[11px] animate-pulse" aria-hidden>
|
||||
{t('export.btnLoading')}
|
||||
EXPORTING…
|
||||
</span>
|
||||
) : null}
|
||||
{loading !== value ? label : null}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2';
|
||||
import { C2CallbackPicker } from './C2CallbackPicker';
|
||||
@@ -19,7 +18,6 @@ export function ImportC2HistoryModal({
|
||||
engagementId,
|
||||
onClose,
|
||||
}: ImportC2HistoryModalProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { push } = useToast();
|
||||
|
||||
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
|
||||
@@ -72,12 +70,12 @@ export function ImportC2HistoryModal({
|
||||
});
|
||||
const msg =
|
||||
result.skipped > 0
|
||||
? t('c2.modal.import.toast.partial', { imported: result.imported, skipped: result.skipped })
|
||||
: t('c2.modal.import.toast.imported', { count: result.imported });
|
||||
? `Imported ${result.imported} task(s), ${result.skipped} already attached`
|
||||
: `Imported ${result.imported} task(s)`;
|
||||
push(msg, 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, t('c2.modal.import.error.import')));
|
||||
setSubmitError(extractApiError(err, 'Could not import tasks'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,12 +91,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">
|
||||
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
|
||||
{t('c2.modal.import.title')}
|
||||
Import C2 history
|
||||
</h2>
|
||||
|
||||
{/* Step 1: callback picker */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">{t('c2.modal.picker.title')}</span>
|
||||
<span className="text-[14px] font-medium text-ink">Select callback</span>
|
||||
<C2CallbackPicker
|
||||
callbacks={callbacks}
|
||||
isLoading={callbacksQuery.isLoading}
|
||||
@@ -114,24 +112,24 @@ export function ImportC2HistoryModal({
|
||||
{selectedCallbackId !== null && (
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">
|
||||
{t('c2.tasks.title')}{' '}
|
||||
Task history{' '}
|
||||
{total > 0 && (
|
||||
<span className="text-graphite font-normal">({total} {t('c2.modal.import.total').toLowerCase()})</span>
|
||||
<span className="text-graphite font-normal">({total} total)</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{historyQuery.isLoading && (
|
||||
<p className="text-[14px] text-graphite">{t('state.loading')}</p>
|
||||
<p className="text-[14px] text-graphite">Loading history…</p>
|
||||
)}
|
||||
|
||||
{historyQuery.isError && (
|
||||
<p className="text-[14px] text-bloom-deep">
|
||||
{extractApiError(historyQuery.error, t('c2.modal.import.error.import'))}
|
||||
{extractApiError(historyQuery.error, 'Could not load history')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
|
||||
<p className="text-[14px] text-graphite">{t('c2.modal.import.empty')}</p>
|
||||
<p className="text-[14px] text-graphite">No task history for this callback.</p>
|
||||
)}
|
||||
|
||||
{historyTasks.length > 0 && (
|
||||
@@ -140,12 +138,12 @@ export function ImportC2HistoryModal({
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<th className="px-md py-sm w-8" aria-label={t('common.select')} />
|
||||
<th className="px-md py-sm w-8" aria-label="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">{t('c2.tasks.col.command')}</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">{t('c2.modal.import.col.completed')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.timestamp')}</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">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">Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -177,7 +175,7 @@ export function ImportC2HistoryModal({
|
||||
<C2TaskStatusBadge status={task.status} />
|
||||
</td>
|
||||
<td className="px-md py-sm text-[14px]">
|
||||
{task.completed ? t('common.yes') : t('common.no')}
|
||||
{task.completed ? 'Yes' : 'No'}
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono text-graphite">
|
||||
{task.created_at}
|
||||
@@ -197,10 +195,10 @@ export function ImportC2HistoryModal({
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
{t('c2.modal.import.prev')}
|
||||
Prev
|
||||
</button>
|
||||
<span>
|
||||
{t('c2.modal.import.page')} {page} {t('c2.modal.import.of')} {totalPages}
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -209,11 +207,11 @@ export function ImportC2HistoryModal({
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
{t('c2.modal.import.next')}
|
||||
Next
|
||||
</button>
|
||||
{checkedIds.size > 0 && (
|
||||
<span className="ml-auto text-ink">
|
||||
{checkedIds.size} {t('c2.modal.import.selected')}
|
||||
{checkedIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,7 +235,7 @@ export function ImportC2HistoryModal({
|
||||
onClick={onImport}
|
||||
disabled={!canImport || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? t('c2.modal.import.btn.importing') : t('c2.modal.import.btn.import')}
|
||||
{importMutation.isPending ? 'Importing…' : 'Import selected'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -245,7 +243,7 @@ export function ImportC2HistoryModal({
|
||||
onClick={onClose}
|
||||
disabled={importMutation.isPending}
|
||||
>
|
||||
{t('c2.modal.import.btn.cancel')}
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
@@ -21,7 +20,6 @@ export function Layout(): JSX.Element {
|
||||
const { user, isAdmin, isRedteam, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { theme, cycleTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
@@ -43,7 +41,7 @@ export function Layout(): JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleTheme}
|
||||
aria-label={t('nav.themeAria', { label: themeLabel(theme) })}
|
||||
aria-label={`Theme: ${themeLabel(theme)} — click to cycle`}
|
||||
className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text"
|
||||
>
|
||||
<ThemeIcon theme={theme} />
|
||||
@@ -54,7 +52,7 @@ export function Layout(): JSX.Element {
|
||||
onClick={handleLogout}
|
||||
className="text-[14px] underline-offset-2 hover:underline"
|
||||
>
|
||||
{t('nav.signOut')}
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -64,7 +62,7 @@ export function Layout(): JSX.Element {
|
||||
{/* nav-bar-top — fixed dark slab, never inverts (same visual family as utility-strip + footer) */}
|
||||
<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">
|
||||
<Link to="/engagements" className="flex items-center gap-sm" aria-label={t('nav.brandHome')}>
|
||||
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
|
||||
<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>
|
||||
</Link>
|
||||
@@ -80,7 +78,7 @@ export function Layout(): JSX.Element {
|
||||
}`
|
||||
}
|
||||
>
|
||||
{t('nav.engagements')}
|
||||
Engagements
|
||||
</NavLink>
|
||||
{isAdmin || isRedteam ? (
|
||||
<NavLink
|
||||
@@ -93,7 +91,7 @@ export function Layout(): JSX.Element {
|
||||
}`
|
||||
}
|
||||
>
|
||||
{t('nav.templates')}
|
||||
Templates
|
||||
</NavLink>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
@@ -107,7 +105,7 @@ export function Layout(): JSX.Element {
|
||||
}`
|
||||
}
|
||||
>
|
||||
{t('nav.users')}
|
||||
Users
|
||||
</NavLink>
|
||||
) : null}
|
||||
</nav>
|
||||
@@ -124,7 +122,7 @@ export function Layout(): JSX.Element {
|
||||
{/* footer — fixed dark slab, never inverts */}
|
||||
<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">
|
||||
{t('nav.footer')}
|
||||
Mimic — Internal Purple Team tooling. Authorized engagements only.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { extractApiError } from '@/api/client';
|
||||
@@ -42,7 +41,6 @@ export function MitreMatrixModal({
|
||||
onApply,
|
||||
onCancel,
|
||||
}: MitreMatrixModalProps): JSX.Element | null {
|
||||
const { t } = useTranslation();
|
||||
const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen);
|
||||
|
||||
const [selectedTechMap, setSelectedTechMap] = useState<Map<string, { id: string; name: string }>>(
|
||||
@@ -179,24 +177,24 @@ export function MitreMatrixModal({
|
||||
{/* Header */}
|
||||
<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">
|
||||
{t('mitre.matrix.title')}
|
||||
MITRE ATT&CK Matrix
|
||||
</h2>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder={t('mitre.matrix.filter')}
|
||||
placeholder="Filter techniques…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="text-input w-56 h-9 text-[14px]"
|
||||
aria-label={t('mitre.matrix.filter')}
|
||||
aria-label="Filter techniques"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body — overflow-y-auto, NO overflow-x */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-md py-md">
|
||||
{isLoading && <LoadingState label={t('mitre.matrix.loading')} />}
|
||||
{isLoading && <LoadingState label="Loading MITRE matrix…" />}
|
||||
{isError && (
|
||||
<ErrorState message={extractApiError(error, t('mitre.matrix.error'))} />
|
||||
<ErrorState message={extractApiError(error, 'Could not load MITRE matrix')} />
|
||||
)}
|
||||
{!isLoading && !isError && matrix && (
|
||||
<div
|
||||
@@ -278,7 +276,7 @@ export function MitreMatrixModal({
|
||||
{hasSubtechniques ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? t('common.collapse', { id: tech.id }) : t('common.expand', { id: tech.id })}
|
||||
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`}
|
||||
onClick={() => toggleExpand(tech.id)}
|
||||
className={`mr-[2px] flex-shrink-0 text-[9px] w-3 leading-none mt-[1px] ${
|
||||
isSelected ? 'text-white' : 'text-graphite'
|
||||
@@ -329,7 +327,7 @@ export function MitreMatrixModal({
|
||||
);
|
||||
})}
|
||||
{visibleTechniques.length === 0 && searchLower && (
|
||||
<div className="px-xs py-xxs text-[10px] text-graphite italic">{t('mitre.matrix.noMatch')}</div>
|
||||
<div className="px-xs py-xxs text-[10px] text-graphite italic">No match</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,7 +340,7 @@ export function MitreMatrixModal({
|
||||
{/* Footer */}
|
||||
<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}>
|
||||
{t('mitre.matrix.close')}
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -351,8 +349,8 @@ export function MitreMatrixModal({
|
||||
disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)}
|
||||
>
|
||||
{totalSelected === 0
|
||||
? t('mitre.matrix.clearAll')
|
||||
: t(totalSelected === 1 ? 'mitre.matrix.applyItem_one' : 'mitre.matrix.applyItem_other', { count: totalSelected })}
|
||||
? 'Clear all'
|
||||
: `Apply ${totalSelected} item${totalSelected !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
import { useMitreSearch } from '@/hooks/useMitre';
|
||||
@@ -15,7 +14,6 @@ export function MitreTechniquePicker({
|
||||
onSelect,
|
||||
disabled = false,
|
||||
}: MitreTechniquePickerProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -97,31 +95,31 @@ export function MitreTechniquePicker({
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined}
|
||||
aria-label={t('mitre.field.search')}
|
||||
aria-label="Search MITRE technique"
|
||||
className="text-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={t('mitre.field.search')}
|
||||
placeholder="Search by ID or name (e.g. T1059)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-20 w-full mt-xxs bg-paper border border-steel rounded-none overflow-hidden">
|
||||
{isFetching && (
|
||||
<div className="px-md py-sm text-[14px] text-graphite">{t('state.loading')}</div>
|
||||
<div className="px-md py-sm text-[14px] text-graphite">Searching…</div>
|
||||
)}
|
||||
|
||||
{isError && !isFetching && (
|
||||
<div className="px-md py-sm text-[14px] text-bloom-deep" role="alert">
|
||||
{extractApiError(error, t('mitre.matrix.error'))}
|
||||
{extractApiError(error, 'MITRE search unavailable')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
|
||||
<div className="px-md py-sm text-[14px] text-graphite">{t('state.empty.default')}</div>
|
||||
<div className="px-md py-sm text-[14px] text-graphite">No results</div>
|
||||
)}
|
||||
|
||||
{!isFetching && items.length > 0 && (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
|
||||
interface TechniqueTagProps {
|
||||
@@ -19,7 +18,6 @@ export function MitreTechniqueTag({
|
||||
onRemove,
|
||||
disabled = false,
|
||||
}: TechniqueTagProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
data-testid="mitre-technique-tag"
|
||||
@@ -30,7 +28,7 @@ export function MitreTechniqueTag({
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('mitre.tag.remove')} ${technique.id}`}
|
||||
aria-label={`Remove ${technique.id}`}
|
||||
onClick={onRemove}
|
||||
className="text-primary-deep opacity-60 hover:opacity-100 leading-none"
|
||||
>
|
||||
@@ -47,7 +45,6 @@ export function MitreTacticTag({
|
||||
onRemove,
|
||||
disabled = false,
|
||||
}: TacticTagProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
data-testid="mitre-tactic-tag"
|
||||
@@ -58,7 +55,7 @@ export function MitreTacticTag({
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('mitre.tag.remove')} ${tactic.id}`}
|
||||
aria-label={`Remove ${tactic.id}`}
|
||||
onClick={onRemove}
|
||||
className="text-white opacity-60 hover:opacity-100 leading-none"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid2x2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
import { useUpdateSimulation } from '@/hooks/useSimulations';
|
||||
@@ -25,7 +24,6 @@ export function MitreTechniquesField({
|
||||
engagementId,
|
||||
disabled = false,
|
||||
}: MitreTechniquesFieldProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [showMatrix, setShowMatrix] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
@@ -35,34 +33,34 @@ export function MitreTechniquesField({
|
||||
const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
technique_ids: techniques.map((tech) => tech.id),
|
||||
tactic_ids: nextTactics.map((tac) => tac.id),
|
||||
technique_ids: techniques.map((t) => t.id),
|
||||
tactic_ids: nextTactics.map((t) => t.id),
|
||||
});
|
||||
push(t('mitre.field.savedToast'), 'success');
|
||||
push('Techniques updated', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('mitre.field.errorToast')), 'error');
|
||||
push(extractApiError(err, 'Could not update techniques'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTechnique = (id: string) => {
|
||||
void save(value.filter((tech) => tech.id !== id), tactics);
|
||||
void save(value.filter((t) => t.id !== id), tactics);
|
||||
};
|
||||
|
||||
const handleRemoveTactic = (id: string) => {
|
||||
void save(value, tactics.filter((tac) => tac.id !== id));
|
||||
void save(value, tactics.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const handleSelect = (technique: MitreTechnique) => {
|
||||
if (value.some((tech) => tech.id === technique.id)) return;
|
||||
if (value.some((t) => t.id === technique.id)) return;
|
||||
void save([...value, technique], tactics);
|
||||
setShowPicker(false);
|
||||
};
|
||||
|
||||
const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => {
|
||||
setShowMatrix(false);
|
||||
const merged = techniques.map((sel) => {
|
||||
const existing = value.find((v) => v.id === sel.id);
|
||||
return existing ?? sel;
|
||||
const merged = techniques.map((s) => {
|
||||
const existing = value.find((v) => v.id === s.id);
|
||||
return existing ?? s;
|
||||
});
|
||||
void save(merged, newTactics);
|
||||
};
|
||||
@@ -74,22 +72,22 @@ export function MitreTechniquesField({
|
||||
<div className="flex flex-col gap-sm">
|
||||
{/* Chips area */}
|
||||
{isEmpty ? (
|
||||
<p className="text-[13px] text-graphite">{t('mitre.field.empty')}</p>
|
||||
<p className="text-[13px] text-graphite">No techniques selected</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
|
||||
{tactics.map((tac) => (
|
||||
{tactics.map((t) => (
|
||||
<MitreTacticTag
|
||||
key={tac.id}
|
||||
tactic={tac}
|
||||
onRemove={() => handleRemoveTactic(tac.id)}
|
||||
key={t.id}
|
||||
tactic={t}
|
||||
onRemove={() => handleRemoveTactic(t.id)}
|
||||
disabled={disabled || isPending}
|
||||
/>
|
||||
))}
|
||||
{value.map((tech) => (
|
||||
{value.map((t) => (
|
||||
<MitreTechniqueTag
|
||||
key={tech.id}
|
||||
technique={tech}
|
||||
onRemove={() => handleRemoveTechnique(tech.id)}
|
||||
key={t.id}
|
||||
technique={t}
|
||||
onRemove={() => handleRemoveTechnique(t.id)}
|
||||
disabled={disabled || isPending}
|
||||
/>
|
||||
))}
|
||||
@@ -109,20 +107,20 @@ export function MitreTechniquesField({
|
||||
onClick={() => setShowPicker(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('mitre.field.search')}
|
||||
Search technique (e.g. T1059)…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mitre.field.openMatrix')}
|
||||
aria-label="Open MITRE matrix"
|
||||
onClick={() => { setShowPicker(false); setShowMatrix(true); }}
|
||||
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"
|
||||
>
|
||||
<Grid2x2 size={16} />
|
||||
</button>
|
||||
{isPending && <span className="text-[12px] text-graphite">{t('common.saving')}</span>}
|
||||
{isPending && <span className="text-[12px] text-graphite">Saving…</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { Role } from '@/api/types';
|
||||
@@ -24,7 +23,6 @@ export function ProtectedRoute({
|
||||
}: ProtectedRouteProps): JSX.Element {
|
||||
const { user, status } = useAuth();
|
||||
const { push } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const roleDenied = Boolean(
|
||||
@@ -33,12 +31,12 @@ export function ProtectedRoute({
|
||||
|
||||
useEffect(() => {
|
||||
if (roleDenied) {
|
||||
push(t('auth.forbidden'), 'error');
|
||||
push('Accès refusé', 'error');
|
||||
}
|
||||
}, [roleDenied, push, t]);
|
||||
}, [roleDenied, push]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return <LoadingState label={t('auth.loadingSession')} />;
|
||||
return <LoadingState label="Loading session…" />;
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated' || !user) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { EmptyState } from './EmptyState';
|
||||
@@ -18,8 +16,12 @@ interface SimulationListProps {
|
||||
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 {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { push } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -59,10 +61,10 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
try {
|
||||
const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id });
|
||||
setShowPicker(false);
|
||||
push(t('simulation.form.toast.created'), 'success');
|
||||
push(`Created "${sim.name}" from template`, 'success');
|
||||
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('simulation.form.error.create')), 'error');
|
||||
push(extractApiError(err, 'Could not create simulation from template'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,11 +77,11 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
onClick={handleBlank}
|
||||
data-testid="new-simulation-btn"
|
||||
>
|
||||
<Plus size={14} aria-hidden /> {t('simulation.list.new')}
|
||||
<Plus size={14} aria-hidden /> New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('simulation.list.rowActions')}
|
||||
aria-label="More options"
|
||||
aria-expanded={open}
|
||||
className="btn-primary px-sm"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
@@ -131,17 +133,16 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
}
|
||||
|
||||
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
|
||||
const { canEditEngagements } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isLoading) return <LoadingState label={t('common.loading')} />;
|
||||
if (isLoading) return <LoadingState label="Loading simulations…" />;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(error, t('simulation.list.error'))}
|
||||
message={extractApiError(error, 'Could not load simulations')}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -150,8 +151,8 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('simulation.list.empty.title')}
|
||||
description={t('simulation.list.empty.desc')}
|
||||
title="No simulations yet"
|
||||
description="Create the first simulation to start tracking red team tests."
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<NewSimulationDropdown engagementId={engagementId} />
|
||||
@@ -164,7 +165,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
return (
|
||||
<div className="flex flex-col gap-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[24px] font-medium text-ink">{t('engagement.detail.tabs.simulations')}</h2>
|
||||
<h2 className="text-[24px] font-medium text-ink">Simulations</h2>
|
||||
{canEditEngagements ? (
|
||||
<NewSimulationDropdown engagementId={engagementId} />
|
||||
) : null}
|
||||
@@ -174,10 +175,10 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr>
|
||||
<th>{t('simulation.list.col.name')}</th>
|
||||
<th>{t('simulation.list.col.mitre')}</th>
|
||||
<th>{t('simulation.list.col.status')}</th>
|
||||
<th>{t('simulation.list.col.executedAt')}</th>
|
||||
<th>Name</th>
|
||||
<th>MITRE</th>
|
||||
<th>Status</th>
|
||||
<th>Executed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -201,8 +202,8 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
<td className="text-charcoal font-mono">
|
||||
{(() => {
|
||||
const items = [
|
||||
...(sim.tactics ?? []).map((tactic) => tactic.id),
|
||||
...sim.techniques.map((tech) => tech.id),
|
||||
...(sim.tactics ?? []).map((t) => t.id),
|
||||
...sim.techniques.map((t) => t.id),
|
||||
];
|
||||
if (items.length === 0) return '—';
|
||||
if (items.length === 1) return items[0];
|
||||
@@ -213,7 +214,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
<SimulationStatusBadge status={sim.status} />
|
||||
</td>
|
||||
<td className="text-charcoal font-mono">
|
||||
{sim.executed_at ? formatDateTime(sim.executed_at) : '—'}
|
||||
{formatDate(sim.executed_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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> = {
|
||||
pending: 'bg-cloud text-graphite border border-hairline',
|
||||
in_progress: 'bg-primary-soft text-primary-deep',
|
||||
@@ -9,14 +15,13 @@ const STYLES: Record<SimulationStatus, string> = {
|
||||
};
|
||||
|
||||
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
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-status={status}
|
||||
>
|
||||
{t(`status.simulation.${status}`)}
|
||||
{LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { EngagementStatus } from '@/api/types';
|
||||
|
||||
const LABELS: Record<EngagementStatus, string> = {
|
||||
planned: 'Planned',
|
||||
active: 'Active',
|
||||
closed: 'Closed',
|
||||
};
|
||||
|
||||
const STYLES: Record<EngagementStatus, string> = {
|
||||
planned: 'bg-warn-soft text-warn border border-warn',
|
||||
active: 'bg-primary-soft text-primary-deep',
|
||||
@@ -8,14 +13,13 @@ const STYLES: Record<EngagementStatus, string> = {
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
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-status={status}
|
||||
>
|
||||
{t(`status.engagement.${status}`)}
|
||||
{LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ interface TabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
@@ -14,23 +13,13 @@ interface TabsProps {
|
||||
}
|
||||
|
||||
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));
|
||||
onChange(items[(index + 1) % items.length].id);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
onChange(nextEnabled(index, -1));
|
||||
onChange(items[(index - 1 + items.length) % items.length].id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +30,6 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const isActive = item.id === activeId;
|
||||
const isDisabled = item.disabled ?? false;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -49,12 +37,10 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tabpanel-${item.id}`}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
onClick={() => { if (!isDisabled) onChange(item.id); }}
|
||||
onClick={() => onChange(item.id)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
className={`tab-underline${isActive ? ' tab-underline-active' : ''}${isDisabled ? ' tab-underline-disabled' : ''} pb-xs`}
|
||||
className={`tab-underline${isActive ? ' tab-underline-active' : ''} pb-xs`}
|
||||
>
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
import { useTemplates } from '@/hooks/useTemplates';
|
||||
@@ -24,7 +23,6 @@ export function TemplatePickerModal({
|
||||
onSelectTemplate,
|
||||
isPending = false,
|
||||
}: TemplatePickerModalProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useTemplates();
|
||||
|
||||
return (
|
||||
@@ -39,11 +37,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="flex items-center justify-between">
|
||||
<h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink">
|
||||
{t('template.picker.title')}
|
||||
From template…
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
className="text-graphite hover:text-ink text-[20px] leading-none"
|
||||
>
|
||||
@@ -52,18 +50,19 @@ export function TemplatePickerModal({
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 -mx-xl px-xl">
|
||||
{isLoading ? <LoadingState label={t('template.picker.loading')} /> : null}
|
||||
{isLoading ? <LoadingState label="Loading templates…" /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(error, t('template.form.errorLoad'))}
|
||||
message={extractApiError(error, 'Could not load templates')}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('template.picker.empty')}
|
||||
title="No templates available"
|
||||
description="Create one from the Templates page."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -71,22 +70,22 @@ export function TemplatePickerModal({
|
||||
<table className="w-full text-left" data-testid="template-picker-table">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-md py-sm">{t('template.list.col.name')}</th>
|
||||
<th className="px-md py-sm">{t('simulation.list.col.mitre')}</th>
|
||||
<th className="px-md py-sm">{t('template.list.col.createdBy')}</th>
|
||||
<th className="px-md py-sm">Name</th>
|
||||
<th className="px-md py-sm">MITRE</th>
|
||||
<th className="px-md py-sm">Created by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((template) => (
|
||||
{data.map((t) => (
|
||||
<tr
|
||||
key={template.id}
|
||||
key={t.id}
|
||||
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
|
||||
onClick={() => !isPending && onSelectTemplate(template)}
|
||||
data-testid={`template-row-${template.id}`}
|
||||
onClick={() => !isPending && onSelectTemplate(t)}
|
||||
data-testid={`template-row-${t.id}`}
|
||||
>
|
||||
<td className="px-md py-sm text-ink font-medium">{template.name}</td>
|
||||
<td className="px-md py-sm text-charcoal text-[14px]">{mitreCount(template)}</td>
|
||||
<td className="px-md py-sm text-charcoal text-[14px]">{template.created_by.username}</td>
|
||||
<td className="px-md py-sm text-ink font-medium">{t.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]">{t.created_by.username}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -96,7 +95,7 @@ export function TemplatePickerModal({
|
||||
|
||||
<div className="border-t border-hairline pt-sm">
|
||||
<button type="button" className="btn-outline-ink" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
export function ToastViewport(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { toasts, dismiss } = useToast();
|
||||
return (
|
||||
<div
|
||||
@@ -10,9 +8,9 @@ export function ToastViewport(): JSX.Element {
|
||||
aria-atomic="true"
|
||||
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
|
||||
>
|
||||
{toasts.map((toast) => {
|
||||
const isError = toast.kind === 'error';
|
||||
const isSuccess = toast.kind === 'success';
|
||||
{toasts.map((t) => {
|
||||
const isError = t.kind === 'error';
|
||||
const isSuccess = t.kind === 'success';
|
||||
const surface = isError
|
||||
? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep'
|
||||
: isSuccess
|
||||
@@ -20,18 +18,18 @@ export function ToastViewport(): JSX.Element {
|
||||
: 'bg-paper text-ink border border-hairline border-l-4 border-l-primary';
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
key={t.id}
|
||||
role="status"
|
||||
data-testid="toast"
|
||||
data-kind={toast.kind}
|
||||
data-kind={t.kind}
|
||||
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">
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label={t('toast.dismiss')}
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="text-current opacity-70 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
function readHash(defaultId: string, validIds?: string[]): string {
|
||||
function readHash(defaultId: string): string {
|
||||
const hash = window.location.hash.slice(1); // strip leading '#'
|
||||
if (!hash) return defaultId;
|
||||
if (validIds && !validIds.includes(hash)) return defaultId;
|
||||
return hash;
|
||||
return hash || defaultId;
|
||||
}
|
||||
|
||||
export function useHashTab(defaultId: string, validIds?: string[]): [string, (id: string) => void] {
|
||||
const [activeId, setActiveId] = useState<string>(() => readHash(defaultId, validIds));
|
||||
export function useHashTab(defaultId: string): [string, (id: string) => void] {
|
||||
const [activeId, setActiveId] = useState<string>(() => readHash(defaultId));
|
||||
|
||||
useEffect(() => {
|
||||
function onHashChange() {
|
||||
setActiveId(readHash(defaultId, validIds));
|
||||
setActiveId(readHash(defaultId));
|
||||
}
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, [defaultId, validIds]);
|
||||
}, [defaultId]);
|
||||
|
||||
const navigate = useCallback((id: string) => {
|
||||
// replaceState: no history entry (Back button unaffected), no anchor-jump scroll
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
{
|
||||
"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é"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,8 +0,0 @@
|
||||
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}`);
|
||||
@@ -1,7 +0,0 @@
|
||||
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');
|
||||
@@ -5,7 +5,6 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { ToastProvider } from './hooks/useToast';
|
||||
import './i18n';
|
||||
import './styles/index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagement } from '@/hooks/useEngagements';
|
||||
@@ -12,12 +11,11 @@ import { SimulationList } from '@/components/SimulationList';
|
||||
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import { Tabs } from '@/components/Tabs';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type TabId = 'description' | 'simulations';
|
||||
type TabId = 'schedule' | 'description' | 'simulations';
|
||||
|
||||
export function EngagementDetailPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const { canEditEngagements } = useAuth();
|
||||
@@ -25,44 +23,49 @@ export function EngagementDetailPage(): JSX.Element {
|
||||
const detail = useEngagement(numericId);
|
||||
const simsQuery = useEngagementSimulations(numericId);
|
||||
|
||||
const [activeTabRaw, setActiveTab] = useHashTab('description');
|
||||
const [activeTabRaw, setActiveTab] = useHashTab('schedule');
|
||||
const activeTab = activeTabRaw as TabId;
|
||||
|
||||
if (detail.isLoading) return <LoadingState label={t('engagement.detail.loading')} />;
|
||||
if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, t('engagement.detail.errorLoad'))}
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!detail.data) return <ErrorState message={t('engagement.detail.notFound')} />;
|
||||
if (!detail.data) return <ErrorState message="Engagement not found" />;
|
||||
|
||||
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 },
|
||||
{ id: 'schedule', label: 'Schedule' },
|
||||
{ id: 'description', label: 'Description' },
|
||||
{ id: 'simulations', label: 'Simulations', count: simCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<BackLink to="/engagements">{t('engagement.detail.backTo')}</BackLink>
|
||||
<BackLink to="/engagements">Back to engagements</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
|
||||
<div className="flex items-center gap-md">
|
||||
<StatusBadge status={eng.status} />
|
||||
<span className="text-[14px] text-graphite">
|
||||
{t('engagement.detail.createdBy')}{' '}
|
||||
<span className="text-ink">{eng.created_by.username}</span>
|
||||
Created by <span className="text-ink">{eng.created_by.username}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<ExportEngagementButton engagementId={eng.id} />
|
||||
<div className="flex items-center gap-sm">
|
||||
<ExportEngagementButton engagementId={eng.id} />
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
@@ -73,29 +76,27 @@ export function EngagementDetailPage(): JSX.Element {
|
||||
id={`tabpanel-${activeTab}`}
|
||||
aria-labelledby={`tab-${activeTab}`}
|
||||
>
|
||||
{activeTab === 'schedule' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeTab === 'description' && (
|
||||
<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>
|
||||
<div className="card-product">
|
||||
<h2 className="text-[20px] font-medium mb-md">Description</h2>
|
||||
<p className="text-[16px] text-charcoal whitespace-pre-line">
|
||||
{eng.description?.trim() ? eng.description : t('engagement.detail.noDescription')}
|
||||
{eng.description?.trim() ? eng.description : 'No description provided.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { EngagementInput, EngagementStatus } from '@/api/types';
|
||||
import {
|
||||
@@ -14,7 +13,12 @@ import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
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 {
|
||||
name: string;
|
||||
@@ -32,8 +36,17 @@ const EMPTY: FormState = {
|
||||
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 {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const editing = Boolean(id);
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
@@ -49,22 +62,6 @@ export function EngagementFormPage(): JSX.Element {
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (editing && detail.data) {
|
||||
@@ -78,11 +75,11 @@ export function EngagementFormPage(): JSX.Element {
|
||||
}
|
||||
}, [editing, detail.data]);
|
||||
|
||||
if (editing && detail.isLoading) return <LoadingState label={t('engagement.form.loading')} />;
|
||||
if (editing && detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (editing && detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, t('engagement.form.error.load'))}
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -112,15 +109,15 @@ export function EngagementFormPage(): JSX.Element {
|
||||
try {
|
||||
if (editing && numericId) {
|
||||
await patchMutation.mutateAsync(payload);
|
||||
push(t('engagement.form.toast.updated'), 'success');
|
||||
push('Engagement updated', 'success');
|
||||
navigate(`/engagements/${numericId}`);
|
||||
} else {
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
push(t('engagement.form.toast.created'), 'success');
|
||||
push('Engagement created', 'success');
|
||||
navigate(`/engagements/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, t('engagement.form.error.save')));
|
||||
setSubmitError(extractApiError(err, 'Could not save engagement'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,10 +127,12 @@ export function EngagementFormPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header>
|
||||
<h1 className="text-[32px] font-medium leading-none">
|
||||
{editing ? t('engagement.form.title.edit') : t('engagement.form.title.new')}
|
||||
{editing ? 'Edit engagement' : 'New engagement'}
|
||||
</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
{editing ? t('engagement.form.subtitle.edit') : t('engagement.form.subtitle.new')}
|
||||
{editing
|
||||
? 'Update the engagement metadata.'
|
||||
: 'Create a new red team mission to host simulations.'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -145,7 +144,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
}
|
||||
>
|
||||
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label={t('engagement.form.field.name')} htmlFor="eng-name" required error={errors.name}>
|
||||
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
|
||||
<TextInput
|
||||
id="eng-name"
|
||||
name="name"
|
||||
@@ -155,7 +154,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('engagement.form.field.description')} htmlFor="eng-description">
|
||||
<FormField label="Description" htmlFor="eng-description">
|
||||
<TextArea
|
||||
id="eng-description"
|
||||
name="description"
|
||||
@@ -166,7 +165,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<FormField
|
||||
label={t('engagement.form.field.startDate')}
|
||||
label="Start date"
|
||||
htmlFor="eng-start"
|
||||
required
|
||||
error={errors.start_date}
|
||||
@@ -182,9 +181,9 @@ export function EngagementFormPage(): JSX.Element {
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('engagement.form.field.endDate')}
|
||||
label="End date"
|
||||
htmlFor="eng-end"
|
||||
hint={t('engagement.form.field.endDateHint')}
|
||||
hint="Leave empty to clear / leave open-ended"
|
||||
error={errors.end_date}
|
||||
>
|
||||
<TextInput
|
||||
@@ -197,7 +196,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label={t('engagement.form.field.status')} htmlFor="eng-status" required>
|
||||
<FormField label="Status" htmlFor="eng-status" required>
|
||||
<Select
|
||||
id="eng-status"
|
||||
name="status"
|
||||
@@ -214,18 +213,14 @@ export function EngagementFormPage(): JSX.Element {
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-md pt-sm">
|
||||
<button type="submit" className="btn-primary" disabled={submitting} data-testid="btn-submit">
|
||||
{submitting
|
||||
? t('engagement.form.btn.saving')
|
||||
: editing
|
||||
? t('engagement.form.btn.save')
|
||||
: t('engagement.form.btn.create')}
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
|
||||
</button>
|
||||
<Link
|
||||
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
|
||||
className="btn-outline-ink"
|
||||
>
|
||||
{t('engagement.form.btn.cancel')}
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Engagement } from '@/api/types';
|
||||
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
|
||||
@@ -10,22 +9,25 @@ import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
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 {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementsList();
|
||||
const { canEditEngagements } = useAuth();
|
||||
const { push } = useToast();
|
||||
const deleteMutation = useDeleteEngagement();
|
||||
|
||||
const onDelete = async (eng: Engagement) => {
|
||||
if (!window.confirm(t('engagement.list.deleteConfirm', { name: eng.name }))) return;
|
||||
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(eng.id);
|
||||
push(t('engagement.list.toast.deleted'), 'success');
|
||||
push('Engagement deleted', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('engagement.list.error.delete')), 'error');
|
||||
push(extractApiError(err, 'Could not delete engagement'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,32 +35,32 @@ export function EngagementsListPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-end justify-between gap-md">
|
||||
<div>
|
||||
<h1 className="text-[32px] font-medium leading-none">{t('engagement.list.title')}</h1>
|
||||
<h1 className="text-[32px] font-medium leading-none">Engagements</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
{t('engagement.list.subtitle')}
|
||||
Red team missions and their lifecycle status.
|
||||
</p>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> {t('engagement.list.new')}
|
||||
<Plus size={14} aria-hidden /> New
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{isLoading ? <LoadingState label={t('common.loading')} /> : null}
|
||||
{isLoading ? <LoadingState label="Loading engagements…" /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState message={extractApiError(error, t('engagement.list.error.load'))} onRetry={() => refetch()} />
|
||||
<ErrorState message={extractApiError(error, 'Could not load engagements')} onRetry={() => refetch()} />
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('engagement.list.empty.title')}
|
||||
description={t('engagement.list.empty.desc')}
|
||||
title="No engagements yet"
|
||||
description="Create your first engagement to start tracking red team missions."
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> {t('engagement.list.new')}
|
||||
<Plus size={14} aria-hidden /> New
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
@@ -70,12 +72,12 @@ export function EngagementsListPage(): JSX.Element {
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr>
|
||||
<th>{t('engagement.list.col.name')}</th>
|
||||
<th>{t('engagement.list.col.status')}</th>
|
||||
<th>{t('engagement.list.col.start')}</th>
|
||||
<th>{t('engagement.list.col.end')}</th>
|
||||
<th>{t('engagement.list.col.createdBy')}</th>
|
||||
<th className="text-right">{t('engagement.list.col.actions')}</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Created by</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -95,12 +97,12 @@ export function EngagementsListPage(): JSX.Element {
|
||||
<td className="text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<Link to={`/engagements/${eng.id}`} className="btn-text-link">
|
||||
{t('engagement.list.view')}
|
||||
View
|
||||
</Link>
|
||||
{canEditEngagements ? (
|
||||
<>
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-text-link">
|
||||
{t('engagement.list.edit')}
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
@@ -108,7 +110,7 @@ export function EngagementsListPage(): JSX.Element {
|
||||
onClick={() => onDelete(eng)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t('engagement.list.delete')}
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { FormField, TextInput } from '@/components/FormField';
|
||||
@@ -15,7 +14,6 @@ export function LoginPage(): JSX.Element {
|
||||
const location = useLocation();
|
||||
const fromPath = (location.state as LocationState | null)?.from;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -36,7 +34,7 @@ export function LoginPage(): JSX.Element {
|
||||
await login(username, password);
|
||||
navigate(fromPath ?? '/engagements', { replace: true });
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, t('auth.login.invalid')));
|
||||
setError(extractApiError(err, 'Invalid credentials'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -50,10 +48,10 @@ export function LoginPage(): JSX.Element {
|
||||
<span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden />
|
||||
<h1 className="text-[28px] font-medium leading-none">Mimic</h1>
|
||||
</div>
|
||||
<p className="text-[16px] text-charcoal">{t('auth.login.subtitle')}</p>
|
||||
<p className="text-[16px] text-charcoal">Sign in to access your engagements.</p>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-md">
|
||||
<FormField label={t('auth.login.username')} htmlFor="login-username" required>
|
||||
<FormField label="Username" htmlFor="login-username" required>
|
||||
<TextInput
|
||||
id="login-username"
|
||||
name="username"
|
||||
@@ -64,7 +62,7 @@ export function LoginPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('auth.login.password')} htmlFor="login-password" required>
|
||||
<FormField label="Password" htmlFor="login-password" required>
|
||||
<TextInput
|
||||
id="login-password"
|
||||
type="password"
|
||||
@@ -83,7 +81,7 @@ export function LoginPage(): JSX.Element {
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? t('auth.login.signingIn') : t('auth.login.signIn')}
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Save, RotateCcw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationPatchInput } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useHashTab } from '@/hooks/useHashTab';
|
||||
import {
|
||||
useCreateSimulation,
|
||||
useDeleteSimulation,
|
||||
@@ -26,9 +24,6 @@ import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
|
||||
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 {
|
||||
name: string;
|
||||
@@ -63,7 +58,6 @@ const EMPTY_SOC: SocFormState = {
|
||||
};
|
||||
|
||||
export function SimulationFormPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { eid, sid } = useParams<{ eid: string; sid: string }>();
|
||||
const engagementId = eid ? Number(eid) : undefined;
|
||||
const simulationId = sid ? Number(sid) : undefined;
|
||||
@@ -83,7 +77,9 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, {
|
||||
enabled: !isNew && canEditRT,
|
||||
});
|
||||
const c2TaskCount = c2TasksQuery.data?.tasks?.length ?? 0;
|
||||
const hasTasks = (c2TasksQuery.data?.tasks?.length ?? 0) > 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 createMutation = useCreateSimulation(engagementId ?? 0);
|
||||
@@ -99,26 +95,6 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const [showC2Modal, setShowC2Modal] = 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(() => {
|
||||
if (!isNew && detail.data) {
|
||||
const s = detail.data;
|
||||
@@ -139,11 +115,11 @@ export function SimulationFormPage(): JSX.Element {
|
||||
}
|
||||
}, [isNew, detail.data]);
|
||||
|
||||
if (!isNew && detail.isLoading) return <LoadingState label={t('simulation.form.loading')} />;
|
||||
if (!isNew && detail.isLoading) return <LoadingState label="Loading simulation…" />;
|
||||
if (!isNew && detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, t('simulation.form.error.load'))}
|
||||
message={extractApiError(detail.error, 'Could not load simulation')}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -152,8 +128,12 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const simulation = detail.data;
|
||||
const status = simulation?.status;
|
||||
|
||||
// US-18: Done = fully read-only, Reopen only
|
||||
const isDone = status === 'done';
|
||||
|
||||
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
||||
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
||||
|
||||
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
|
||||
const rtDisabled = !canEditRT || isDone;
|
||||
const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
|
||||
@@ -164,22 +144,17 @@ export function SimulationFormPage(): JSX.Element {
|
||||
!isDone && (canEditEngagements || isSoc) && status === 'review_required';
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setNameError(null);
|
||||
setSubmitError(null);
|
||||
if (!rt.name.trim()) { setNameError(t('simulation.form.validation.nameRequired')); return; }
|
||||
if (!rt.name.trim()) { setNameError('Name is required'); return; }
|
||||
try {
|
||||
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
|
||||
push(t('simulation.form.toast.created'), 'success');
|
||||
push('Simulation created', 'success');
|
||||
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, t('simulation.form.error.create')));
|
||||
setSubmitError(extractApiError(err, 'Could not create simulation'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,7 +162,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
e.preventDefault();
|
||||
setNameError(null);
|
||||
setSubmitError(null);
|
||||
if (!rt.name.trim()) { setNameError(t('simulation.form.validation.nameRequired')); return; }
|
||||
if (!rt.name.trim()) { setNameError('Name is required'); return; }
|
||||
const patch: SimulationPatchInput = {
|
||||
name: rt.name.trim(),
|
||||
description: rt.description.trim() || null,
|
||||
@@ -198,9 +173,9 @@ export function SimulationFormPage(): JSX.Element {
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync(patch);
|
||||
push(t('simulation.form.toast.updated'), 'success');
|
||||
push('Simulation updated', 'success');
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, t('simulation.form.error.update')));
|
||||
setSubmitError(extractApiError(err, 'Could not update simulation'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -215,36 +190,36 @@ export function SimulationFormPage(): JSX.Element {
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync(patch);
|
||||
push(t('simulation.form.toast.socUpdated'), 'success');
|
||||
push('SOC report updated', 'success');
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, t('simulation.form.error.soc')));
|
||||
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
|
||||
}
|
||||
};
|
||||
|
||||
const onMarkReview = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('review_required');
|
||||
push(t('simulation.form.toast.markedReview'), 'success');
|
||||
push('Simulation marked for review', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('simulation.form.error.transition')), 'error');
|
||||
push(extractApiError(err, 'Transition failed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('done');
|
||||
push(t('simulation.form.toast.closed'), 'success');
|
||||
push('Simulation closed', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('simulation.form.error.transition')), 'error');
|
||||
push(extractApiError(err, 'Transition failed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onReopen = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('review_required');
|
||||
push(t('simulation.form.toast.reopened'), 'success');
|
||||
push('Simulation reopened', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('simulation.form.error.transition')), 'error');
|
||||
push(extractApiError(err, 'Transition failed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,45 +227,25 @@ export function SimulationFormPage(): JSX.Element {
|
||||
setShowDeleteConfirm(false);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(simulationId as number);
|
||||
push(t('simulation.form.toast.deleted'), 'success');
|
||||
push('Simulation deleted', 'success');
|
||||
navigate(`/engagements/${engagementId}`);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('simulation.form.error.delete')), 'error');
|
||||
push(extractApiError(err, 'Could not delete simulation'), '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
|
||||
if (isNew) {
|
||||
const creating = createMutation.isPending;
|
||||
const submitting = createMutation.isPending;
|
||||
return (
|
||||
<div className="flex flex-col gap-xl max-w-2xl">
|
||||
<header>
|
||||
<BackLink to={`/engagements/${engagementId}`}>{t('engagement.detail.backTo')}</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none mt-sm">{t('simulation.form.title.new')}</h1>
|
||||
<BackLink to={`/engagements/${engagementId}`}>Back to engagement</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1>
|
||||
</header>
|
||||
|
||||
<Tabs items={tabItems} activeId="red-team" onChange={() => {}} />
|
||||
|
||||
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
|
||||
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
|
||||
<TextInput
|
||||
id="sim-name"
|
||||
name="name"
|
||||
@@ -300,47 +255,38 @@ export function SimulationFormPage(): JSX.Element {
|
||||
/>
|
||||
</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 ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||
) : null}
|
||||
</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 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const submitting =
|
||||
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<BackLink to={`/engagements/${engagementId}`}>{t('engagement.detail.backTo')}</BackLink>
|
||||
<BackLink to={`/engagements/${engagementId}`}>Back to engagement</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
|
||||
{status ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<SimulationStatusBadge status={status} />
|
||||
{simulation?.created_by && (
|
||||
<span className="text-[14px] text-graphite">
|
||||
{t('engagement.detail.createdBy')}{' '}
|
||||
<span className="text-ink">{simulation.created_by.username}</span>
|
||||
Created by <span className="text-ink">{simulation.created_by.username}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -348,264 +294,260 @@ export function SimulationFormPage(): JSX.Element {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Done banner — above tabs, global status info */}
|
||||
{/* Done banner */}
|
||||
{isDone && (
|
||||
<AlertBanner variant="success">
|
||||
{t('simulation.form.banner.done')}
|
||||
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
|
||||
</AlertBanner>
|
||||
)}
|
||||
|
||||
<Tabs items={tabItems} activeId={safeTab} onChange={setActiveTab} />
|
||||
{/* SOC banner */}
|
||||
{socBlocked && (
|
||||
<div data-testid="soc-blocked-banner">
|
||||
<AlertBanner variant="warn">
|
||||
Simulation not yet ready for review — the red team must mark it as "Review required" before you can fill in the SOC section.
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`tabpanel-${safeTab}`}
|
||||
aria-labelledby={`tab-${safeTab}`}
|
||||
>
|
||||
{/* ── Red Team tab ──────────────────────────────────────── */}
|
||||
{safeTab === 'red-team' && (
|
||||
<form
|
||||
id="rt-form"
|
||||
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
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>
|
||||
{/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */}
|
||||
<div className="grid gap-xl lg:grid-cols-2 items-start">
|
||||
{/* Left column: RT card + C2 tasks panel */}
|
||||
<div className="flex flex-col gap-xl">
|
||||
{/* Red Team card */}
|
||||
<form
|
||||
id="rt-form"
|
||||
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<h2 className="text-[20px] font-medium text-ink">Red Team</h2>
|
||||
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
|
||||
<MitreTechniquesField
|
||||
value={simulation?.techniques ?? []}
|
||||
tactics={simulation?.tactics ?? []}
|
||||
simulationId={simulationId as number}
|
||||
engagementId={engagementId as number}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
<FormField label="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>
|
||||
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques & 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>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<FormField label={t('simulation.form.field.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={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 && (
|
||||
{/* C2 tasks panel — under RT card, same left column */}
|
||||
{showTasksPanel && 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>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||
) : null}
|
||||
|
||||
{/* Contextual sticky bar — not rendered at all for C2 tab */}
|
||||
{safeTab !== 'c2' && (
|
||||
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||
{safeTab === 'red-team' && (
|
||||
<>
|
||||
{!isDone && canEditRT && (
|
||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.save')}
|
||||
</button>
|
||||
)}
|
||||
{showMarkReview && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Unified sticky action bar */}
|
||||
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||
{/* Done state: Reopen only */}
|
||||
{showReopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onReopen}
|
||||
disabled={transitionMutation.isPending}
|
||||
data-testid="reopen-btn"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden />
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{safeTab === 'soc' && (
|
||||
<>
|
||||
{!isDone && canSaveSoc && (
|
||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.saveSoc')}
|
||||
</button>
|
||||
)}
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onClose}
|
||||
disabled={transitionMutation.isPending}
|
||||
data-testid="close-btn"
|
||||
>
|
||||
{t('simulation.form.btn.close')}
|
||||
</button>
|
||||
)}
|
||||
{showReopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onReopen}
|
||||
disabled={transitionMutation.isPending}
|
||||
data-testid="reopen-btn"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden />
|
||||
{t('simulation.form.btn.reopen')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Normal state buttons */}
|
||||
{!isDone && canEditRT && (
|
||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{!isDone && canSaveSoc && (
|
||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
||||
</button>
|
||||
)}
|
||||
{showMarkReview && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onMarkReview}
|
||||
disabled={transitionMutation.isPending}
|
||||
>
|
||||
Mark for review
|
||||
</button>
|
||||
)}
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onClose}
|
||||
disabled={transitionMutation.isPending}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
{!isDone && canEditEngagements && simulationId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep ml-auto"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title={t('simulation.form.deleteConfirm.title')}
|
||||
description={t('simulation.form.deleteConfirm.desc')}
|
||||
confirmLabel={t('simulation.form.deleteConfirm.confirm')}
|
||||
cancelLabel={t('simulation.form.deleteConfirm.cancel')}
|
||||
title="Delete simulation"
|
||||
description="This action is permanent. The simulation will be deleted forever."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Save, Grid2x2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
@@ -26,7 +25,6 @@ interface FormState {
|
||||
const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' };
|
||||
|
||||
export function TemplateFormPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const templateId = id ? Number(id) : undefined;
|
||||
const isNew = !templateId;
|
||||
@@ -49,15 +47,15 @@ export function TemplateFormPage(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (existing.data) {
|
||||
const tpl = existing.data;
|
||||
const t = existing.data;
|
||||
setForm({
|
||||
name: tpl.name,
|
||||
description: tpl.description ?? '',
|
||||
commands: tpl.commands ?? '',
|
||||
prerequisites: tpl.prerequisites ?? '',
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
commands: t.commands ?? '',
|
||||
prerequisites: t.prerequisites ?? '',
|
||||
});
|
||||
setTechniques(tpl.techniques);
|
||||
setTactics(tpl.tactics);
|
||||
setTechniques(t.techniques);
|
||||
setTactics(t.tactics);
|
||||
}
|
||||
}, [existing.data]);
|
||||
|
||||
@@ -67,7 +65,7 @@ export function TemplateFormPage(): JSX.Element {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (!form.name.trim()) {
|
||||
setFormError(t('template.form.validation.nameRequired'));
|
||||
setFormError('Name is required');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
@@ -75,20 +73,20 @@ export function TemplateFormPage(): JSX.Element {
|
||||
description: form.description.trim() || null,
|
||||
commands: form.commands.trim() || null,
|
||||
prerequisites: form.prerequisites.trim() || null,
|
||||
technique_ids: techniques.map((tech) => tech.id),
|
||||
tactic_ids: tactics.map((tac) => tac.id),
|
||||
technique_ids: techniques.map((t) => t.id),
|
||||
tactic_ids: tactics.map((t) => t.id),
|
||||
};
|
||||
try {
|
||||
if (isNew) {
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
push(t('template.form.toast.created'), 'success');
|
||||
push('Template created', 'success');
|
||||
navigate(`/admin/templates/${created.id}/edit`, { replace: true });
|
||||
} else {
|
||||
await updateMutation.mutateAsync(payload);
|
||||
push(t('template.form.toast.saved'), 'success');
|
||||
push('Template saved', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(extractApiError(err, t('template.form.errorSave')));
|
||||
setFormError(extractApiError(err, 'Could not save template'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,10 +94,10 @@ export function TemplateFormPage(): JSX.Element {
|
||||
if (!templateId) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(templateId);
|
||||
push(t('template.form.toast.deleted'), 'success');
|
||||
push('Template deleted', 'success');
|
||||
navigate('/admin/templates', { replace: true });
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('template.form.errorDelete')), 'error');
|
||||
push(extractApiError(err, 'Could not delete template'), 'error');
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
@@ -111,16 +109,16 @@ export function TemplateFormPage(): JSX.Element {
|
||||
};
|
||||
|
||||
const handlePickerSelect = (technique: MitreTechnique) => {
|
||||
if (techniques.some((tech) => tech.id === technique.id)) return;
|
||||
if (techniques.some((t) => t.id === technique.id)) return;
|
||||
setTechniques((prev) => [...prev, technique]);
|
||||
setShowPicker(false);
|
||||
};
|
||||
|
||||
if (!isNew && existing.isLoading) return <LoadingState label={t('template.form.loading')} />;
|
||||
if (!isNew && existing.isLoading) return <LoadingState label="Loading template…" />;
|
||||
if (!isNew && existing.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(existing.error, t('template.form.errorLoad'))}
|
||||
message={extractApiError(existing.error, 'Could not load template')}
|
||||
onRetry={() => existing.refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -130,9 +128,9 @@ export function TemplateFormPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<BackLink to="/admin/templates">{t('engagement.detail.backTo')}</BackLink>
|
||||
<BackLink to="/admin/templates">Back to templates</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none">
|
||||
{isNew ? t('template.form.title.new') : (existing.data?.name ?? t('template.form.title.edit'))}
|
||||
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}
|
||||
</h1>
|
||||
</div>
|
||||
{!isNew ? (
|
||||
@@ -142,13 +140,13 @@ export function TemplateFormPage(): JSX.Element {
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t('template.form.btn.delete')}
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} className="card-product flex flex-col gap-lg max-w-2xl">
|
||||
<FormField label={t('template.form.field.name')} htmlFor="tpl-name" required error={formError}>
|
||||
<FormField label="Name" htmlFor="tpl-name" required error={formError}>
|
||||
<TextInput
|
||||
id="tpl-name"
|
||||
value={form.name}
|
||||
@@ -157,56 +155,56 @@ export function TemplateFormPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('template.form.field.description')} htmlFor="tpl-desc">
|
||||
<FormField label="Description" htmlFor="tpl-desc">
|
||||
<TextArea
|
||||
id="tpl-desc"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
disabled={isPending}
|
||||
placeholder={t('template.form.field.descriptionPlaceholder')}
|
||||
placeholder="What does this simulation cover?"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('template.form.field.commands')} htmlFor="tpl-commands" hint={t('template.form.field.commandsHint')}>
|
||||
<FormField label="Commands" htmlFor="tpl-commands" hint="One command per line">
|
||||
<TextArea
|
||||
id="tpl-commands"
|
||||
value={form.commands}
|
||||
onChange={(e) => setForm({ ...form, commands: e.target.value })}
|
||||
disabled={isPending}
|
||||
placeholder={t('template.form.field.commandsPlaceholder')}
|
||||
placeholder="e.g. mimikatz.exe sekurlsa::logonpasswords"
|
||||
className="font-mono text-[14px]"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('template.form.field.prerequisites')} htmlFor="tpl-prereqs">
|
||||
<FormField label="Prerequisites" htmlFor="tpl-prereqs">
|
||||
<TextArea
|
||||
id="tpl-prereqs"
|
||||
value={form.prerequisites}
|
||||
onChange={(e) => setForm({ ...form, prerequisites: e.target.value })}
|
||||
disabled={isPending}
|
||||
placeholder={t('template.form.field.prerequisitesPlaceholder')}
|
||||
placeholder="e.g. Local admin access required"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col gap-sm">
|
||||
<span className="text-[14px] font-medium text-ink">{t('template.form.field.mitre')}</span>
|
||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques & Tactics</span>
|
||||
|
||||
{techniques.length === 0 && tactics.length === 0 ? (
|
||||
<p className="text-[13px] text-graphite">{t('template.form.field.mitreEmpty')}</p>
|
||||
<p className="text-[13px] text-graphite">No techniques selected</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
|
||||
{tactics.map((tac) => (
|
||||
{tactics.map((t) => (
|
||||
<MitreTacticTag
|
||||
key={tac.id}
|
||||
tactic={tac}
|
||||
onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== tac.id))}
|
||||
key={t.id}
|
||||
tactic={t}
|
||||
onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== t.id))}
|
||||
/>
|
||||
))}
|
||||
{techniques.map((tech) => (
|
||||
{techniques.map((t) => (
|
||||
<MitreTechniqueTag
|
||||
key={tech.id}
|
||||
technique={tech}
|
||||
onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== tech.id))}
|
||||
key={t.id}
|
||||
technique={t}
|
||||
onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== t.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -222,13 +220,13 @@ export function TemplateFormPage(): JSX.Element {
|
||||
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
|
||||
onClick={() => setShowPicker(true)}
|
||||
>
|
||||
{t('template.form.field.mitreSearch')}
|
||||
Search technique (e.g. T1059)…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('template.form.field.mitreOpenMatrix')}
|
||||
aria-label="Open MITRE matrix"
|
||||
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"
|
||||
>
|
||||
@@ -239,10 +237,10 @@ export function TemplateFormPage(): JSX.Element {
|
||||
|
||||
<div className="flex items-center gap-md pt-xs border-t border-hairline">
|
||||
<button type="submit" className="btn-primary" disabled={isPending}>
|
||||
<Save size={14} aria-hidden /> {isPending ? t('template.form.btn.saving') : t('template.form.btn.save')}
|
||||
<Save size={14} aria-hidden /> {isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<Link to="/admin/templates" className="btn-outline-ink">
|
||||
{t('template.form.btn.cancel')}
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
@@ -257,10 +255,9 @@ export function TemplateFormPage(): JSX.Element {
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
<ConfirmDialog
|
||||
title={t('template.form.deleteConfirm.title')}
|
||||
description={t('template.form.deleteConfirm.desc')}
|
||||
confirmLabel={t('template.form.deleteConfirm.confirm')}
|
||||
cancelLabel={t('template.form.deleteConfirm.cancel')}
|
||||
title="Delete template"
|
||||
description={`Delete "${existing.data?.name ?? 'this template'}"? This cannot be undone. Simulations already created from it are unaffected.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
destructive
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
import { useDeleteTemplate, useTemplates } from '@/hooks/useTemplates';
|
||||
@@ -8,25 +7,28 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
function mitreCount(t: SimulationTemplate): number {
|
||||
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 {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useTemplates();
|
||||
const deleteMutation = useDeleteTemplate();
|
||||
const { push } = useToast();
|
||||
|
||||
const onDelete = async (template: SimulationTemplate) => {
|
||||
if (!window.confirm(`${t('template.list.title')} « ${template.name} » ?`)) return;
|
||||
const onDelete = async (t: SimulationTemplate) => {
|
||||
if (!window.confirm(`Delete template "${t.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(template.id);
|
||||
push(t('template.form.toast.deleted'), 'success');
|
||||
await deleteMutation.mutateAsync(t.id);
|
||||
push('Template deleted', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('template.form.errorDelete')), 'error');
|
||||
push(extractApiError(err, 'Could not delete template'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,32 +36,32 @@ export function TemplatesListPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-end justify-between gap-md">
|
||||
<div>
|
||||
<h1 className="text-[32px] font-medium leading-none">{t('template.list.title')}</h1>
|
||||
<h1 className="text-[32px] font-medium leading-none">Templates</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
{t('template.list.subtitle')}
|
||||
Reusable simulation blueprints for red team operations.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/admin/templates/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> {t('template.list.new')}
|
||||
<Plus size={14} aria-hidden /> New
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{isLoading ? <LoadingState label={t('common.loading')} /> : null}
|
||||
{isLoading ? <LoadingState label="Loading templates…" /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(error, t('template.form.errorLoad'))}
|
||||
message={extractApiError(error, 'Could not load templates')}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('template.list.empty.title')}
|
||||
description={t('template.list.empty.desc')}
|
||||
title="No templates yet"
|
||||
description="Create your first template to speed up simulation setup."
|
||||
action={
|
||||
<Link to="/admin/templates/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> {t('template.list.new')}
|
||||
<Plus size={14} aria-hidden /> New template
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -70,41 +72,41 @@ export function TemplatesListPage(): JSX.Element {
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr>
|
||||
<th>{t('template.list.col.name')}</th>
|
||||
<th>{t('template.list.col.mitre')}</th>
|
||||
<th>{t('template.list.col.createdBy')}</th>
|
||||
<th>{t('template.list.col.updated')}</th>
|
||||
<th className="text-right">{t('engagement.list.col.actions')}</th>
|
||||
<th>Name</th>
|
||||
<th>MITRE</th>
|
||||
<th>Created by</th>
|
||||
<th>Updated</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((template) => (
|
||||
<tr key={template.id}>
|
||||
{data.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td>
|
||||
<Link
|
||||
to={`/admin/templates/${template.id}/edit`}
|
||||
to={`/admin/templates/${t.id}/edit`}
|
||||
className="text-ink font-medium hover:underline"
|
||||
>
|
||||
{template.name}
|
||||
{t.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="text-charcoal">
|
||||
{mitreCount(template) === 0 ? '—' : mitreCount(template)}
|
||||
{mitreCount(t) === 0 ? '—' : mitreCount(t)}
|
||||
</td>
|
||||
<td className="text-charcoal">{template.created_by.username}</td>
|
||||
<td className="text-charcoal font-mono">{formatDate(template.updated_at)}</td>
|
||||
<td className="text-charcoal">{t.created_by.username}</td>
|
||||
<td className="text-charcoal font-mono">{formatDate(t.updated_at)}</td>
|
||||
<td className="text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<Link to={`/admin/templates/${template.id}/edit`} className="btn-text-link">
|
||||
{t('template.list.edit')}
|
||||
<Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep"
|
||||
onClick={() => onDelete(template)}
|
||||
onClick={() => onDelete(t)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t('template.list.delete')}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Fragment, useState, type FormEvent } from 'react';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Role, User } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -16,6 +14,12 @@ import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
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 {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -25,7 +29,6 @@ interface CreateFormState {
|
||||
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
|
||||
|
||||
export function UsersAdminPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { user: currentUser } = useAuth();
|
||||
const { push } = useToast();
|
||||
const list = useUsersList();
|
||||
@@ -33,12 +36,6 @@ export function UsersAdminPage(): JSX.Element {
|
||||
const patchMutation = usePatchUser();
|
||||
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 [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
@@ -50,15 +47,15 @@ export function UsersAdminPage(): JSX.Element {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
if (createForm.password.length < 8) {
|
||||
setCreateError(t('user.admin.error.passwordMinLength'));
|
||||
setCreateError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(createForm);
|
||||
setCreateForm(EMPTY_CREATE);
|
||||
push(t('user.admin.toast.created'), 'success');
|
||||
push('User created', 'success');
|
||||
} catch (err) {
|
||||
setCreateError(extractApiError(err, t('user.admin.error.create')));
|
||||
setCreateError(extractApiError(err, 'Could not create user'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,53 +63,53 @@ export function UsersAdminPage(): JSX.Element {
|
||||
if (u.role === role) return;
|
||||
try {
|
||||
await patchMutation.mutateAsync({ id: u.id, input: { role } });
|
||||
push(t('user.admin.toast.roleUpdated', { username: u.username }), 'success');
|
||||
push(`Role updated for ${u.username}`, 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('user.admin.error.roleUpdate')), 'error');
|
||||
push(extractApiError(err, 'Could not update role'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onResetPassword = async (u: User, e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (resetPassword.length < 8) {
|
||||
push(t('user.admin.error.passwordMinLength'), 'error');
|
||||
push('Password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
|
||||
push(t('user.admin.toast.passwordReset', { username: u.username }), 'success');
|
||||
push(`Password reset for ${u.username}`, 'success');
|
||||
setResetOpen(null);
|
||||
setResetPassword('');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('user.admin.error.passwordReset')), 'error');
|
||||
push(extractApiError(err, 'Could not reset password'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (u: User) => {
|
||||
if (currentUser?.id === u.id) {
|
||||
push(t('user.admin.error.selfDelete'), 'error');
|
||||
push('You cannot delete your own account', 'error');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t('user.admin.deleteConfirm', { username: u.username }))) return;
|
||||
if (!window.confirm(`Delete user "${u.username}"?`)) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(u.id);
|
||||
push(t('user.admin.toast.deleted'), 'success');
|
||||
push('User deleted', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('user.admin.error.delete')), 'error');
|
||||
push(extractApiError(err, 'Could not delete user'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header>
|
||||
<h1 className="text-[32px] font-medium leading-none">{t('user.admin.title')}</h1>
|
||||
<h1 className="text-[32px] font-medium leading-none">User accounts</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
{t('user.admin.createSubtitle')}
|
||||
Manage local accounts. Admins can create new red team or SOC analysts.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="card-product flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">{t('user.admin.createSection')}</h2>
|
||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||
{/*
|
||||
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.
|
||||
@@ -124,13 +121,13 @@ export function UsersAdminPage(): JSX.Element {
|
||||
>
|
||||
{/* Row 1 — labels */}
|
||||
<label htmlFor="new-username" className="text-[14px] font-medium text-ink">
|
||||
{t('user.admin.field.username')} <span className="text-bloom-deep">*</span>
|
||||
Username <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<label htmlFor="new-password" className="text-[14px] font-medium text-ink">
|
||||
{t('user.admin.field.password')} <span className="text-bloom-deep">*</span>
|
||||
Password <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<label htmlFor="new-role" className="text-[14px] font-medium text-ink">
|
||||
{t('user.admin.col.role')} <span className="text-bloom-deep">*</span>
|
||||
Role <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<div aria-hidden="true" />
|
||||
|
||||
@@ -151,18 +148,17 @@ export function UsersAdminPage(): JSX.Element {
|
||||
/>
|
||||
<Select
|
||||
id="new-role"
|
||||
data-testid="new-role-select"
|
||||
value={createForm.role}
|
||||
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? t('common.creating') : t('user.admin.btn.create')}
|
||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
|
||||
{/* Row 3 — hints */}
|
||||
<div aria-hidden="true" />
|
||||
<span className="text-[12px] text-graphite">{t('user.admin.passwordHint')}</span>
|
||||
<span className="text-[12px] text-graphite">≥ 8 characters</span>
|
||||
<div aria-hidden="true" />
|
||||
<div aria-hidden="true" />
|
||||
</form>
|
||||
@@ -174,19 +170,19 @@ export function UsersAdminPage(): JSX.Element {
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">{t('user.admin.allSection')}</h2>
|
||||
<h2 className="text-[20px] font-medium">All accounts</h2>
|
||||
|
||||
{list.isLoading ? <LoadingState label={t('state.loading')} /> : null}
|
||||
{list.isLoading ? <LoadingState label="Loading users…" /> : null}
|
||||
{list.isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(list.error, t('user.admin.error.create'))}
|
||||
message={extractApiError(list.error, 'Could not load users')}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('user.admin.empty.title')}
|
||||
description={t('user.admin.empty.desc')}
|
||||
title="No users yet"
|
||||
description="Create the first account using the form above."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -195,10 +191,10 @@ export function UsersAdminPage(): JSX.Element {
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr>
|
||||
<th>{t('user.admin.col.username')}</th>
|
||||
<th>{t('user.admin.col.role')}</th>
|
||||
<th>{t('user.admin.col.createdAt')}</th>
|
||||
<th className="text-right">{t('user.admin.col.actions')}</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -213,7 +209,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
<span className="font-mono font-medium">{u.username}</span>
|
||||
{isSelf ? (
|
||||
<span className="ml-sm font-sans text-[12px] text-graphite">
|
||||
{t('user.admin.you')}
|
||||
(you)
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
@@ -222,11 +218,11 @@ export function UsersAdminPage(): JSX.Element {
|
||||
value={u.role}
|
||||
onChange={(e) => onRoleChange(u, e.target.value as Role)}
|
||||
options={ROLE_OPTIONS}
|
||||
aria-label={`${t('user.admin.col.role')} ${u.username}`}
|
||||
aria-label={`Change role for ${u.username}`}
|
||||
disabled={patchMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-charcoal font-mono">{formatDate(u.created_at)}</td>
|
||||
<td className="text-charcoal font-mono">{u.created_at}</td>
|
||||
<td className="text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<button
|
||||
@@ -237,7 +233,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
setResetPassword('');
|
||||
}}
|
||||
>
|
||||
{t('user.admin.resetPassword')}
|
||||
Reset password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -245,7 +241,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
disabled={isSelf || deleteMutation.isPending}
|
||||
onClick={() => onDelete(u)}
|
||||
>
|
||||
{t('user.admin.btn.delete')}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -259,9 +255,9 @@ export function UsersAdminPage(): JSX.Element {
|
||||
className="flex items-end gap-md"
|
||||
>
|
||||
<FormField
|
||||
label={t('user.admin.newPasswordFor', { username: u.username })}
|
||||
label={`New password for ${u.username}`}
|
||||
htmlFor={`reset-${u.id}`}
|
||||
hint={t('user.admin.passwordHint')}
|
||||
hint="≥ 8 characters"
|
||||
>
|
||||
<TextInput
|
||||
id={`reset-${u.id}`}
|
||||
@@ -273,7 +269,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
<button type="submit" className="btn-primary">
|
||||
{t('user.admin.btn.setPassword')}
|
||||
Save password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -283,7 +279,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
setResetPassword('');
|
||||
}}
|
||||
>
|
||||
{t('user.admin.btn.cancel')}
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -162,10 +162,6 @@
|
||||
.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 {
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('EngagementFormPage — C2 config card visibility', () => {
|
||||
routerProps: { initialEntries: ['/engagements/5/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('btn-submit')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-config-card')).toBeNull();
|
||||
});
|
||||
@@ -101,7 +101,7 @@ describe('EngagementFormPage — C2 config card visibility', () => {
|
||||
routerProps: { initialEntries: ['/engagements/new'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('btn-submit')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /create engagement/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-config-card')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('ExportEngagementButton', () => {
|
||||
renderWithProviders(<ExportEngagementButton engagementId={1} />);
|
||||
expect(screen.getByTestId('export-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('export-dropdown-toggle')).toBeInTheDocument();
|
||||
expect(screen.getByText('Exporter')).toBeInTheDocument();
|
||||
expect(screen.getByText('Export')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking primary opens dropdown with three formats', async () => {
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('MitreMatrixModal', () => {
|
||||
);
|
||||
await user.click(t1078Btn!);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Appliquer/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Apply/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -148,7 +148,7 @@ describe('MitreMatrixModal', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Fermer/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
@@ -202,7 +202,7 @@ describe('MitreMatrixModal', () => {
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Filtrer/i);
|
||||
const searchInput = screen.getByPlaceholderText(/Filter techniques/i);
|
||||
await user.type(searchInput, 'T1059');
|
||||
|
||||
expect(screen.queryByText('T1078')).toBeNull();
|
||||
@@ -224,7 +224,7 @@ describe('MitreMatrixModal', () => {
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
expect(screen.queryByText(/Default Accounts/)).toBeNull();
|
||||
|
||||
const expandBtn = screen.getByRole('button', { name: /Déplier T1078/i });
|
||||
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i });
|
||||
await user.click(expandBtn);
|
||||
|
||||
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
|
||||
@@ -242,7 +242,7 @@ describe('MitreMatrixModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Appliquer \(1 élément\)/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Apply 1 item/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,11 +259,11 @@ describe('MitreMatrixModal', () => {
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
const applyBtn = screen.getByRole('button', { name: /Tout effacer/i });
|
||||
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
||||
expect(applyBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Apply button shows "Tout effacer" and is enabled when initial selection is deselected', async () => {
|
||||
it('Apply button shows "Clear all" and is enabled when initial selection is deselected', async () => {
|
||||
const onApply = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('MitreMatrixModal', () => {
|
||||
);
|
||||
await user.click(t1078Btn!);
|
||||
|
||||
const applyBtn = screen.getByRole('button', { name: /Tout effacer/i });
|
||||
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
||||
expect(applyBtn).not.toBeDisabled();
|
||||
await user.click(applyBtn);
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('MitreTechniquePicker', () => {
|
||||
vi.useRealTimers();
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/Rechercher une technique/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/Search by ID or name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('MitreTechniqueTag', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /Retirer T1059/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Remove T1059/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking × calls onRemove', async () => {
|
||||
@@ -32,7 +32,7 @@ describe('MitreTechniqueTag', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniqueTag technique={TECHNIQUE} onRemove={onRemove} />,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Retirer T1059/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Remove T1059/i }));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('MitreTechniqueTag', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} disabled />,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Retirer/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('MitreTacticTag', () => {
|
||||
renderWithProviders(
|
||||
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /Retirer TA0007/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Remove TA0007/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking × calls onRemove', async () => {
|
||||
@@ -66,7 +66,7 @@ describe('MitreTacticTag', () => {
|
||||
renderWithProviders(
|
||||
<MitreTacticTag tactic={TACTIC} onRemove={onRemove} />,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Retirer TA0007/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Remove TA0007/i }));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -74,6 +74,6 @@ describe('MitreTacticTag', () => {
|
||||
renderWithProviders(
|
||||
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} disabled />,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Retirer/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('MitreTechniquesField', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
expect(screen.getByText(/Aucune technique associée/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/No techniques selected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders technique tags for each technique', () => {
|
||||
@@ -72,16 +72,16 @@ describe('MitreTechniquesField', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /Ouvrir la matrice/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Open MITRE matrix/i })).toBeInTheDocument();
|
||||
// The search placeholder button
|
||||
expect(screen.getByRole('button', { name: /Rechercher une technique/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Search technique/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides input row when disabled', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} disabled />,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Ouvrir la matrice/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Open MITRE matrix/i })).toBeNull();
|
||||
});
|
||||
|
||||
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} />,
|
||||
);
|
||||
|
||||
const removeBtn = screen.getByRole('button', { name: /Retirer T1059/i });
|
||||
const removeBtn = screen.getByRole('button', { name: /Remove T1059/i });
|
||||
await user.click(removeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -115,7 +115,7 @@ describe('MitreTechniquesField', () => {
|
||||
<MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
|
||||
const removeBtn = screen.getByRole('button', { name: /Retirer TA0007/i });
|
||||
const removeBtn = screen.getByRole('button', { name: /Remove TA0007/i });
|
||||
await user.click(removeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -131,7 +131,7 @@ describe('MitreTechniquesField', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Rechercher une technique/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Search technique/i }));
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('MitreTechniquesField', () => {
|
||||
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Rechercher une technique/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Search technique/i }));
|
||||
const combobox = screen.getByRole('combobox');
|
||||
|
||||
await user.type(combobox, 'T1059');
|
||||
@@ -161,7 +161,7 @@ describe('MitreTechniquesField', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Ouvrir la matrice/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Open MITRE matrix/i }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
// Reset hash between tests so useHashTab doesn't bleed state
|
||||
afterEach(() => { history.replaceState(null, '', '/'); });
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
@@ -45,6 +43,7 @@ vi.mock('@/hooks/useAuth', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Wrap the page in a Route so useParams gets eid and sid
|
||||
function EditPage() {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -69,7 +68,6 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
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(() => {
|
||||
@@ -90,12 +88,12 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/^Description/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/^Commandes/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/^Exécuté le/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/Description/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/Commands/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows "Mark for review" button when status is pending', async () => {
|
||||
@@ -104,7 +102,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mark-review-btn')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,8 +111,8 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => screen.getByTestId('mark-review-btn'));
|
||||
expect(screen.queryByTestId('close-btn')).toBeNull();
|
||||
await waitFor(() => screen.getByRole('button', { name: /Mark for review/i }));
|
||||
expect(screen.queryByRole('button', { name: /^Close$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Mark for review" for in_progress status', async () => {
|
||||
@@ -124,22 +122,18 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mark-review-btn')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Close" button on SOC tab when status is review_required', async () => {
|
||||
it('shows "Close" button when status is review_required', async () => {
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||
renderWithProviders(<EditPage />, {
|
||||
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(() => {
|
||||
expect(screen.getByTestId('close-btn')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,173 +143,46 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-btn')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Delete$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationFormPage — tabs', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'redteam';
|
||||
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('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 />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const socTab = screen.getByRole('tab', { name: /SOC/i });
|
||||
expect(socTab).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('SOC tab is enabled when status is done', async () => {
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
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'] },
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
describe('SimulationFormPage — SOC role + pending (blocked)', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'soc';
|
||||
mock = new MockAdapter(apiClient);
|
||||
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('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('SOC tab is disabled for SOC role when status is pending', async () => {
|
||||
it('shows the SOC blocked banner', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).toBeDisabled());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('SOC inputs are disabled when status is pending', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Log source/i)).toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/Incident number/i)).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Red Team inputs are disabled for SOC', async () => {
|
||||
@@ -324,10 +191,10 @@ describe('SimulationFormPage — SOC role + pending (tab disabled)', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Nom/i)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/^Name/i)).toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/^Description/i)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/Description/i)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,7 +206,6 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -351,14 +217,11 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
||||
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(() => {
|
||||
expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled();
|
||||
});
|
||||
expect(screen.getByLabelText(/^Numéro d'incident/i)).not.toBeDisabled();
|
||||
|
||||
expect(screen.getByLabelText(/Incident number/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('Red Team inputs remain disabled for SOC even when review_required', async () => {
|
||||
@@ -367,21 +230,29 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Nom/i)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/^Name/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 () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
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(() => {
|
||||
expect(screen.getByTestId('close-btn')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -402,17 +273,8 @@ describe('SimulationFormPage — new simulation', () => {
|
||||
renderWithProviders(<NewPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
|
||||
});
|
||||
expect(screen.getByLabelText(/^Nom/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();
|
||||
expect(screen.getByLabelText(/^Name/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -435,7 +297,6 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||
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'] },
|
||||
});
|
||||
@@ -446,18 +307,46 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||
|
||||
it('hides Execute via C2 button when no c2 config (404)', 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'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Execute via C2 button when simulation is done', async () => {
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
|
||||
mock.onGet('/engagements/42/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: true,
|
||||
});
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
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',
|
||||
@@ -467,19 +356,69 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
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(() => {
|
||||
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
|
||||
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 () => {
|
||||
mock.onGet('/engagements/42/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
|
||||
@@ -4,10 +4,10 @@ import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
|
||||
import type { SimulationStatus } from '@/api/types';
|
||||
|
||||
const CASES: { status: SimulationStatus; label: string }[] = [
|
||||
{ status: 'pending', label: 'En attente' },
|
||||
{ status: 'in_progress', label: 'En cours' },
|
||||
{ status: 'review_required', label: 'Révision requise' },
|
||||
{ status: 'done', label: 'Terminé' },
|
||||
{ status: 'pending', label: 'Pending' },
|
||||
{ status: 'in_progress', label: 'In progress' },
|
||||
{ status: 'review_required', label: 'Review required' },
|
||||
{ status: 'done', label: 'Done' },
|
||||
];
|
||||
|
||||
describe('SimulationStatusBadge', () => {
|
||||
|
||||
@@ -2,18 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
planned: 'Planifié',
|
||||
active: 'Actif',
|
||||
closed: 'Clôturé',
|
||||
};
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it.each(['planned', 'active', 'closed'] as const)('renders %s label and data attr', (status) => {
|
||||
render(<StatusBadge status={status} />);
|
||||
const badge = screen.getByTestId('status-badge');
|
||||
expect(badge).toHaveAttribute('data-status', status);
|
||||
expect(badge.textContent).toBe(LABELS[status]);
|
||||
expect(badge.textContent?.toLowerCase()).toBe(status);
|
||||
});
|
||||
|
||||
it('applies warn-soft surface for planned', () => {
|
||||
|
||||
@@ -92,20 +92,22 @@ describe('TemplateFormPage — new mode', () => {
|
||||
|
||||
it('renders the form with name field in empty state', () => {
|
||||
renderNew();
|
||||
expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Description/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Commandes/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Prérequis/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Nom/i)).toHaveValue('');
|
||||
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Description/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Commands/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Prerequisites/i)).toBeInTheDocument();
|
||||
// All inputs should be empty
|
||||
expect(screen.getByLabelText(/Name/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('shows validation error when name is empty on submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderNew();
|
||||
const saveBtn = screen.getByRole('button', { name: /Enregistrer/i });
|
||||
// Name field is empty by default — click Save directly
|
||||
const saveBtn = screen.getByRole('button', { name: /Save/i });
|
||||
await user.click(saveBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Le nom est obligatoire')).toBeInTheDocument();
|
||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,8 +115,8 @@ describe('TemplateFormPage — new mode', () => {
|
||||
mock.onPost('/templates').reply(201, { ...TEMPLATE, id: 99 });
|
||||
const user = userEvent.setup();
|
||||
renderNew();
|
||||
await user.type(screen.getByLabelText(/^Nom/i), 'My Template');
|
||||
await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
|
||||
await user.type(screen.getByLabelText(/Name/i), 'My Template');
|
||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
||||
await waitFor(() => {
|
||||
expect(mock.history.post.length).toBe(1);
|
||||
});
|
||||
@@ -126,8 +128,8 @@ describe('TemplateFormPage — new mode', () => {
|
||||
mock.onPost('/templates').reply(409, { error: 'template name already exists' });
|
||||
const user = userEvent.setup();
|
||||
renderNew();
|
||||
await user.type(screen.getByLabelText(/^Nom/i), 'Duplicate');
|
||||
await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Duplicate');
|
||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('template name already exists')).toBeInTheDocument();
|
||||
});
|
||||
@@ -135,7 +137,7 @@ describe('TemplateFormPage — new mode', () => {
|
||||
|
||||
it('does not show Delete button in new mode', () => {
|
||||
renderNew();
|
||||
expect(screen.queryByText('Supprimer')).toBeNull();
|
||||
expect(screen.queryByText('Delete')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,7 +176,7 @@ describe('TemplateFormPage — edit mode', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Supprimer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits PATCH on save', async () => {
|
||||
@@ -185,7 +187,7 @@ describe('TemplateFormPage — edit mode', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
||||
await waitFor(() => {
|
||||
expect(mock.history.patch.length).toBe(1);
|
||||
});
|
||||
@@ -199,14 +201,14 @@ describe('TemplateFormPage — edit mode', () => {
|
||||
const user = userEvent.setup();
|
||||
renderEdit(5);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Supprimer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText('Supprimer'));
|
||||
await user.click(screen.getByText('Delete'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
// Click the Supprimer button inside the dialog
|
||||
const dialogDeleteBtn = screen.getAllByText('Supprimer').find(
|
||||
// Click the Delete button inside the dialog
|
||||
const dialogDeleteBtn = screen.getAllByText('Delete').find(
|
||||
(el) => el.tagName === 'BUTTON' && el.closest('[role="dialog"]')
|
||||
) as HTMLElement;
|
||||
await user.click(dialogDeleteBtn);
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('TemplatePickerModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Aucun template disponible/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/No templates available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists templates with name and MITRE count', async () => {
|
||||
@@ -128,9 +128,9 @@ describe('TemplatePickerModal', () => {
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Annuler')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText('Annuler'));
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('TemplatesListPage', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText(/Nouveau/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/New/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows Edit and Delete actions', async () => {
|
||||
@@ -109,8 +109,8 @@ describe('TemplatesListPage', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText('Modifier').length).toBe(2);
|
||||
expect(screen.getAllByText('Supprimer').length).toBe(2);
|
||||
expect(screen.getAllByText('Edit').length).toBe(2);
|
||||
expect(screen.getAllByText('Delete').length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls delete endpoint on confirm', async () => {
|
||||
@@ -124,10 +124,10 @@ describe('TemplatesListPage', () => {
|
||||
|
||||
renderWithProviders(<TemplatesListPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Supprimer')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButtons = screen.getAllByText('Supprimer');
|
||||
const deleteButtons = screen.getAllByText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('Toast', () => {
|
||||
expect(toast).toHaveTextContent('Session expirée');
|
||||
expect(toast).toHaveAttribute('data-kind', 'error');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /fermer/i }));
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('toast')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -56,15 +56,15 @@ describe('UsersAdminPage', () => {
|
||||
|
||||
await screen.findByText('alice');
|
||||
|
||||
await user.type(screen.getByLabelText(/^nom d'utilisateur/i), 'dan');
|
||||
await user.type(screen.getByLabelText(/^mot de passe/i), 'sup3rs4fe!');
|
||||
await user.type(screen.getByLabelText(/^username/i), 'dan');
|
||||
await user.type(screen.getByLabelText(/^password/i), 'sup3rs4fe!');
|
||||
// role default is 'redteam'; switch to 'soc' to match newUser
|
||||
await user.selectOptions(screen.getByTestId('new-role-select'), 'soc');
|
||||
await user.selectOptions(screen.getByLabelText(/^role/i), 'soc');
|
||||
|
||||
// After POST, hooks invalidate and the list refetches → return the new list
|
||||
mock.onGet('/users').reply(200, [...USERS, newUser]);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^créer$/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postSpy).toHaveBeenCalledWith({
|
||||
@@ -85,12 +85,12 @@ describe('UsersAdminPage', () => {
|
||||
// The "Reset password" button for bob lives in bob's row.
|
||||
const bobRow = screen.getByText('bob').closest('tr');
|
||||
expect(bobRow).not.toBeNull();
|
||||
await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /réinitialiser le mot de passe/i }));
|
||||
await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /reset password/i }));
|
||||
|
||||
// The reset form for bob (and bob only) must appear.
|
||||
expect(await screen.findByLabelText(/nouveau mot de passe pour bob/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/nouveau mot de passe pour carol/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/nouveau mot de passe pour alice/i)).toBeNull();
|
||||
expect(await screen.findByLabelText(/new password for bob/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/new password for carol/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/new password for alice/i)).toBeNull();
|
||||
});
|
||||
|
||||
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 bobRow = screen.getByText('bob').closest('tr') as HTMLElement;
|
||||
|
||||
expect(within(aliceRow).getByRole('button', { name: /supprimer/i })).toBeDisabled();
|
||||
expect(within(bobRow).getByRole('button', { name: /supprimer/i })).toBeEnabled();
|
||||
expect(within(aliceRow).getByRole('button', { name: /delete/i })).toBeDisabled();
|
||||
expect(within(bobRow).getByRole('button', { name: /delete/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('C2ConfigCard — with config (has_token=true)', () => {
|
||||
it('shows Replace token affordance when has_token=true', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remplacer le token')).toBeInTheDocument();
|
||||
expect(screen.getByText('Replace token')).toBeInTheDocument();
|
||||
});
|
||||
// Token input shows placeholder bullets (readOnly)
|
||||
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 () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remplacer le token')).toBeInTheDocument();
|
||||
expect(screen.getByText('Replace token')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('Remplacer le token'));
|
||||
fireEvent.click(screen.getByText('Replace token'));
|
||||
await waitFor(() => {
|
||||
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
|
||||
expect(tokenInput.readOnly).toBeFalsy();
|
||||
@@ -99,7 +99,7 @@ describe('C2ConfigCard — with config (has_token=true)', () => {
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('c2-test-btn'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connecté')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('C2ConfigCard — 503 disabled state', () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/fonctionnalités C2 sont désactivées/i),
|
||||
screen.getByText(/C2 features are disabled/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('c2-save-btn')).toBeDisabled();
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('C2TasksPanel — empty state', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Aucune tâche C2/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/No C2 tasks yet/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('c2-task-row')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('ExecuteViaC2Modal', () => {
|
||||
it('renders modal with title and callback table', async () => {
|
||||
renderModal();
|
||||
expect(screen.getByTestId('c2-modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Exécuter via C2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Execute via C2')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('ExecuteViaC2Modal', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Annuler/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('ImportC2HistoryModal — step 1: callback picker', () => {
|
||||
it('renders modal with title and callback rows', async () => {
|
||||
renderModal();
|
||||
expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument();
|
||||
expect(screen.getByText("Importer l'historique C2")).toBeInTheDocument();
|
||||
expect(screen.getByText('Import C2 history')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
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'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 tâche(s) importée(s)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Imported 1 task(s)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,7 +342,7 @@ describe('ImportC2HistoryModal — toast wording', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('0 importée(s), 1 déjà attachée(s)'),
|
||||
screen.getByText('Imported 0 task(s), 1 already attached'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -374,7 +374,7 @@ describe('ImportC2HistoryModal — Cancel button', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Annuler/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,44 +82,3 @@ describe('Tabs', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -22,13 +22,13 @@ describe('ErrorState', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="Boom" onRetry={onRetry} />);
|
||||
expect(screen.getByTestId('error-state')).toHaveTextContent('Boom');
|
||||
await userEvent.click(screen.getByRole('button', { name: /réessayer/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('omits retry button when no handler given', () => {
|
||||
render(<ErrorState message="Boom" />);
|
||||
expect(screen.queryByRole('button', { name: /réessayer/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import './src/i18n';
|
||||
|
||||
320
tasks/todo.md
320
tasks/todo.md
@@ -1,208 +1,164 @@
|
||||
# Sprint 13 — SimulationFormPage : layout en 3 tabs (Red Team / SOC / Tâche C2)
|
||||
# Sprint 11 — Spectrum UX port : 4 primitives + compact density global
|
||||
|
||||
**Base** : `sprint/12-i18n-fr` (PR #14 ouverte, pas encore mergée). Sprint 13 enchaîne directement.
|
||||
**Worktree** : `.claude/worktrees/sprint-13-sim-tabs/` (dédié — conforme à `feedback_worktree_per_sprint`).
|
||||
**Branch** : `sprint/13-sim-tabs`.
|
||||
**Scope** : frontend uniquement. Backend intouché. Aligné sur le pattern Tabs du sprint 11 (EngagementDetailPage 3→2 tabs) appliqué à SimulationFormPage.
|
||||
**Base** : `origin/main` (PR #12 mergée — sprint 8 + 9 + 10 en).
|
||||
**Branch** : `sprint/11-spectrum-ux`.
|
||||
**Scope** : frontend-only sprint, 5 livrables coordonnés. Pas de backend, pas de schéma, pas de nouvelle API. Pas de modif tokens couleurs/spacing/font dans tailwind.config.
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
Analyse Spectrum (`/home/user/Documents/01_Projects/spectrum`) faite via workflow. Résultat : Spectrum n'a **pas de design system** (Tailwind raw + violet ramp + recipes inlinées 30× partout). Mimic est structurellement meilleur. On ne porte pas la philosophie Spectrum — on extrait **5 patterns isolés** qui survivent au brutalisme Mimic.
|
||||
|
||||
## Constraints absolues
|
||||
|
||||
1. **Primary `#024ad8` (Electric Blue) reste**. Pas de violet, pas de spectrum ramp.
|
||||
2. **Brutalisme reste** : `rounded-none` sur containers (sauf status pills + tab count pills + avatars), zero `transition-*`, zero `shadow-*`, hairline 1px borders.
|
||||
3. **Pas de modif `tailwind.config.*`** sauf si une nouvelle classe utility est strictement nécessaire (justifier dans le PR).
|
||||
4. **DESIGN.md amendments additives uniquement** — pas de modif des tokens existants.
|
||||
5. **Mono uniquement pour data** (IDs MITRE, dates ISO, commands, etc.) — rule sprint 7.
|
||||
|
||||
## Décisions binding (lockées par user)
|
||||
|
||||
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.
|
||||
|
||||
## Décisions secondaires (à appliquer par défaut)
|
||||
|
||||
- **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é.
|
||||
|
||||
## Contraintes constantes
|
||||
|
||||
- Primary `#024ad8` Electric Blue intouchée.
|
||||
- Brutalisme intact : `rounded-none` (exceptions documentées), zéro `transition-*`, zéro `shadow-*`.
|
||||
- DESIGN.md amendments additives uniquement (sub-section "Sub-page tabs with disabled state" si besoin).
|
||||
- Tous les nouveaux labels passent par `i18n/fr.json` (cf. sprint 12) — pas de string EN hardcodée.
|
||||
- `pnpm build` MANDATORY avant push (leçon `feedback_css_apply_tokens`).
|
||||
- **Q1 → B** : Tabs primitive **+ consumer EngagementDetailPage** (3 tabs : Schedule / Description / Simulations au lieu du stack vertical actuel).
|
||||
- **Q2 → B** : **Compact density GLOBAL** — toutes les tables passent à 32px row, pas opt-in. User accepte le tradeoff WCAG (touch target) sur Engagements list.
|
||||
- **Q3 → A** : AlertBanner + refactor `SimulationFormPage` (Done banner + SOC-blocked banner).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Extend Tabs primitive : support `disabled` per item
|
||||
## Livrables
|
||||
|
||||
**File** : `frontend/src/components/Tabs.tsx`
|
||||
### Livrable 1 — `Tabs` primitive + `useHashTab` hook + consumer
|
||||
|
||||
Current `TabItem` :
|
||||
```tsx
|
||||
interface TabItem<T extends string> { id: T; label: string; count?: number; }
|
||||
```
|
||||
**Files** :
|
||||
- `frontend/src/hooks/useHashTab.ts` (NEW) — ~30 LoC. Reads `window.location.hash`, falls back to default ID, listens to `hashchange`, updates URL without reload. TypeScript pur, zéro style.
|
||||
- `frontend/src/components/Tabs.tsx` (NEW) — composant `<Tabs items={[{id, label, count?}]} activeId onChange />`. Underline variant.
|
||||
- `frontend/src/styles/index.css` — nouvelles recipes :
|
||||
- `.tab-underline` — `text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink` (instantané, pas de transition)
|
||||
- `.tab-underline-active` — `text-primary border-primary` (override)
|
||||
- `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`
|
||||
- `.tab-count-pill-active` — `bg-primary-soft text-primary` (override)
|
||||
- `frontend/src/pages/EngagementDetailPage.tsx` — refactor en 3 tabs :
|
||||
- Tab 1 "Schedule" (id `schedule`, default) — dates + statut + bouton "Edit engagement"
|
||||
- Tab 2 "Description" (id `description`) — texte description
|
||||
- Tab 3 "Simulations" (id `simulations`) — la liste actuelle de simulations
|
||||
- Count pills sur tab 3 = nombre de simulations
|
||||
- Tabs branchées sur `useHashTab('schedule')`
|
||||
- `DESIGN.md` — nouvelle subsection `### Navigation › Sub-page tabs` (additive)
|
||||
|
||||
Target :
|
||||
```tsx
|
||||
interface TabItem<T extends string> { id: T; label: string; count?: number; disabled?: boolean; }
|
||||
```
|
||||
**Brutalism check** :
|
||||
- Tab : `border-b-2` only, pas de bg, pas de rounded, pas de transition
|
||||
- Pill : `rounded-pill` autorisé (exception status pill DESIGN.md L125)
|
||||
- Hover : `hover:text-ink` instantané
|
||||
|
||||
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
|
||||
### Livrable 2 — `AlertBanner` component + 4 recipes
|
||||
|
||||
**New recipe** dans `frontend/src/styles/index.css` :
|
||||
```css
|
||||
.tab-underline-disabled {
|
||||
@apply text-graphite opacity-50 cursor-not-allowed;
|
||||
}
|
||||
```
|
||||
**Files** :
|
||||
- `frontend/src/components/AlertBanner.tsx` (NEW) — `<AlertBanner variant="error|warn|success|info" title? children />`. Brutalist : border-l-4 strip semantic, fill `bg-paper`, border `border-hairline` partout sauf le strip gauche, `rounded-none`. Icon Lucide à gauche (`AlertCircle` error, `AlertTriangle` warn, `CheckCircle` success, `Info` info) à `size={16}`.
|
||||
- `frontend/src/styles/index.css` — 4 nouvelles recipes :
|
||||
- `.alert-error` — `bg-paper border border-hairline border-l-4 border-l-bloom-deep px-md py-sm flex items-start gap-sm`
|
||||
- `.alert-warn` — same + `border-l-warn`
|
||||
- `.alert-success` — same + `border-l-success`
|
||||
- `.alert-info` — same + `border-l-primary`
|
||||
- `frontend/src/pages/SimulationFormPage.tsx` — refactor 2 banners hand-rollés (~L300 Done banner + ~L310 SOC-blocked banner) → `<AlertBanner variant="success">` / `<AlertBanner variant="warn">`.
|
||||
- `DESIGN.md` — extend `### Toast Notifications` avec subsection `### Inline Banners` (additive, mêmes 4 variants).
|
||||
|
||||
(Override le hover du `.tab-underline` parent ; pas de `border-primary` sur l'active state quand disabled — n'arrivera jamais puisque `aria-selected=false`.)
|
||||
**Note** : `bloom-deep`, `warn`, `success`, `primary` sont les tokens existants — pas de nouveau token couleur.
|
||||
|
||||
**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
|
||||
### Livrable 3 — `BackLink` component (dédup)
|
||||
|
||||
DESIGN.md : ajouter 1 ligne dans la sub-section `Sub-page tabs` documentant la variante `disabled`.
|
||||
**Files** :
|
||||
- `frontend/src/components/BackLink.tsx` (NEW) — `<BackLink to="/engagements">Back to engagements</BackLink>`. Renders `← {children}` (ArrowLeft Lucide size=14, gap-xxs). Style : `text-graphite hover:text-primary caption-md` instantané.
|
||||
- Refactor consumers (3 instances hand-rollées) :
|
||||
- `frontend/src/pages/EngagementDetailPage.tsx` (~L35)
|
||||
- `frontend/src/pages/SimulationFormPage.tsx` (back-link en edit mode)
|
||||
- `frontend/src/pages/TemplateFormPage.tsx` (back-link en edit mode)
|
||||
|
||||
### Livrable 4 — `.table-compact` recipe + apply GLOBAL
|
||||
|
||||
**Files** :
|
||||
- `frontend/src/styles/index.css` — nouvelle recipe (mais appliquée comme default, pas opt-in cette sprint per user decision Q2-B) :
|
||||
- Soit : modifier la recipe `.card-product table` existante pour passer à 32px row, `caption-md` text, `py-xxs px-xs` cells, divider `border-hairline`.
|
||||
- Soit : ajouter `.table-compact` puis l'appliquer à TOUTES les list pages (Engagements, Templates, Users, etc.).
|
||||
- **Choix builder** : pick the cleaner path. Préférer modifier le default si toutes les tables doivent suivre, sinon `.table-compact` + apply partout.
|
||||
- Apply sur :
|
||||
- `frontend/src/pages/EngagementsListPage.tsx`
|
||||
- `frontend/src/pages/TemplatesListPage.tsx`
|
||||
- `frontend/src/pages/UsersAdminPage.tsx`
|
||||
- `frontend/src/components/SimulationList.tsx` (utilisé dans EngagementDetailPage)
|
||||
- Toute autre table dans le code (grep `<table` dans `frontend/src/`)
|
||||
- `DESIGN.md` — `### Data Tables` section : modifier la ligne "Row min-height 44px" → "Row min-height 32px (compact density, all surfaces)". Documenter le tradeoff WCAG (touch target dérogé en faveur de la densité opérateur — c'est un BAS tool, pas un site grand public).
|
||||
|
||||
**ATTENTION** : WCAG SC 2.5.5 (Target Size: Minimum) recommande 44×44. Tu déroges. Garder les boutons d'action des rows ≥ 24px d'icône avec padding pour préserver le hit area minimal. Tester en clavier + lecteur d'écran nicht regressé.
|
||||
|
||||
### Livrable 5 — DESIGN.md amendments + CHANGELOG
|
||||
|
||||
**Files** :
|
||||
- `DESIGN.md` :
|
||||
- `### Navigation` : nouvelle subsection "Sub-page tabs" (livrable 1)
|
||||
- `### Toast Notifications` : nouvelle subsection "Inline Banners" (livrable 2)
|
||||
- `### Data Tables` : ligne row min-height passe à 32px + paragraphe rationale (livrable 4)
|
||||
- `CHANGELOG.md` : entrée Sprint 11 listant chaque primitive + ses tokens DESIGN.md.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — SimulationFormPage : refactor en 3 tabs
|
||||
## Sequencing
|
||||
|
||||
**File** : `frontend/src/pages/SimulationFormPage.tsx`
|
||||
1. **frontend-builder** : livrables 1 + 2 + 3 + 4 + 5 en cascade. Une seule passe. Commits atomiques :
|
||||
```
|
||||
feat(frontend): add Tabs primitive + useHashTab hook
|
||||
feat(frontend): refactor EngagementDetailPage to 3-tab layout
|
||||
feat(frontend): add AlertBanner component + 4 semantic variants
|
||||
refactor(frontend): migrate SimulationFormPage banners to AlertBanner
|
||||
feat(frontend): add BackLink helper + dedup 3 hand-rolled instances
|
||||
feat(frontend): compact table density (32px row) global
|
||||
docs(design): add tabs / inline banners / compact-table-density to DESIGN.md
|
||||
```
|
||||
(collapse OK si plus efficace ; ~5-7 commits attendus)
|
||||
2. **design-reviewer** : visual + token compliance check. Focus :
|
||||
- Underline tabs : pas de transition, pas de bg fill, border-b instantané
|
||||
- Count pill : `rounded-pill` est dans l'exception DESIGN.md, OK
|
||||
- AlertBanner : `border-l-4` semantic OK, pas de shadow
|
||||
- BackLink : caption + icon align baseline
|
||||
- 32px tables : densité lisible ? Hit area boutons d'action OK ?
|
||||
3. **code-reviewer** : sprint diff. Focus :
|
||||
- Aucune transition / shadow / rounded leak
|
||||
- useHashTab : `hashchange` listener cleanup dans useEffect return
|
||||
- Tabs : prop API stable, fallback default ID si hash invalide
|
||||
- AlertBanner : ARIA `role="alert"` ou `role="status"` selon variant
|
||||
- BackLink : `to` est `string` ou objet React Router ? Choisir un seul
|
||||
4. **spec-reviewer** : `SPEC.md` n'a pas vocation à mentionner ces primitives, **skip ce sprint** sauf si tu détectes une feature côté backend qui aurait dérivé.
|
||||
|
||||
**Layout cible (edit mode)** :
|
||||
```
|
||||
[Header : ← Retour à l'engagement | titre sim | status badge]
|
||||
[AlertBanner Done si done (au-dessus des tabs, status info global)]
|
||||
[Tabs : Red Team | SOC (disabled si !=review_required+) | Tâche C2 (count pill)]
|
||||
[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)
|
||||
]
|
||||
```
|
||||
## Contraintes tests
|
||||
|
||||
**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')` où `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,
|
||||
},
|
||||
];
|
||||
```
|
||||
- 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).
|
||||
|
||||
**Sticky bar contextuel** :
|
||||
- Wrapper `<div className="sticky bottom-0 …">` avec contenu conditionnel `{activeTab === 'red-team' && <RtActions />}{activeTab === 'soc' && <SocActions />}{activeTab === 'c2' && null}`.
|
||||
- Ne pas rendre le wrapper du tout si C2 actif (pas de sticky div vide).
|
||||
- Réutiliser les boutons existants (Save/Mark/Save SOC/Close/Reopen/Delete) — juste les regrouper.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**Tests** : `SimulationFormPage.test.tsx` — refactor important :
|
||||
- Les anciens tests "RT card visible AND SOC card visible simultaneously" → maintenant on test "RT tab content visible, SOC tab content via switch-tab".
|
||||
- Tests Mark/Close/Reopen : naviguer vers le bon tab avant d'asserter le bouton.
|
||||
- Tests `data-testid="soc-blocked-banner"` : ce testid disparaît — ajuster ou supprimer le test.
|
||||
- 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 3 — i18n keys nouvelles + DESIGN.md
|
||||
|
||||
**`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.)
|
||||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
## Counts attendus
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- `vitest --run` baseline **212/212** doit tenir.
|
||||
- **Ajouter 4 specs** (1 par new component) :
|
||||
- `Tabs.test.tsx` — active state, hash sync, count pill render
|
||||
- `AlertBanner.test.tsx` — 4 variants rendent les bonnes classes
|
||||
- `BackLink.test.tsx` — render + click navigation
|
||||
- `useHashTab.test.tsx` — hash read/write
|
||||
- `tsc --noEmit` + `eslint --max-warnings=0` clean.
|
||||
- Brutalism unit assertions : pour chaque new component, un test qui vérifie `expect(el).not.toHaveClass('rounded-md')` (sauf pill counter) + `.not.toHaveClass(/transition-/)` + `.not.toHaveClass(/shadow-/)`.
|
||||
|
||||
## 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)
|
||||
- ❌ Side nav / drawer / breadcrumb component — top nav + BackLink suffit
|
||||
- ❌ Global engagement selector top-bar (Spectrum pattern rejeté)
|
||||
- ❌ Editor dynamic-column grid — pas d'éditeur dans Mimic
|
||||
- ❌ Dark mode rework
|
||||
- ❌ Toast queue system (les toasts actuels via `useToast` restent)
|
||||
- ❌ PDF/document deliverable
|
||||
- ❌ Color palette changes
|
||||
- ❌ Tokens spacing/typo dans tailwind.config
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- ✅ 5 primitives livrées + EngagementDetailPage refactorée en tabs + SimulationFormPage banners migrés + BackLink dédup × 3
|
||||
- ✅ vitest **212+/212+** (4 new specs ajoutées), tsc + lint clean
|
||||
- ✅ pytest **469/469** intact (zéro backend touché)
|
||||
- ✅ Toutes tables passent à 32px row density (validation visuelle)
|
||||
- ✅ Design-reviewer APPROVED (brutalism check sur les 4 new recipes)
|
||||
- ✅ Code-reviewer APPROVED
|
||||
- ✅ DESIGN.md amendé (3 subsections additives)
|
||||
- ✅ CHANGELOG.md entrée sprint 11
|
||||
- ✅ PR ouverte sur Gitea avec body
|
||||
|
||||
Reference in New Issue
Block a user