diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f7a7ef..5d57bb4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.59.0", "axios": "^1.7.7", + "lucide-react": "^1.16.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" @@ -5083,6 +5084,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f77e24a..7eed3cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@tanstack/react-query": "^5.59.0", "axios": "^1.7.7", + "lucide-react": "^1.16.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6c73e3e..4a52217 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -78,11 +78,17 @@ export interface MitreTactic { techniques: MitreMatrixTechnique[]; } +export interface MitreTacticRef { + id: string; + name: string; +} + export interface Simulation { id: number; engagement_id: number; name: string; techniques: MitreTechnique[]; + tactics: MitreTacticRef[]; description: string | null; commands: string | null; prerequisites: string | null; @@ -105,6 +111,7 @@ export interface SimulationCreateInput { export interface SimulationPatchInput { name?: string; technique_ids?: string[]; + tactic_ids?: string[]; description?: string | null; commands?: string | null; prerequisites?: string | null; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ad82b05..0a1ed72 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,13 +1,25 @@ import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { Moon, Sun, Monitor } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; +import { useTheme } from '@/hooks/useTheme'; +import type { Theme } from '@/hooks/useTheme'; + +function ThemeIcon({ theme }: { theme: Theme }) { + if (theme === 'light') return ; + if (theme === 'dark') return ; + return ; +} + +function themeLabel(theme: Theme): string { + if (theme === 'light') return 'Light'; + if (theme === 'dark') return 'Dark'; + return 'System'; +} -/** - * Top utility strip (ink) + main nav (canvas). - * Mirrors DESIGN.md utility-strip + nav-bar-top pattern, scaled to internal app. - */ export function Layout(): JSX.Element { const { user, isAdmin, logout } = useAuth(); const navigate = useNavigate(); + const { theme, cycleTheme } = useTheme(); const handleLogout = async () => { await logout(); @@ -17,7 +29,7 @@ export function Layout(): JSX.Element { return (
{/* utility-strip — ink slab, fine print */} -
+
Mimic · Purple Team BAS {user ? ( @@ -26,6 +38,15 @@ export function Layout(): JSX.Element { {user.role} {user.username} + {/* Techniques */} -
+
{visibleTechniques.map((tech, techIdx) => { - const isSelected = selectedMap.has(tech.id); + const isSelected = selectedTechMap.has(tech.id); const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id); const hasSubtechniques = tech.subtechniques.length > 0; const isLast = techIdx === visibleTechniques.length - 1; - // Filter subtechniques when searching const visibleSubs = searchLower ? tech.subtechniques.filter( (s) => @@ -254,68 +268,67 @@ export function MitreMatrixModal({ return (
- {/* Technique row */}
- {/* Chevron — expand/collapse, does NOT toggle selection */} {hasSubtechniques ? ( ) : ( - + )} - {/* Label — click toggles selection */}
- {/* Subtechniques — shown when expanded */} {isExpanded && visibleSubs.map((sub) => { - const isSubSelected = selectedMap.has(sub.id); + const isSubSelected = selectedTechMap.has(sub.id); return ( ); })}
); })} + {visibleTechniques.length === 0 && searchLower && ( +
No match
+ )}
); @@ -333,11 +346,11 @@ export function MitreMatrixModal({ type="button" className="btn-primary" onClick={handleApply} - disabled={isLoading || isError || (totalSelected === 0 && initialSelection.length === 0)} + disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)} > {totalSelected === 0 ? 'Clear all' - : `Apply ${totalSelected} technique${totalSelected !== 1 ? 's' : ''}`} + : `Apply ${totalSelected} item${totalSelected !== 1 ? 's' : ''}`}
diff --git a/frontend/src/components/MitreTechniqueTag.tsx b/frontend/src/components/MitreTechniqueTag.tsx index a4d15be..7874057 100644 --- a/frontend/src/components/MitreTechniqueTag.tsx +++ b/frontend/src/components/MitreTechniqueTag.tsx @@ -1,29 +1,63 @@ -import type { MitreTechnique } from '@/api/types'; +import type { MitreTechnique, MitreTacticRef } from '@/api/types'; -interface MitreTechniqueTagProps { +interface TechniqueTagProps { technique: MitreTechnique; onRemove: () => void; disabled?: boolean; } +interface TacticTagProps { + tactic: MitreTacticRef; + onRemove: () => void; + disabled?: boolean; +} + +// Technique chip — soft blue, id only, name in title export function MitreTechniqueTag({ technique, onRemove, disabled = false, -}: MitreTechniqueTagProps): JSX.Element { +}: TechniqueTagProps): JSX.Element { return ( - {technique.id} - — {technique.name} + {technique.id} {!disabled && ( + )} + + ); +} + +// Tactic chip — primary blue filled, id only, name in title +export function MitreTacticTag({ + tactic, + onRemove, + disabled = false, +}: TacticTagProps): JSX.Element { + return ( + + {tactic.id} + {!disabled && ( + diff --git a/frontend/src/components/MitreTechniquesField.tsx b/frontend/src/components/MitreTechniquesField.tsx index b454fee..1c1cc09 100644 --- a/frontend/src/components/MitreTechniquesField.tsx +++ b/frontend/src/components/MitreTechniquesField.tsx @@ -1,14 +1,17 @@ import { useState } from 'react'; +import { Grid2x2 } from 'lucide-react'; import { extractApiError } from '@/api/client'; -import type { MitreTechnique } from '@/api/types'; +import type { MitreTechnique, MitreTacticRef } from '@/api/types'; import { useUpdateSimulation } from '@/hooks/useSimulations'; import { useToast } from '@/hooks/useToast'; -import { MitreTechniqueTag } from './MitreTechniqueTag'; +import { MitreTechniqueTag, MitreTacticTag } from './MitreTechniqueTag'; import { MitreTechniquePicker } from './MitreTechniquePicker'; import { MitreMatrixModal } from './MitreMatrixModal'; +import type { MatrixSelection } from './MitreMatrixModal'; interface MitreTechniquesFieldProps { value: MitreTechnique[]; + tactics: MitreTacticRef[]; simulationId: number; engagementId: number; disabled?: boolean; @@ -16,6 +19,7 @@ interface MitreTechniquesFieldProps { export function MitreTechniquesField({ value, + tactics, simulationId, engagementId, disabled = false, @@ -26,10 +30,11 @@ export function MitreTechniquesField({ const { push } = useToast(); const updateMutation = useUpdateSimulation(simulationId, engagementId); - const save = async (techniques: MitreTechnique[]) => { + const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => { try { await updateMutation.mutateAsync({ technique_ids: techniques.map((t) => t.id), + tactic_ids: nextTactics.map((t) => t.id), }); push('Techniques updated', 'success'); } catch (err) { @@ -37,96 +42,92 @@ export function MitreTechniquesField({ } }; - const handleRemove = (id: string) => { - const next = value.filter((t) => t.id !== id); - void save(next); + const handleRemoveTechnique = (id: string) => { + void save(value.filter((t) => t.id !== id), tactics); + }; + + const handleRemoveTactic = (id: string) => { + void save(value, tactics.filter((t) => t.id !== id)); }; const handleSelect = (technique: MitreTechnique) => { - // Dedup: no-op if already present if (value.some((t) => t.id === technique.id)) return; - const next = [...value, technique]; - void save(next); + void save([...value, technique], tactics); + setShowPicker(false); }; - const handleMatrixApply = (selection: MitreTechnique[]) => { + const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => { setShowMatrix(false); - // Merge: preserve existing tactics on items already in value, fill from selection otherwise. - // The backend re-enriches tactics at serialize time, so the exact tactics here don't matter. - const merged = selection.map((s) => { + const merged = techniques.map((s) => { const existing = value.find((v) => v.id === s.id); return existing ?? s; }); - void save(merged); + void save(merged, newTactics); }; const isPending = updateMutation.isPending; + const isEmpty = value.length === 0 && tactics.length === 0; return (
- {/* Tag list */} - {value.length === 0 ? ( -

- No techniques selected — use the matrix or the quick search to add. -

+ {/* Chips area */} + {isEmpty ? ( +

No techniques selected

) : ( -
+
+ {tactics.map((t) => ( + handleRemoveTactic(t.id)} + disabled={disabled || isPending} + /> + ))} {value.map((t) => ( handleRemove(t.id)} + onRemove={() => handleRemoveTechnique(t.id)} disabled={disabled || isPending} /> ))}
)} - {/* Action buttons — hidden in read-only mode */} + {/* Input row — hidden in read-only mode */} {!disabled && ( -
+
+
+ {showPicker ? ( + + ) : ( + + )} +
- - {isPending && ( - Saving… - )} + {isPending && Saving…}
)} - {/* Inline Quick Search picker */} - {showPicker && !disabled && ( -
- { - handleSelect(technique); - setShowPicker(false); - }} - disabled={isPending} - /> -
- )} - - {/* Matrix modal */} 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 && (