Compare commits
3 Commits
main
...
sprint/13-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
578a7c3d1a | ||
|
|
0378792dc4 | ||
|
|
9b6b682acc |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed — Sprint 13 (SimulationFormPage 3-tab layout)
|
||||||
|
|
||||||
|
**Frontend only** (253 vitest passing)
|
||||||
|
|
||||||
|
- `frontend/src/components/Tabs.tsx` — Added `disabled?: boolean` per-item: `aria-disabled`, `aria-controls`, native `disabled`, `.tab-underline-disabled` CSS, arrow-key skip logic.
|
||||||
|
- `frontend/src/styles/index.css` — New `.tab-underline-disabled` recipe (`text-graphite opacity-50 cursor-not-allowed`).
|
||||||
|
- `frontend/src/hooks/useHashTab.ts` — Added optional `validIds?: string[]` for hash fallback to defaultId when hash not in valid set.
|
||||||
|
- `frontend/src/pages/SimulationFormPage.tsx` — Full 3-tab refactor: Red Team / SOC / Tâche C2. RT fields in RT tab, SOC fields in SOC tab, `C2TasksPanel` in C2 tab. SOC tab disabled when `status ∉ {review_required, done}`. C2 tab disabled in create mode. Contextual sticky bar hides entirely on C2 tab. SOC-blocked `AlertBanner` removed; tab disabled state replaces it. `safeTab` guards against stale hash from previous navigation.
|
||||||
|
- `frontend/src/i18n/fr.json` — Added `simulation.form.tab.{redTeam,soc,c2}` keys.
|
||||||
|
- `DESIGN.md` — Documented disabled tab variant and `aria-controls` contract.
|
||||||
|
- `frontend/tests/SimulationFormPage.test.tsx` — Rewritten for tab-based layout: SOC-blocked-banner tests removed; tab disabled-state tests, SOC-only field tests, C2 tab panel test, count pill test added.
|
||||||
|
- `frontend/tests/components/Tabs.test.tsx` — Added 5 disabled-state tests.
|
||||||
|
|
||||||
### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n)
|
### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n)
|
||||||
|
|
||||||
**Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests)
|
**Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests)
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ 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.
|
- **`.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.
|
- **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`.
|
- **Active count pill**: `.tab-count-pill-active` — `bg-primary-soft text-primary`.
|
||||||
- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` on each button.
|
- **Disabled variant**: `.tab-underline-disabled` — `text-steel cursor-not-allowed`. Uses the same `text-steel` token as `btn-outline:disabled` for system-wide consistency. Applied when `disabled?: boolean` is set on a `TabItem`. Overrides `.tab-underline` hover. ARIA: `aria-disabled="true"` + native `disabled` attribute. Arrow-key navigation skips disabled items.
|
||||||
|
- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` + `aria-controls="tabpanel-{id}"` on each button.
|
||||||
|
|
||||||
### Data Tables
|
### Data Tables
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface TabItem {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabsProps {
|
interface TabsProps {
|
||||||
@@ -13,13 +14,23 @@ interface TabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
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) {
|
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||||
if (e.key === 'ArrowRight') {
|
if (e.key === 'ArrowRight') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange(items[(index + 1) % items.length].id);
|
onChange(nextEnabled(index, 1));
|
||||||
} else if (e.key === 'ArrowLeft') {
|
} else if (e.key === 'ArrowLeft') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange(items[(index - 1 + items.length) % items.length].id);
|
onChange(nextEnabled(index, -1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +41,7 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
|||||||
>
|
>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const isActive = item.id === activeId;
|
const isActive = item.id === activeId;
|
||||||
|
const isDisabled = item.disabled ?? false;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -37,10 +49,12 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
aria-controls={`tabpanel-${item.id}`}
|
aria-controls={`tabpanel-${item.id}`}
|
||||||
|
aria-disabled={isDisabled || undefined}
|
||||||
|
disabled={isDisabled}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(item.id)}
|
onClick={() => { if (!isDisabled) onChange(item.id); }}
|
||||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||||
className={`tab-underline${isActive ? ' tab-underline-active' : ''} pb-xs`}
|
className={`tab-underline${isActive ? ' tab-underline-active' : ''}${isDisabled ? ' tab-underline-disabled' : ''} pb-xs`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.count !== undefined ? (
|
{item.count !== undefined ? (
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
function readHash(defaultId: string): string {
|
function readHash(defaultId: string, validIds?: string[]): string {
|
||||||
const hash = window.location.hash.slice(1); // strip leading '#'
|
const hash = window.location.hash.slice(1); // strip leading '#'
|
||||||
return hash || defaultId;
|
if (!hash) return defaultId;
|
||||||
|
if (validIds && !validIds.includes(hash)) return defaultId;
|
||||||
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHashTab(defaultId: string): [string, (id: string) => void] {
|
export function useHashTab(defaultId: string, validIds?: string[]): [string, (id: string) => void] {
|
||||||
const [activeId, setActiveId] = useState<string>(() => readHash(defaultId));
|
const [activeId, setActiveId] = useState<string>(() => readHash(defaultId, validIds));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onHashChange() {
|
function onHashChange() {
|
||||||
setActiveId(readHash(defaultId));
|
setActiveId(readHash(defaultId, validIds));
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', onHashChange);
|
window.addEventListener('hashchange', onHashChange);
|
||||||
return () => window.removeEventListener('hashchange', onHashChange);
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
}, [defaultId]);
|
}, [defaultId, validIds]);
|
||||||
|
|
||||||
const navigate = useCallback((id: string) => {
|
const navigate = useCallback((id: string) => {
|
||||||
// replaceState: no history entry (Back button unaffected), no anchor-jump scroll
|
// replaceState: no history entry (Back button unaffected), no anchor-jump scroll
|
||||||
|
|||||||
@@ -204,7 +204,12 @@
|
|||||||
"transition": "Transition échouée",
|
"transition": "Transition échouée",
|
||||||
"load": "Impossible de charger la simulation"
|
"load": "Impossible de charger la simulation"
|
||||||
},
|
},
|
||||||
"loading": "Chargement de la simulation…"
|
"loading": "Chargement de la simulation…",
|
||||||
|
"tab": {
|
||||||
|
"redTeam": "Red Team",
|
||||||
|
"soc": "SOC",
|
||||||
|
"c2": "Tâche C2"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"col": {
|
"col": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { extractApiError } from '@/api/client';
|
|||||||
import type { SimulationPatchInput } from '@/api/types';
|
import type { SimulationPatchInput } from '@/api/types';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useHashTab } from '@/hooks/useHashTab';
|
||||||
import {
|
import {
|
||||||
useCreateSimulation,
|
useCreateSimulation,
|
||||||
useDeleteSimulation,
|
useDeleteSimulation,
|
||||||
@@ -25,6 +26,9 @@ import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
|
|||||||
import { C2TasksPanel } from '@/components/C2TasksPanel';
|
import { C2TasksPanel } from '@/components/C2TasksPanel';
|
||||||
import { AlertBanner } from '@/components/AlertBanner';
|
import { AlertBanner } from '@/components/AlertBanner';
|
||||||
import { BackLink } from '@/components/BackLink';
|
import { BackLink } from '@/components/BackLink';
|
||||||
|
import { Tabs } from '@/components/Tabs';
|
||||||
|
|
||||||
|
type TabId = 'red-team' | 'soc' | 'c2';
|
||||||
|
|
||||||
interface RedteamFormState {
|
interface RedteamFormState {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -79,9 +83,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, {
|
const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, {
|
||||||
enabled: !isNew && canEditRT,
|
enabled: !isNew && canEditRT,
|
||||||
});
|
});
|
||||||
const hasTasks = (c2TasksQuery.data?.tasks?.length ?? 0) > 0;
|
const c2TaskCount = c2TasksQuery.data?.tasks?.length ?? 0;
|
||||||
// Show panel when: has C2 config (so Execute button is visible) OR already has tasks
|
|
||||||
const showTasksPanel = !isNew && canEditRT && (hasC2Config || hasTasks);
|
|
||||||
|
|
||||||
const detail = useSimulation(isNew ? undefined : simulationId);
|
const detail = useSimulation(isNew ? undefined : simulationId);
|
||||||
const createMutation = useCreateSimulation(engagementId ?? 0);
|
const createMutation = useCreateSimulation(engagementId ?? 0);
|
||||||
@@ -97,6 +99,26 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
const [showC2Modal, setShowC2Modal] = useState(false);
|
const [showC2Modal, setShowC2Modal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
|
// All hooks must be called unconditionally before any early returns.
|
||||||
|
const [activeTab, setActiveTab] = useHashTab('red-team');
|
||||||
|
|
||||||
|
// Compute disabled flags early using detail.data (may be undefined during load — that's fine).
|
||||||
|
const loadedStatus = detail.data?.status;
|
||||||
|
const earlySOCDisabled = isNew || (loadedStatus !== 'review_required' && loadedStatus !== 'done');
|
||||||
|
const earlyC2Disabled = isNew || isSoc;
|
||||||
|
const earlyHash = activeTab;
|
||||||
|
const earlySafeTab =
|
||||||
|
(earlyHash === 'soc' && earlySOCDisabled) || (earlyHash === 'c2' && earlyC2Disabled)
|
||||||
|
? 'red-team'
|
||||||
|
: earlyHash;
|
||||||
|
|
||||||
|
// Sync window.location.hash when the active hash points to a disabled tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (earlySafeTab !== activeTab) {
|
||||||
|
setActiveTab(earlySafeTab);
|
||||||
|
}
|
||||||
|
}, [earlySafeTab, activeTab, setActiveTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNew && detail.data) {
|
if (!isNew && detail.data) {
|
||||||
const s = detail.data;
|
const s = detail.data;
|
||||||
@@ -130,12 +152,8 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
const simulation = detail.data;
|
const simulation = detail.data;
|
||||||
const status = simulation?.status;
|
const status = simulation?.status;
|
||||||
|
|
||||||
// US-18: Done = fully read-only, Reopen only
|
|
||||||
const isDone = status === 'done';
|
const isDone = status === 'done';
|
||||||
|
|
||||||
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
||||||
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
|
||||||
|
|
||||||
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
|
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
|
||||||
const rtDisabled = !canEditRT || isDone;
|
const rtDisabled = !canEditRT || isDone;
|
||||||
const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
|
const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
|
||||||
@@ -146,6 +164,11 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
!isDone && (canEditEngagements || isSoc) && status === 'review_required';
|
!isDone && (canEditEngagements || isSoc) && status === 'review_required';
|
||||||
const showReopen = isDone && (isAdmin || isRedteam || isSoc);
|
const showReopen = isDone && (isAdmin || isRedteam || isSoc);
|
||||||
|
|
||||||
|
// Post-load versions (same logic, now with confirmed status)
|
||||||
|
const socTabDisabled = earlySOCDisabled;
|
||||||
|
const c2TabDisabled = earlyC2Disabled;
|
||||||
|
const safeTab = earlySafeTab;
|
||||||
|
|
||||||
const onSubmitNew = async (e: FormEvent) => {
|
const onSubmitNew = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setNameError(null);
|
setNameError(null);
|
||||||
@@ -236,9 +259,27 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitting =
|
||||||
|
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ id: 'red-team' as TabId, label: t('simulation.form.tab.redTeam') },
|
||||||
|
{
|
||||||
|
id: 'soc' as TabId,
|
||||||
|
label: t('simulation.form.tab.soc'),
|
||||||
|
disabled: socTabDisabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c2' as TabId,
|
||||||
|
label: t('simulation.form.tab.c2'),
|
||||||
|
count: !isNew && c2TaskCount > 0 ? c2TaskCount : undefined,
|
||||||
|
disabled: c2TabDisabled,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// New simulation form
|
// New simulation form
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const submitting = createMutation.isPending;
|
const creating = createMutation.isPending;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-xl max-w-2xl">
|
<div className="flex flex-col gap-xl max-w-2xl">
|
||||||
<header>
|
<header>
|
||||||
@@ -246,6 +287,8 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
<h1 className="text-[32px] font-medium leading-none mt-sm">{t('simulation.form.title.new')}</h1>
|
<h1 className="text-[32px] font-medium leading-none mt-sm">{t('simulation.form.title.new')}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<Tabs items={tabItems} activeId="red-team" onChange={() => {}} />
|
||||||
|
|
||||||
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
|
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
|
||||||
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
|
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -257,26 +300,34 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-xs">
|
||||||
|
<span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
|
||||||
|
<MitreTechniquesField
|
||||||
|
value={[]}
|
||||||
|
tactics={[]}
|
||||||
|
simulationId={0}
|
||||||
|
engagementId={engagementId ?? 0}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{submitError ? (
|
{submitError ? (
|
||||||
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-md pt-sm">
|
|
||||||
<button type="submit" className="btn-primary" disabled={submitting} data-testid="create-sim-btn">
|
|
||||||
{submitting ? 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>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||||
|
<button type="button" className="btn-primary" disabled={creating} data-testid="create-sim-btn" onClick={onSubmitNew}>
|
||||||
|
{creating ? t('simulation.form.btn.creating') : t('simulation.form.btn.create')}
|
||||||
|
</button>
|
||||||
|
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
|
||||||
|
{t('simulation.form.btn.cancel')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitting =
|
|
||||||
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-xl">
|
<div className="flex flex-col gap-xl">
|
||||||
<header className="flex items-start justify-between gap-md">
|
<header className="flex items-start justify-between gap-md">
|
||||||
@@ -297,256 +348,257 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Done banner */}
|
{/* Done banner — above tabs, global status info */}
|
||||||
{isDone && (
|
{isDone && (
|
||||||
<AlertBanner variant="success">
|
<AlertBanner variant="success">
|
||||||
{t('simulation.form.banner.done')}
|
{t('simulation.form.banner.done')}
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SOC banner */}
|
<Tabs items={tabItems} activeId={safeTab} onChange={setActiveTab} />
|
||||||
{socBlocked && (
|
|
||||||
<div data-testid="soc-blocked-banner">
|
|
||||||
<AlertBanner variant="warn">
|
|
||||||
{t('simulation.form.banner.socNotReady')}
|
|
||||||
</AlertBanner>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */}
|
<div
|
||||||
<div className="grid gap-xl lg:grid-cols-2 items-start">
|
role="tabpanel"
|
||||||
{/* Left column: RT card + C2 tasks panel */}
|
id={`tabpanel-${safeTab}`}
|
||||||
<div className="flex flex-col gap-xl">
|
aria-labelledby={`tab-${safeTab}`}
|
||||||
{/* Red Team card */}
|
>
|
||||||
<form
|
{/* ── Red Team tab ──────────────────────────────────────── */}
|
||||||
id="rt-form"
|
{safeTab === 'red-team' && (
|
||||||
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
|
<form
|
||||||
noValidate
|
id="rt-form"
|
||||||
className="card-product flex flex-col gap-md"
|
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
|
||||||
>
|
noValidate
|
||||||
<h2 className="text-[20px] font-medium text-ink">{t('simulation.form.header.redTeam')}</h2>
|
className="card-product flex flex-col gap-md"
|
||||||
|
>
|
||||||
|
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
|
||||||
|
<TextInput
|
||||||
|
id="sim-name"
|
||||||
|
name="name"
|
||||||
|
value={rt.name}
|
||||||
|
onChange={(e) => setRt({ ...rt, name: e.target.value })}
|
||||||
|
disabled={rtDisabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
|
<div className="flex flex-col gap-xs">
|
||||||
<TextInput
|
<span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
|
||||||
id="sim-name"
|
<MitreTechniquesField
|
||||||
name="name"
|
value={simulation?.techniques ?? []}
|
||||||
value={rt.name}
|
tactics={simulation?.tactics ?? []}
|
||||||
onChange={(e) => setRt({ ...rt, name: e.target.value })}
|
simulationId={simulationId as number}
|
||||||
disabled={rtDisabled}
|
engagementId={engagementId as number}
|
||||||
required
|
disabled={rtDisabled}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-xs">
|
|
||||||
<span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
|
|
||||||
<MitreTechniquesField
|
|
||||||
value={simulation?.techniques ?? []}
|
|
||||||
tactics={simulation?.tactics ?? []}
|
|
||||||
simulationId={simulationId as number}
|
|
||||||
engagementId={engagementId as number}
|
|
||||||
disabled={rtDisabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* C2 tasks panel — under RT card, same left column */}
|
<FormField label={t('simulation.form.field.description')} htmlFor="sim-description">
|
||||||
{showTasksPanel && simulationId && (
|
<TextArea
|
||||||
|
id="sim-description"
|
||||||
|
name="description"
|
||||||
|
value={rt.description}
|
||||||
|
onChange={(e) => setRt({ ...rt, description: e.target.value })}
|
||||||
|
disabled={rtDisabled}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={t('simulation.form.field.commands')} htmlFor="sim-commands" hint={t('simulation.form.field.commandsHint')}>
|
||||||
|
<TextArea
|
||||||
|
id="sim-commands"
|
||||||
|
name="commands"
|
||||||
|
value={rt.commands}
|
||||||
|
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
|
||||||
|
disabled={rtDisabled}
|
||||||
|
className="min-h-[160px] font-mono text-[14px]"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={t('simulation.form.field.prerequisites')} htmlFor="sim-prerequisites">
|
||||||
|
<TextArea
|
||||||
|
id="sim-prerequisites"
|
||||||
|
name="prerequisites"
|
||||||
|
value={rt.prerequisites}
|
||||||
|
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
|
||||||
|
disabled={rtDisabled}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={t('simulation.form.field.executedAt')} htmlFor="sim-executed-at">
|
||||||
|
<TextInput
|
||||||
|
id="sim-executed-at"
|
||||||
|
type="datetime-local"
|
||||||
|
name="executed_at"
|
||||||
|
value={rt.executed_at}
|
||||||
|
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
|
||||||
|
disabled={rtDisabled}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={t('simulation.form.field.executionResult')} htmlFor="sim-exec-result">
|
||||||
|
<TextArea
|
||||||
|
id="sim-exec-result"
|
||||||
|
name="execution_result"
|
||||||
|
value={rt.execution_result}
|
||||||
|
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
|
||||||
|
disabled={rtDisabled}
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{!isDone && canEditRT && hasC2Config && (
|
||||||
|
<div className="pt-xs flex items-center gap-md flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="c2-execute-btn"
|
||||||
|
className="btn-outline"
|
||||||
|
onClick={() => setShowC2Modal(true)}
|
||||||
|
>
|
||||||
|
{t('simulation.form.btn.executeC2')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="c2-import-trigger-btn"
|
||||||
|
className="btn-outline"
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
>
|
||||||
|
{t('simulation.form.btn.importC2History')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── SOC tab ───────────────────────────────────────────── */}
|
||||||
|
{safeTab === 'soc' && (
|
||||||
|
<form
|
||||||
|
id="soc-form"
|
||||||
|
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
|
||||||
|
noValidate
|
||||||
|
className="card-product flex flex-col gap-md"
|
||||||
|
>
|
||||||
|
<FormField label={t('simulation.form.field.logSource')} htmlFor="sim-log-source">
|
||||||
|
<TextInput
|
||||||
|
id="sim-log-source"
|
||||||
|
name="log_source"
|
||||||
|
value={soc.log_source}
|
||||||
|
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
|
||||||
|
disabled={socDisabled}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={t('simulation.form.field.logs')} htmlFor="sim-logs">
|
||||||
|
<TextArea
|
||||||
|
id="sim-logs"
|
||||||
|
name="logs"
|
||||||
|
value={soc.logs}
|
||||||
|
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
|
||||||
|
disabled={socDisabled}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={t('simulation.form.field.socComment')} htmlFor="sim-soc-comment">
|
||||||
|
<TextArea
|
||||||
|
id="sim-soc-comment"
|
||||||
|
name="soc_comment"
|
||||||
|
value={soc.soc_comment}
|
||||||
|
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })}
|
||||||
|
disabled={socDisabled}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={t('simulation.form.field.incidentNumber')} htmlFor="sim-incident">
|
||||||
|
<TextInput
|
||||||
|
id="sim-incident"
|
||||||
|
name="incident_number"
|
||||||
|
value={soc.incident_number}
|
||||||
|
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })}
|
||||||
|
disabled={socDisabled}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── C2 tab ────────────────────────────────────────────── */}
|
||||||
|
{safeTab === 'c2' && simulationId && (
|
||||||
<C2TasksPanel simulationId={simulationId} />
|
<C2TasksPanel simulationId={simulationId} />
|
||||||
)}
|
)}
|
||||||
</div>{/* end left column */}
|
|
||||||
|
|
||||||
{/* SOC card */}
|
|
||||||
<form
|
|
||||||
id="soc-form"
|
|
||||||
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
|
|
||||||
noValidate
|
|
||||||
className="card-product flex flex-col gap-md"
|
|
||||||
>
|
|
||||||
<h2 className="text-[20px] font-medium text-ink">{t('simulation.form.header.soc')}</h2>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{submitError ? (
|
{submitError ? (
|
||||||
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Unified sticky action bar */}
|
{/* Contextual sticky bar — not rendered at all for C2 tab */}
|
||||||
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
{safeTab !== 'c2' && (
|
||||||
{/* Done state: Reopen only */}
|
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||||
{showReopen && (
|
{safeTab === 'red-team' && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
{!isDone && canEditRT && (
|
||||||
className="btn-outline"
|
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||||
onClick={onReopen}
|
<Save size={14} aria-hidden />
|
||||||
disabled={transitionMutation.isPending}
|
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.save')}
|
||||||
data-testid="reopen-btn"
|
</button>
|
||||||
>
|
)}
|
||||||
<RotateCcw size={14} aria-hidden />
|
{showMarkReview && (
|
||||||
{t('simulation.form.btn.reopen')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
)}
|
className="btn-outline"
|
||||||
|
onClick={onMarkReview}
|
||||||
|
disabled={transitionMutation.isPending}
|
||||||
|
data-testid="mark-review-btn"
|
||||||
|
>
|
||||||
|
{t('simulation.form.btn.markReview')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isDone && canEditEngagements && simulationId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-text-link text-bloom-deep ml-auto"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
disabled={submitting}
|
||||||
|
data-testid="delete-btn"
|
||||||
|
>
|
||||||
|
{t('simulation.form.btn.delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Normal state buttons */}
|
{safeTab === 'soc' && (
|
||||||
{!isDone && canEditRT && (
|
<>
|
||||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
{!isDone && canSaveSoc && (
|
||||||
<Save size={14} aria-hidden />
|
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||||
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.save')}
|
<Save size={14} aria-hidden />
|
||||||
</button>
|
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.saveSoc')}
|
||||||
)}
|
</button>
|
||||||
{!isDone && canSaveSoc && (
|
)}
|
||||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
{showClose && (
|
||||||
<Save size={14} aria-hidden />
|
<button
|
||||||
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.saveSoc')}
|
type="button"
|
||||||
</button>
|
className="btn-outline"
|
||||||
)}
|
onClick={onClose}
|
||||||
{showMarkReview && (
|
disabled={transitionMutation.isPending}
|
||||||
<button
|
data-testid="close-btn"
|
||||||
type="button"
|
>
|
||||||
className="btn-outline"
|
{t('simulation.form.btn.close')}
|
||||||
onClick={onMarkReview}
|
</button>
|
||||||
disabled={transitionMutation.isPending}
|
)}
|
||||||
data-testid="mark-review-btn"
|
{showReopen && (
|
||||||
>
|
<button
|
||||||
{t('simulation.form.btn.markReview')}
|
type="button"
|
||||||
</button>
|
className="btn-outline"
|
||||||
)}
|
onClick={onReopen}
|
||||||
{showClose && (
|
disabled={transitionMutation.isPending}
|
||||||
<button
|
data-testid="reopen-btn"
|
||||||
type="button"
|
>
|
||||||
className="btn-outline"
|
<RotateCcw size={14} aria-hidden />
|
||||||
onClick={onClose}
|
{t('simulation.form.btn.reopen')}
|
||||||
disabled={transitionMutation.isPending}
|
</button>
|
||||||
data-testid="close-btn"
|
)}
|
||||||
>
|
</>
|
||||||
{t('simulation.form.btn.close')}
|
)}
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -162,6 +162,10 @@
|
|||||||
.tab-count-pill-active {
|
.tab-count-pill-active {
|
||||||
@apply bg-primary-soft text-primary;
|
@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) ─────────────────────────────────────── */
|
/* ─── Inline alert banners (L2) ─────────────────────────────────────── */
|
||||||
.alert-error {
|
.alert-error {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
// Reset hash between tests so useHashTab doesn't bleed state
|
||||||
|
afterEach(() => { history.replaceState(null, '', '/'); });
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
@@ -43,7 +45,6 @@ vi.mock('@/hooks/useAuth', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Wrap the page in a Route so useParams gets eid and sid
|
|
||||||
function EditPage() {
|
function EditPage() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -68,6 +69,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
|||||||
mock = new MockAdapter(apiClient);
|
mock = new MockAdapter(apiClient);
|
||||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -126,12 +128,16 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Close" button when status is review_required', async () => {
|
it('shows "Close" button on SOC tab when status is review_required', async () => {
|
||||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Switch to SOC tab to see Close button
|
||||||
|
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('close-btn')).toBeInTheDocument();
|
expect(screen.getByTestId('close-btn')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -148,41 +154,168 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SimulationFormPage — SOC role + pending (blocked)', () => {
|
describe('SimulationFormPage — tabs', () => {
|
||||||
let mock: MockAdapter;
|
let mock: MockAdapter;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockRole = 'soc';
|
mockRole = 'redteam';
|
||||||
mock = new MockAdapter(apiClient);
|
mock = new MockAdapter(apiClient);
|
||||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||||
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
|
|
||||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore();
|
mock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the SOC blocked banner', async () => {
|
it('renders 3 tabs: Red Team, SOC, Tâche C2', async () => {
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
|
||||||
|
|
||||||
|
expect(screen.getByRole('tab', { name: /Red Team/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: /SOC/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SOC tab is disabled when status is pending', async () => {
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
|
||||||
|
|
||||||
|
const socTab = screen.getByRole('tab', { name: /SOC/i });
|
||||||
|
expect(socTab).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SOC tab is enabled when status is review_required', async () => {
|
||||||
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
|
const socTab = screen.getByRole('tab', { name: /SOC/i });
|
||||||
|
expect(socTab).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('SOC inputs are disabled when status is pending', async () => {
|
it('SOC tab is enabled when status is done', async () => {
|
||||||
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText(/^Source de log/i)).toBeDisabled();
|
const socTab = screen.getByRole('tab', { name: /SOC/i });
|
||||||
|
expect(socTab).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C2 tab is enabled for redteam in edit mode', async () => {
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByLabelText(/^Numéro d'incident/i)).toBeDisabled();
|
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
|
||||||
|
|
||||||
|
expect(screen.getByRole('tab', { name: /Tâche C2/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C2 tab is disabled for SOC role (backend 403 on /c2/tasks)', async () => {
|
||||||
|
mockRole = 'soc';
|
||||||
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeDisabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SOC fields are visible after switching to SOC tab', async () => {
|
||||||
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/^Source de log/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByLabelText(/^Numéro d'incident/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sticky bar is not rendered on C2 tab', async () => {
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Tâche C2/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument());
|
||||||
|
// Save button should not appear on C2 tab
|
||||||
|
expect(screen.queryByRole('button', { name: /Enregistrer/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C2 tasks count pill shows when tasks exist', async () => {
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
mythic_task_display_id: 10,
|
||||||
|
callback_display_id: 1,
|
||||||
|
command: 'whoami',
|
||||||
|
params: null,
|
||||||
|
status: 'completed',
|
||||||
|
completed: true,
|
||||||
|
output: 'SYSTEM',
|
||||||
|
mapping_applied: false,
|
||||||
|
source: 'import',
|
||||||
|
created_at: '2026-06-10T10:00:00',
|
||||||
|
completed_at: '2026-06-10T10:00:05',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Count pill shows "1" on the C2 tab
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SimulationFormPage — SOC role + pending (tab disabled)', () => {
|
||||||
|
let mock: MockAdapter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRole = 'soc';
|
||||||
|
mock = new MockAdapter(apiClient);
|
||||||
|
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||||
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SOC tab is disabled for SOC role when status is pending', async () => {
|
||||||
|
renderWithProviders(<EditPage />, {
|
||||||
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).toBeDisabled());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Red Team inputs are disabled for SOC', async () => {
|
it('Red Team inputs are disabled for SOC', async () => {
|
||||||
@@ -206,6 +339,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
|||||||
mock = new MockAdapter(apiClient);
|
mock = new MockAdapter(apiClient);
|
||||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -217,10 +351,13 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
|||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Switch to SOC tab
|
||||||
|
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled();
|
expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByLabelText(/^Numéro d'incident/i)).not.toBeDisabled();
|
expect(screen.getByLabelText(/^Numéro d'incident/i)).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,23 +371,15 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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(/^Source de log/i)).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows "Close" for SOC when review_required', async () => {
|
it('shows "Close" for SOC when review_required', async () => {
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Switch to SOC tab
|
||||||
|
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('close-btn')).toBeInTheDocument();
|
expect(screen.getByTestId('close-btn')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -276,6 +405,15 @@ describe('SimulationFormPage — new simulation', () => {
|
|||||||
expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('create-sim-btn')).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||||
@@ -297,6 +435,7 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
|||||||
url: 'https://mythic.lab:7443',
|
url: 'https://mythic.lab:7443',
|
||||||
verify_tls: true,
|
verify_tls: true,
|
||||||
});
|
});
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
@@ -307,6 +446,7 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
|||||||
|
|
||||||
it('hides Execute via C2 button when no c2 config (404)', async () => {
|
it('hides Execute via C2 button when no c2 config (404)', async () => {
|
||||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
@@ -323,100 +463,21 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
|||||||
url: 'https://mythic.lab:7443',
|
url: 'https://mythic.lab:7443',
|
||||||
verify_tls: true,
|
verify_tls: true,
|
||||||
});
|
});
|
||||||
|
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
// Wait for RT tab to load; done sim disables RT editing
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).toBeDisabled());
|
||||||
|
// Execute via C2 not shown on done simulation
|
||||||
|
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
||||||
|
// Reopen is on the SOC tab — switch to confirm
|
||||||
|
const socTab = screen.getByRole('tab', { name: /SOC/i });
|
||||||
|
expect(socTab).not.toBeDisabled();
|
||||||
|
fireEvent.click(socTab);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
|
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SimulationFormPage — C2 tasks panel visibility', () => {
|
|
||||||
let mock: MockAdapter;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRole = 'redteam';
|
|
||||||
mock = new MockAdapter(apiClient);
|
|
||||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
mock.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows C2 tasks panel when c2 config exists (even with no tasks)', async () => {
|
|
||||||
mock.onGet('/engagements/42/c2-config').reply(200, {
|
|
||||||
has_token: true,
|
|
||||||
url: 'https://mythic.lab:7443',
|
|
||||||
verify_tls: true,
|
|
||||||
});
|
|
||||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
|
||||||
renderWithProviders(<EditPage />, {
|
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides C2 tasks panel when no c2 config and no tasks', async () => {
|
|
||||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
|
||||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
|
||||||
renderWithProviders(<EditPage />, {
|
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
||||||
});
|
|
||||||
// Wait for page data to load then confirm no panel
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows C2 tasks panel when tasks exist even without c2 config', async () => {
|
|
||||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
|
||||||
mock.onGet('/simulations/7/c2/tasks').reply(200, {
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
mythic_task_display_id: 10,
|
|
||||||
callback_display_id: 1,
|
|
||||||
command: 'whoami',
|
|
||||||
params: null,
|
|
||||||
status: 'completed',
|
|
||||||
completed: true,
|
|
||||||
output: 'SYSTEM',
|
|
||||||
mapping_applied: false,
|
|
||||||
source: 'import',
|
|
||||||
created_at: '2026-06-10T10:00:00',
|
|
||||||
completed_at: '2026-06-10T10:00:05',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
renderWithProviders(<EditPage />, {
|
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SOC role never sees C2 tasks panel', async () => {
|
|
||||||
mockRole = 'soc';
|
|
||||||
mock.onGet('/engagements/42/c2-config').reply(200, {
|
|
||||||
has_token: true,
|
|
||||||
url: 'https://mythic.lab:7443',
|
|
||||||
verify_tls: true,
|
|
||||||
});
|
|
||||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
|
||||||
renderWithProviders(<EditPage />, {
|
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Import C2 history button when c2 config exists', async () => {
|
it('shows Import C2 history button when c2 config exists', async () => {
|
||||||
|
|||||||
@@ -82,3 +82,44 @@ 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
356
tasks/todo.md
356
tasks/todo.md
@@ -1,260 +1,208 @@
|
|||||||
# Sprint 12 — i18n FR complet + EngagementDetailPage tab merge
|
# Sprint 13 — SimulationFormPage : layout en 3 tabs (Red Team / SOC / Tâche C2)
|
||||||
|
|
||||||
**Base** : `sprint/11-spectrum-ux` (PR #13 ouverte, pas encore mergée). Sprint 12 enchaîne directement.
|
**Base** : `sprint/12-i18n-fr` (PR #14 ouverte, pas encore mergée). Sprint 13 enchaîne directement.
|
||||||
**Worktree** : `.claude/worktrees/sprint-12-i18n-fr/` (dédié — conforme à la mémoire worktree-per-sprint).
|
**Worktree** : `.claude/worktrees/sprint-13-sim-tabs/` (dédié — conforme à `feedback_worktree_per_sprint`).
|
||||||
**Branch** : `sprint/12-i18n-fr`.
|
**Branch** : `sprint/13-sim-tabs`.
|
||||||
**Scope** : frontend uniquement. Backend intouché.
|
**Scope** : frontend uniquement. Backend intouché. Aligné sur le pattern Tabs du sprint 11 (EngagementDetailPage 3→2 tabs) appliqué à SimulationFormPage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Décisions binding (lockées par user)
|
## Décisions binding (lockées par user)
|
||||||
|
|
||||||
1. **Approche i18n** : `react-i18next` complet (deps `react-i18next` + `i18next`, JSON FR, hook `useTranslation`).
|
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. **Acronymes** : RT, SOC, MITRE, ATT&CK, C2, BAS, TLS, MITM, JWT, IP, URL, API, ID — **gardés en anglais** dans les valeurs FR.
|
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. **Status enum** : valeurs DB (`pending`, `in_progress`, `done`, `planned`, `active`, `closed`) gardées EN — traduction au rendering layer via `i18n/status.ts`.
|
3. **Sticky bar entièrement contextuelle** par tab :
|
||||||
4. **Format de date** : `fr-FR` locale (`jj/mm/aaaa`, ex. `20/06/2026`).
|
- **RT tab** : `[Save, Mark for review]` (et `[Delete]` global, voir plus bas)
|
||||||
5. **Ton toasts/alerts** : **bref** (ex. `"Engagement créé"`, pas `"Engagement enregistré avec succès"`).
|
- **SOC tab** : `[Save SOC, Close]`
|
||||||
6. **Schedule → Description tab** : merger Schedule INTO Description. Résultat : 2 tabs (Description + Simulations).
|
- **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
|
## Contraintes constantes
|
||||||
|
|
||||||
- Primary `#024ad8` Electric Blue intouchée.
|
- Primary `#024ad8` Electric Blue intouchée.
|
||||||
- Brutalisme intact : `rounded-none` partout sauf status pills + tab count pills + avatars, zéro `transition-*`, zéro `shadow-*`.
|
- Brutalisme intact : `rounded-none` (exceptions documentées), zéro `transition-*`, zéro `shadow-*`.
|
||||||
- `tailwind.config.*` inchangé sauf cas exceptionnel justifié.
|
- DESIGN.md amendments additives uniquement (sub-section "Sub-page tabs with disabled state" si besoin).
|
||||||
- DESIGN.md amendments additives uniquement.
|
- Tous les nouveaux labels passent par `i18n/fr.json` (cf. sprint 12) — pas de string EN hardcodée.
|
||||||
- Mono uniquement pour data (IDs MITRE, dates ISO côté backend, commands, etc.).
|
- `pnpm build` MANDATORY avant push (leçon `feedback_css_apply_tokens`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 1 — Merge Schedule into Description (mécanique, commit 1)
|
## Task 1 — Extend Tabs primitive : support `disabled` per item
|
||||||
|
|
||||||
**File** : `frontend/src/pages/EngagementDetailPage.tsx`
|
**File** : `frontend/src/components/Tabs.tsx`
|
||||||
|
|
||||||
**Current** (3 tabs Schedule / Description / Simulations) :
|
Current `TabItem` :
|
||||||
```tsx
|
```tsx
|
||||||
type TabId = 'schedule' | 'description' | 'simulations';
|
interface TabItem<T extends string> { id: T; label: string; count?: number; }
|
||||||
const items: TabItem<TabId>[] = [
|
|
||||||
{ id: 'schedule', label: 'Schedule' },
|
|
||||||
{ id: 'description', label: 'Description' },
|
|
||||||
{ id: 'simulations', label: 'Simulations', count },
|
|
||||||
];
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Target** (2 tabs) :
|
Target :
|
||||||
```tsx
|
```tsx
|
||||||
type TabId = 'description' | 'simulations';
|
interface TabItem<T extends string> { id: T; label: string; count?: number; disabled?: boolean; }
|
||||||
const items: TabItem<TabId>[] = [
|
|
||||||
{ id: 'description', label: 'Description' },
|
|
||||||
{ id: 'simulations', label: 'Simulations', count },
|
|
||||||
];
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Migration du contenu Schedule** : déplacer dans le panel Description, en HEADER (au-dessus du paragraphe description) :
|
Comportement quand `disabled` :
|
||||||
```tsx
|
- `<button>` reçoit `disabled={true}` et `aria-disabled="true"`
|
||||||
<header className="flex items-start justify-between gap-md mb-md">
|
- Class CSS `.tab-underline-disabled` ajoutée (nouvelle recipe ci-dessous)
|
||||||
<dl className="grid grid-cols-2 gap-x-lg gap-y-xxs text-caption-md">
|
- Le hover ne change pas la couleur (`hover:text-graphite` ou rien)
|
||||||
<dt className="text-graphite">Start date</dt> <dd>{formatDate(eng.start_date)}</dd>
|
- ArrowLeft/ArrowRight nav skippent les tabs disabled (cycle vers le tab actif suivant non-disabled)
|
||||||
<dt className="text-graphite">End date</dt> <dd>{eng.end_date ? formatDate(eng.end_date) : '—'}</dd>
|
- Clicks ignorés (`onClick={() => !disabled && onChange(item.id)}`)
|
||||||
<dt className="text-graphite">Status</dt> <dd><StatusBadge status={eng.status} /></dd>
|
- `aria-selected` reste `false` quand disabled
|
||||||
<dt className="text-graphite">Created at</dt> <dd>{formatDate(eng.created_at)}</dd>
|
|
||||||
</dl>
|
**New recipe** dans `frontend/src/styles/index.css` :
|
||||||
<Link to={`/engagements/${id}/edit`} className="btn-outline">Edit</Link>
|
```css
|
||||||
</header>
|
.tab-underline-disabled {
|
||||||
<hr className="my-md border-hairline" />
|
@apply text-graphite opacity-50 cursor-not-allowed;
|
||||||
<h2 className="text-display-sm mb-sm">Description</h2>
|
}
|
||||||
{eng.description || <p className="text-graphite">No description provided.</p>}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Default `useHashTab('description')` au lieu de `'schedule'`.
|
(Override le hover du `.tab-underline` parent ; pas de `border-primary` sur l'active state quand disabled — n'arrivera jamais puisque `aria-selected=false`.)
|
||||||
- Header de la page (BackLink + name + créé par + Edit + Export) reste **au-dessus des tabs**, intouché.
|
|
||||||
- Mettre à jour `EngagementDetailPage.test.tsx` si une assertion vise le tab "Schedule".
|
|
||||||
|
|
||||||
**Note** : les strings ci-dessus seront ensuite traduites dans le pass i18n. Pas la peine de les écrire en FR dans commit 1.
|
**Tests** : extend `Tabs.test.tsx` :
|
||||||
|
- Tab avec `disabled: true` ne fire pas `onChange` au click
|
||||||
|
- ArrowRight/ArrowLeft skippent les tabs disabled
|
||||||
|
- `aria-disabled="true"` présent
|
||||||
|
- Classe `.tab-underline-disabled` appliquée
|
||||||
|
|
||||||
|
DESIGN.md : ajouter 1 ligne dans la sub-section `Sub-page tabs` documentant la variante `disabled`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 2 — Full FR translation via react-i18next
|
## Task 2 — SimulationFormPage : refactor en 3 tabs
|
||||||
|
|
||||||
### Structure cible
|
**File** : `frontend/src/pages/SimulationFormPage.tsx`
|
||||||
|
|
||||||
|
**Layout cible (edit mode)** :
|
||||||
```
|
```
|
||||||
frontend/src/i18n/
|
[Header : ← Retour à l'engagement | titre sim | status badge]
|
||||||
├── index.ts # init i18next, single FR locale
|
[AlertBanner Done si done (au-dessus des tabs, status info global)]
|
||||||
├── fr.json # toutes les keys FR
|
[Tabs : Red Team | SOC (disabled si !=review_required+) | Tâche C2 (count pill)]
|
||||||
└── status.ts # engagementStatusLabel + simulationStatusLabel
|
[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)
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Init (`frontend/src/i18n/index.ts`)
|
**Layout en mode création (new)** :
|
||||||
```ts
|
```
|
||||||
import i18n from 'i18next';
|
[Header : ← Retour à l'engagement | "Nouvelle simulation"]
|
||||||
import { initReactI18next } from 'react-i18next';
|
[Tabs : Red Team (active) | SOC (disabled) | Tâche C2 (disabled, pas de count pill)]
|
||||||
import fr from './fr.json';
|
[Panel Red Team : champs simplifiés (juste Name + MITRE Techniques minimum)]
|
||||||
|
[Sticky bar bottom : [Create simulation] [Cancel]]
|
||||||
i18n.use(initReactI18next).init({
|
|
||||||
resources: { fr: { translation: fr } },
|
|
||||||
lng: 'fr',
|
|
||||||
fallbackLng: 'fr',
|
|
||||||
interpolation: { escapeValue: false },
|
|
||||||
returnEmptyString: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default i18n;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Mount dans `frontend/src/main.tsx` : `import './i18n'` avant `<App />`.
|
**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).
|
||||||
|
|
||||||
### Pattern usage
|
**Sticky bar contextuel** :
|
||||||
```tsx
|
- Wrapper `<div className="sticky bottom-0 …">` avec contenu conditionnel `{activeTab === 'red-team' && <RtActions />}{activeTab === 'soc' && <SocActions />}{activeTab === 'c2' && null}`.
|
||||||
const { t } = useTranslation();
|
- Ne pas rendre le wrapper du tout si C2 actif (pas de sticky div vide).
|
||||||
<button>{t('common.save')}</button>
|
- Réutiliser les boutons existants (Save/Mark/Save SOC/Close/Reopen/Delete) — juste les regrouper.
|
||||||
<h1>{t('engagement.list.title')}</h1>
|
|
||||||
<p>{t('engagement.delete.confirm', { name: eng.name })}</p>
|
**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.)
|
||||||
|
|
||||||
### Convention de keys — **nested by feature**
|
**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).
|
||||||
|
|
||||||
```
|
|
||||||
common.* save, cancel, delete, edit, new, loading, retry, back, dismiss
|
|
||||||
nav.* engagements, templates, users, signOut, brand
|
|
||||||
auth.* login.title, login.subtitle, login.signIn, login.signingIn, login.invalid, forbidden
|
|
||||||
engagement.list.* title, subtitle, new, empty.title, empty.desc, col.name, col.status, col.start, col.end, col.createdBy, col.actions, view, edit, delete, deleteConfirm, toast.deleted, error.delete
|
|
||||||
engagement.detail.* tabs.description, tabs.simulations, schedule.startDate, schedule.endDate, schedule.status, schedule.createdAt, edit, createdBy, noDescription, loading, errorLoad, notFound
|
|
||||||
engagement.form.* title.new, title.edit, subtitle.new, subtitle.edit, field.name, field.description, field.startDate, field.endDate, field.endDateHint, field.status, validation.nameRequired, validation.startDateRequired, validation.endDateAfterStart, btn.create, btn.save, btn.saving, btn.cancel, toast.created, toast.updated, error.save, error.load
|
|
||||||
simulation.form.* title.new, field.name, field.mitre, field.description, field.commands, field.commandsHint, field.prerequisites, field.executedAt, field.executionResult, btn.create, btn.creating, btn.save, btn.saving, btn.saveSoc, btn.markReview, btn.close, btn.reopen, btn.delete, btn.executeC2, btn.importC2History, btn.cancel, deleteConfirm.title, deleteConfirm.desc, deleteConfirm.confirm, deleteConfirm.cancel, banner.done, banner.socNotReady, header.redTeam, header.soc, field.logSource, field.logs, field.socComment, field.incidentNumber, validation.nameRequired, toast.created, toast.updated, toast.deleted, toast.markedReview, toast.closed, toast.reopened, toast.socUpdated, error.create, error.update, error.delete, error.soc, error.transition, loading, errorLoad
|
|
||||||
template.list.* title, subtitle, new, empty.title, empty.desc, col.name, col.description, col.commands, col.createdAt, edit, delete
|
|
||||||
template.form.* title.new, title.edit, field.name, field.description, field.descriptionPlaceholder, field.commands, field.commandsHint, field.commandsPlaceholder, field.prerequisites, field.prerequisitesPlaceholder, field.mitre, field.mitreSearch, field.mitreOpenMatrix, field.mitreEmpty, btn.save, btn.saving, btn.delete, btn.cancel, validation.nameRequired, toast.created, toast.saved, toast.deleted, deleteConfirm.title, deleteConfirm.desc, deleteConfirm.confirm, deleteConfirm.cancel, loading, errorLoad, errorSave, errorDelete
|
|
||||||
user.admin.* title, subtitle, new, col.username, col.role, col.createdAt, col.actions, role.admin, role.redteam, role.soc, field.username, field.password, btn.create, btn.creating, btn.resetPassword, btn.delete, btn.cancel, ...
|
|
||||||
c2.config.* title, disabled, field.url, field.urlHint, field.token, field.tokenHint, field.verifyTls, verifyTlsHint, btn.save, btn.saving, btn.test, btn.testing, btn.delete, toast.saved, toast.tested, toast.deleted, error.save, error.test, error.delete
|
|
||||||
c2.tasks.* title, empty, col.task, col.command, col.source, col.status, col.completedAt, refreshing
|
|
||||||
c2.modal.execute.* title, callback, btn.launch, btn.launching, btn.cancel, commandCount_one, commandCount_other, validation.callbackRequired, validation.commandsRequired, toast.launched, error.launch
|
|
||||||
c2.modal.import.* title, total, page, of, prev, next, btn.import, btn.importing, btn.cancel, selected, empty, toast.imported, toast.partial, error.import
|
|
||||||
c2.modal.picker.* title, empty, hostnameColon, lastCheckinColon
|
|
||||||
mitre.matrix.* title, filter, loading, error, applyItem_one, applyItem_other, clearAll, close, retry
|
|
||||||
mitre.field.* empty, search, openMatrix, savedToast, errorToast
|
|
||||||
mitre.tag.* remove
|
|
||||||
state.* loading, error.title, error.desc, empty.default, retry
|
|
||||||
toast.* dismiss
|
|
||||||
status.engagement.* planned, active, closed
|
|
||||||
status.simulation.* pending, in_progress, review_required, done
|
|
||||||
```
|
|
||||||
|
|
||||||
### Status enum map (`frontend/src/i18n/status.ts`)
|
|
||||||
```ts
|
|
||||||
import i18n from './index';
|
|
||||||
import type { EngagementStatus, SimulationStatus } from '@/types';
|
|
||||||
|
|
||||||
export const engagementStatusLabel = (s: EngagementStatus): string =>
|
|
||||||
i18n.t(`status.engagement.${s}`);
|
|
||||||
export const simulationStatusLabel = (s: SimulationStatus): string =>
|
|
||||||
i18n.t(`status.simulation.${s}`);
|
|
||||||
```
|
|
||||||
|
|
||||||
Consumers : `StatusBadge.tsx`, `SimulationStatusBadge.tsx` si distinct, `EngagementDetailPage` (header dans Description tab), `EngagementFormPage` (STATUS_OPTIONS dérivé du map), `SimulationFormPage` banners (restructurer comme phrases entières dans JSON, pas inline status word).
|
|
||||||
|
|
||||||
### Date formatting (`frontend/src/lib/format.ts`)
|
|
||||||
```ts
|
|
||||||
export const formatDate = (iso: string | null | undefined): string => {
|
|
||||||
if (!iso) return '—';
|
|
||||||
return new Date(iso).toLocaleDateString('fr-FR'); // jj/mm/aaaa
|
|
||||||
};
|
|
||||||
export const formatDateTime = (iso: string): string =>
|
|
||||||
new Date(iso).toLocaleString('fr-FR');
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace raw ISO renders dans : `EngagementDetailPage`, `EngagementsListPage`, `TemplatesListPage`, `UsersAdminPage`. Garder ISO dans les commands / execution_result / params (data brut).
|
|
||||||
|
|
||||||
### Acronymes — locked EN dans le JSON FR
|
|
||||||
|
|
||||||
Exemples corrects :
|
|
||||||
- `"c2.config.title": "Configuration C2"`
|
|
||||||
- `"mitre.matrix.title": "Matrice MITRE ATT&CK"`
|
|
||||||
- `"simulation.form.header.redTeam": "Red Team"` (acronyme idiom)
|
|
||||||
- `"simulation.form.header.soc": "SOC"` (acronyme)
|
|
||||||
- `"auth.forbidden": "Accès refusé"` (déjà FR dans le code)
|
|
||||||
|
|
||||||
### Strings déjà FR (preserve)
|
|
||||||
|
|
||||||
- `ProtectedRoute.tsx:35` → `'Accès refusé'` (déjà FR). Route via `t('auth.forbidden')`.
|
|
||||||
- `Toast.test.tsx:28` → assertion `'Session expirée'`. Confirmer cohérence avec la key associée.
|
|
||||||
|
|
||||||
### Composants out-of-scope
|
|
||||||
|
|
||||||
`ForbiddenState.tsx` et `NotFoundState.tsx` n'existent pas — pas de keys i18n à créer. Si tu vois un cas où ils manquent, **flag dans le PR body** pour un sprint follow-up, ne crée rien.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Counts (estimation workflow)
|
## Counts attendus
|
||||||
|
|
||||||
| Métrique | Valeur |
|
| Métrique | Valeur |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Strings UI uniques à traduire (dedup) | **~140 keys** |
|
| Fichiers source modifiés | 3 (`Tabs.tsx`, `SimulationFormPage.tsx`, `index.css` recipe) |
|
||||||
| Test assertions à update | **~123** (sur 193 raw matches, 70 sont data fixture) |
|
| Fichiers source touched (juste i18n keys) | 1 (`fr.json`) |
|
||||||
| Fichiers source modifiés | **~30** |
|
| Fichiers tests modifiés | 2 (`Tabs.test.tsx`, `SimulationFormPage.test.tsx`) |
|
||||||
| Fichiers tests modifiés | **23** |
|
| Nouveaux fichiers | 0 |
|
||||||
| Nouveaux fichiers | 3 (`i18n/index.ts`, `i18n/fr.json`, `i18n/status.ts`) |
|
| DESIGN.md amendments | 1 paragraphe additive |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Commits plan (10 commits)
|
## Commits plan (~4 commits)
|
||||||
|
|
||||||
1. `feat(frontend): merge Schedule tab into Description on EngagementDetailPage`
|
1. `feat(frontend): Tabs primitive supports per-item disabled state` (Tabs.tsx + recipe + Tabs.test.tsx)
|
||||||
2. `chore(frontend): install react-i18next + i18next deps`
|
2. `feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / C2 tasks)` (SimulationFormPage.tsx + tests + i18n keys + DESIGN.md amendment)
|
||||||
3. `feat(frontend): scaffold i18n init, fr.json skeleton, status map`
|
3. `refactor(frontend): contextual sticky bar per active tab on SimulationFormPage` (extraction des actions par tab, supprimer SOC-blocked banner)
|
||||||
4. `feat(frontend): i18n common + nav + auth (Layout, LoginPage, ProtectedRoute)`
|
4. `docs(changelog): sprint 13 simulation tabs entry`
|
||||||
5. `feat(frontend): i18n engagement pages (list, detail, form) + status map wiring`
|
|
||||||
6. `feat(frontend): i18n simulation pages (form + list component)`
|
|
||||||
7. `feat(frontend): i18n template pages (list + form + picker modal)`
|
|
||||||
8. `feat(frontend): i18n MITRE components (matrix modal, picker, field, tag)`
|
|
||||||
9. `feat(frontend): i18n C2 components (config, tasks, modals, picker)`
|
|
||||||
10. `feat(frontend): i18n shared state components + users admin + fr-FR date formatting`
|
|
||||||
|
|
||||||
**Ordre** : chaque commit = une slice verticale (source + tests mis à jour ensemble) → vitest reste vert à chaque commit.
|
Collapse à 2-3 si plus efficace.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tests strategy
|
|
||||||
|
|
||||||
### Baseline
|
|
||||||
- vitest **236/236** doit tenir à chaque commit.
|
|
||||||
- Tests rendent avec i18n init déjà chargé (FR loaded), pas de mock setup needed.
|
|
||||||
|
|
||||||
### Pattern de rewrite (3 stratégies)
|
|
||||||
1. **Direct rewrite** (cas le plus fréquent) : `getByRole('button', { name: /save/i })` → `getByRole('button', { name: /enregistrer/i })`.
|
|
||||||
2. **Refactor vers `data-testid`** (recommandé pour les boutons avec action semantics) : `data-testid="btn-save"` côté source, `getByTestId('btn-save')` côté test. Survit aux re-wordings futurs.
|
|
||||||
- Cible prioritaire : `SimulationFormPage.test.tsx` (5 boutons), `UsersAdminPage.test.tsx` (4), `TemplateFormPage.test.tsx` (4), `EngagementFormPage.test.tsx` (2).
|
|
||||||
3. **Keep domain assertions** (~70 sites) : `getByText('T1078')`, `getByText('alice')`, `getByText('WIN-DC01')`, `getByText('whoami')`. Pas de change.
|
|
||||||
|
|
||||||
### Nouveau test (commit 3)
|
|
||||||
`frontend/tests/i18n.test.ts` :
|
|
||||||
- i18n init avec `lng: 'fr'`.
|
|
||||||
- `t('common.save')` → `'Enregistrer'`.
|
|
||||||
- Missing key returns key path (debug).
|
|
||||||
- `simulationStatusLabel('done')` → `'Terminé'`.
|
|
||||||
- Interpolation : `t('engagement.delete.confirm', { name: 'X' })` contient `« X »`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## DoD
|
## DoD
|
||||||
|
|
||||||
1. EngagementDetailPage a **2 tabs** (Description + Simulations). Schedule content = header block dans le panel Description. Edit button atteignable.
|
1. SimulationFormPage en edit mode : 3 tabs visibles (Red Team default, SOC, Tâche C2). Count pill sur C2 = `c2Tasks.length`.
|
||||||
2. `package.json` inclut `react-i18next` et `i18next`.
|
2. Tabs SOC + C2 désactivés en mode création.
|
||||||
3. `frontend/src/i18n/index.ts` initialise i18next avec FR comme `lng` ET `fallbackLng`.
|
3. Tab SOC désactivé tant que status ∉ `{review_required, done}`.
|
||||||
4. `frontend/src/i18n/fr.json` contient ~140 keys, organisées en namespaces nested.
|
4. Sticky bar contextuelle : RT [Save + Mark for review + Delete], SOC [Save SOC + Close + Reopen], C2 [aucune sticky bar].
|
||||||
5. `frontend/src/i18n/status.ts` expose les 2 labels et est consommé partout où raw status est rendu.
|
5. `useHashTab` gère le fallback gracieux quand hash pointe vers un tab disabled (retombe sur `'red-team'`).
|
||||||
6. **Zéro string UI EN hardcodée** dans `frontend/src/pages/` + `frontend/src/components/`. Exceptions autorisées : acronymes (liste lockée), brand "Mimic", glyphes (`—`, `×`, `▾`, `▸`), API enum passthroughs, MITRE IDs, command/payload text.
|
6. Tabs primitive : ArrowLeft/ArrowRight skippent les tabs disabled.
|
||||||
7. Dates rendues via `formatDate()` au format `fr-FR` (jj/mm/aaaa).
|
7. AlertBanner Done garde sa place au-dessus des tabs (status global). SOC-blocked banner supprimé.
|
||||||
8. `npm run test` → **236+ / 236+** (+5 nouveaux tests i18n).
|
8. C2TasksPanel rendu dans le tab C2 panel.
|
||||||
9. `npm run build` succès (zéro TS error, PostCSS clean — leçon `feedback_css_apply_tokens` respectée).
|
9. `[Execute via C2]` + `[Import C2 history]` restent dans le tab Red Team.
|
||||||
10. **Manual smoke obligatoire** : login → engagements → detail → simulation form → templates → users → C2 config — chaque string visible est en FR (sauf acronymes + brand).
|
10. Tests : Tabs.test.tsx + SimulationFormPage.test.tsx adaptés, baseline ≥ 245 vitest, +N nouveaux tests sur disabled tabs + count pill + tab gating.
|
||||||
11. CHANGELOG.md entrée sprint 12 listant migration i18n + tab merge.
|
11. `tsc --noEmit` + `eslint --max-warnings=0` + `pnpm build` clean.
|
||||||
|
12. CHANGELOG.md entrée sprint 13.
|
||||||
---
|
|
||||||
|
|
||||||
## Out of scope (explicite)
|
## Out of scope (explicite)
|
||||||
|
|
||||||
- ❌ Backend i18n (pas de translations DB-side)
|
- ❌ Refactor des sub-forms RT / SOC (juste déplacement dans le tab)
|
||||||
- ❌ Multi-langue (uniquement FR pour ce sprint, infra prête mais pas d'EN switcher)
|
- ❌ Nouveau pattern Tabs ailleurs (uniquement SimulationFormPage cette sprint)
|
||||||
- ❌ Language picker UI
|
- ❌ Changes backend
|
||||||
- ❌ ForbiddenState / NotFoundState components creation
|
- ❌ Color / brutalism / token changes
|
||||||
- ❌ Color palette / brutalism / token changes
|
- ❌ Multi-langue (FR uniquement, infra sprint 12)
|
||||||
- ❌ New features
|
|
||||||
|
|||||||
Reference in New Issue
Block a user