feat(frontend): sprint 4 — dark mode + matrix overhaul + tactic selection + done read-only + UI polish
US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment US-18: done status fully read-only + Reopen button (done → review_required) for all roles US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=) US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence 92/92 tests passing, typecheck and lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
New engagement
|
||||
+ New
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
@@ -59,7 +59,7 @@ export function EngagementsListPage(): JSX.Element {
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
Create engagement
|
||||
+ New engagement
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
</FormField>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
</div>
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-md pt-sm">
|
||||
@@ -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 {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* SOC banner — shown when soc user visits pending/in_progress */}
|
||||
{/* Done banner */}
|
||||
{isDone && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-xl px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal"
|
||||
>
|
||||
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SOC banner */}
|
||||
{socBlocked && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -289,7 +301,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
{/* Red Team card */}
|
||||
<form
|
||||
id="rt-form"
|
||||
onSubmit={canEditRT ? onSaveRT : (e) => e.preventDefault()}
|
||||
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
@@ -307,9 +319,10 @@ export function SimulationFormPage(): JSX.Element {
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques</span>
|
||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques & Tactics</span>
|
||||
<MitreTechniquesField
|
||||
value={simulation?.techniques ?? []}
|
||||
tactics={simulation?.tactics ?? []}
|
||||
simulationId={simulationId as number}
|
||||
engagementId={engagementId as number}
|
||||
disabled={rtDisabled}
|
||||
@@ -326,11 +339,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Commands"
|
||||
htmlFor="sim-commands"
|
||||
hint="One command per line"
|
||||
>
|
||||
<FormField label="Commands" htmlFor="sim-commands" hint="One command per line">
|
||||
<TextArea
|
||||
id="sim-commands"
|
||||
name="commands"
|
||||
@@ -422,24 +431,38 @@ export function SimulationFormPage(): JSX.Element {
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
</form>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
</div>
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||
) : null}
|
||||
|
||||
{/* Unified sticky action bar */}
|
||||
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||
{canEditRT && (
|
||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
|
||||
{/* Done state: Reopen only */}
|
||||
{showReopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onReopen}
|
||||
disabled={transitionMutation.isPending}
|
||||
data-testid="reopen-btn"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden />
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
{canSaveSoc && (
|
||||
|
||||
{/* Normal state buttons */}
|
||||
{!isDone && canEditRT && (
|
||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{!isDone && canSaveSoc && (
|
||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
||||
</button>
|
||||
)}
|
||||
@@ -463,7 +486,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
{canEditEngagements && simulationId && (
|
||||
{!isDone && canEditEngagements && simulationId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep ml-auto"
|
||||
|
||||
@@ -110,7 +110,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
|
||||
<section className="card-product flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-start">
|
||||
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-end">
|
||||
<FormField label="Username" htmlFor="new-username" required>
|
||||
<TextInput
|
||||
id="new-username"
|
||||
@@ -137,7 +137,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="self-end">
|
||||
<div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user