)}
- {/* Inline Quick Search picker */}
- {showPicker && !disabled && (
-
setShowMatrix(false)}
/>
diff --git a/frontend/src/components/SimulationList.tsx b/frontend/src/components/SimulationList.tsx
index 94ded36..acf5983 100644
--- a/frontend/src/components/SimulationList.tsx
+++ b/frontend/src/components/SimulationList.tsx
@@ -96,11 +96,15 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
- {sim.techniques.length === 0
- ? '—'
- : sim.techniques.length === 1
- ? sim.techniques[0].id
- : `${sim.techniques[0].id} +${sim.techniques.length - 1}`}
+ {(() => {
+ const items = [
+ ...(sim.tactics ?? []).map((t) => t.id),
+ ...sim.techniques.map((t) => t.id),
+ ];
+ if (items.length === 0) return '—';
+ if (items.length === 1) return items[0];
+ return `${items[0]} +${items.length - 1}`;
+ })()}
|
diff --git a/frontend/src/hooks/useSimulations.ts b/frontend/src/hooks/useSimulations.ts
index ab9eed5..681c4d0 100644
--- a/frontend/src/hooks/useSimulations.ts
+++ b/frontend/src/hooks/useSimulations.ts
@@ -48,6 +48,8 @@ export function useUpdateSimulation(id: number, engagementId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
+ qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
+ qc.invalidateQueries({ queryKey: ['engagements'] });
},
});
}
@@ -71,6 +73,8 @@ export function useTransitionSimulation(id: number, engagementId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
+ qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
+ qc.invalidateQueries({ queryKey: ['engagements'] });
},
});
}
diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts
new file mode 100644
index 0000000..adcf74e
--- /dev/null
+++ b/frontend/src/hooks/useTheme.ts
@@ -0,0 +1,59 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type Theme = 'light' | 'dark' | 'system';
+
+const STORAGE_KEY = 'mimic-theme';
+
+function resolveTheme(theme: Theme): 'light' | 'dark' {
+ if (theme === 'system') {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+ return theme;
+}
+
+function applyTheme(theme: Theme) {
+ const resolved = resolveTheme(theme);
+ document.documentElement.classList.toggle('dark', resolved === 'dark');
+}
+
+function readStoredTheme(): Theme {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
+ } catch {
+ // localStorage unavailable
+ }
+ return 'system';
+}
+
+export function useTheme() {
+ const [theme, setThemeState] = useState(readStoredTheme);
+
+ useEffect(() => {
+ applyTheme(theme);
+ }, [theme]);
+
+ // Track system preference changes when theme === 'system'
+ useEffect(() => {
+ if (theme !== 'system') return;
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
+ const handler = () => applyTheme('system');
+ mq.addEventListener('change', handler);
+ return () => mq.removeEventListener('change', handler);
+ }, [theme]);
+
+ const setTheme = useCallback((next: Theme) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, next);
+ } catch {
+ // ignore
+ }
+ setThemeState(next);
+ }, []);
+
+ const cycleTheme = useCallback(() => {
+ setTheme(theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light');
+ }, [theme, setTheme]);
+
+ return { theme, setTheme, cycleTheme };
+}
diff --git a/frontend/src/pages/EngagementsListPage.tsx b/frontend/src/pages/EngagementsListPage.tsx
index ab2b521..4164895 100644
--- a/frontend/src/pages/EngagementsListPage.tsx
+++ b/frontend/src/pages/EngagementsListPage.tsx
@@ -24,9 +24,9 @@ export function EngagementsListPage(): JSX.Element {
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
try {
await deleteMutation.mutateAsync(eng.id);
- push('Engagement supprimé', 'success');
+ push('Engagement deleted', 'success');
} catch (err) {
- push(extractApiError(err, 'Suppression impossible'), 'error');
+ push(extractApiError(err, 'Could not delete engagement'), 'error');
}
};
@@ -41,7 +41,7 @@ export function EngagementsListPage(): JSX.Element {
{canEditEngagements ? (
- New engagement
+ + New
) : null}
@@ -59,7 +59,7 @@ export function EngagementsListPage(): JSX.Element {
action={
canEditEngagements ? (
- Create engagement
+ + New engagement
) : undefined
}
diff --git a/frontend/src/pages/SimulationFormPage.tsx b/frontend/src/pages/SimulationFormPage.tsx
index c852226..627a6ba 100644
--- a/frontend/src/pages/SimulationFormPage.tsx
+++ b/frontend/src/pages/SimulationFormPage.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
+import { Save, RotateCcw } from 'lucide-react';
import { extractApiError } from '@/api/client';
import type { SimulationPatchInput } from '@/api/types';
import { useAuth } from '@/hooks/useAuth';
@@ -105,30 +106,28 @@ export function SimulationFormPage(): JSX.Element {
const simulation = detail.data;
const status = simulation?.status;
- // Role-based field locking
+ // US-18: Done = fully read-only, Reopen only
+ const isDone = status === 'done';
+
const canEditRT = isAdmin || isRedteam;
- // SOC can only edit when status is review_required or done
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
- const canSaveSoc = socCanEdit || canEditEngagements;
- const rtDisabled = !canEditRT;
- const socDisabled = !canEditEngagements && !socCanEdit;
+ const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
+ const rtDisabled = !canEditRT || isDone;
+ const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
- // Transition buttons visibility
const showMarkReview =
- canEditEngagements && (status === 'pending' || status === 'in_progress');
+ !isDone && canEditEngagements && (status === 'pending' || status === 'in_progress');
const showClose =
- (canEditEngagements || isSoc) && status === 'review_required';
+ !isDone && (canEditEngagements || isSoc) && status === 'review_required';
+ const showReopen = isDone && (isAdmin || isRedteam || isSoc);
const onSubmitNew = async (e: FormEvent) => {
e.preventDefault();
setNameError(null);
setSubmitError(null);
- if (!rt.name.trim()) {
- setNameError('Name is required');
- return;
- }
+ if (!rt.name.trim()) { setNameError('Name is required'); return; }
try {
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
push('Simulation created', 'success');
@@ -142,10 +141,7 @@ export function SimulationFormPage(): JSX.Element {
e.preventDefault();
setNameError(null);
setSubmitError(null);
- if (!rt.name.trim()) {
- setNameError('Name is required');
- return;
- }
+ if (!rt.name.trim()) { setNameError('Name is required'); return; }
const patch: SimulationPatchInput = {
name: rt.name.trim(),
description: rt.description.trim() || null,
@@ -197,6 +193,15 @@ export function SimulationFormPage(): JSX.Element {
}
};
+ const onReopen = async () => {
+ try {
+ await transitionMutation.mutateAsync('review_required');
+ push('Simulation reopened', 'success');
+ } catch (err) {
+ push(extractApiError(err, 'Transition failed'), 'error');
+ }
+ };
+
const onDelete = async () => {
setShowDeleteConfirm(false);
try {
@@ -208,7 +213,7 @@ export function SimulationFormPage(): JSX.Element {
}
};
- // New simulation form (minimal)
+ // New simulation form
if (isNew) {
const submitting = createMutation.isPending;
return (
@@ -232,9 +237,7 @@ export function SimulationFormPage(): JSX.Element {
{submitError ? (
-
- {submitError}
-
+ {submitError}
) : null}
@@ -250,7 +253,6 @@ export function SimulationFormPage(): JSX.Element {
);
}
- // Edit form
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
@@ -275,7 +277,17 @@ export function SimulationFormPage(): JSX.Element {
- {/* SOC banner — shown when soc user visits pending/in_progress */}
+ {/* Done banner */}
+ {isDone && (
+
+ This simulation is done and read-only. Use Reopen to make changes.
+
+ )}
+
+ {/* SOC banner */}
{socBlocked && (
|