Files
mimic/frontend/src/pages/SimulationFormPage.tsx

515 lines
17 KiB
TypeScript
Raw Normal View History

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';
import { useToast } from '@/hooks/useToast';
import {
useCreateSimulation,
useDeleteSimulation,
useSimulation,
useTransitionSimulation,
useUpdateSimulation,
} from '@/hooks/useSimulations';
import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
interface RedteamFormState {
name: string;
description: string;
commands: string;
prerequisites: string;
executed_at: string;
execution_result: string;
}
interface SocFormState {
log_source: string;
logs: string;
soc_comment: string;
incident_number: string;
}
const EMPTY_RT: RedteamFormState = {
name: '',
description: '',
commands: '',
prerequisites: '',
executed_at: '',
execution_result: '',
};
const EMPTY_SOC: SocFormState = {
log_source: '',
logs: '',
soc_comment: '',
incident_number: '',
};
export function SimulationFormPage(): JSX.Element {
const { eid, sid } = useParams<{ eid: string; sid: string }>();
const engagementId = eid ? Number(eid) : undefined;
const simulationId = sid ? Number(sid) : undefined;
const isNew = !simulationId;
const navigate = useNavigate();
const { push } = useToast();
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0);
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
const deleteMutation = useDeleteSimulation(engagementId ?? 0);
const transitionMutation = useTransitionSimulation(simulationId ?? 0, engagementId ?? 0);
const [rt, setRt] = useState<RedteamFormState>(EMPTY_RT);
const [soc, setSoc] = useState<SocFormState>(EMPTY_SOC);
const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
if (!isNew && detail.data) {
const s = detail.data;
setRt({
name: s.name,
description: s.description ?? '',
commands: s.commands ?? '',
prerequisites: s.prerequisites ?? '',
executed_at: s.executed_at ? s.executed_at.slice(0, 16) : '',
execution_result: s.execution_result ?? '',
});
setSoc({
log_source: s.log_source ?? '',
logs: s.logs ?? '',
soc_comment: s.soc_comment ?? '',
incident_number: s.incident_number ?? '',
});
}
}, [isNew, detail.data]);
if (!isNew && detail.isLoading) return <LoadingState label="Loading simulation…" />;
if (!isNew && detail.isError) {
return (
<ErrorState
message={extractApiError(detail.error, 'Could not load simulation')}
onRetry={() => detail.refetch()}
/>
);
}
const simulation = detail.data;
const status = simulation?.status;
// US-18: Done = fully read-only, Reopen only
const isDone = status === 'done';
const canEditRT = isAdmin || isRedteam;
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
const rtDisabled = !canEditRT || isDone;
const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
const showMarkReview =
!isDone && canEditEngagements && (status === 'pending' || status === 'in_progress');
const showClose =
!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; }
try {
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
push('Simulation created', 'success');
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
} catch (err) {
setSubmitError(extractApiError(err, 'Could not create simulation'));
}
};
const onSaveRT = async (e: FormEvent) => {
e.preventDefault();
setNameError(null);
setSubmitError(null);
if (!rt.name.trim()) { setNameError('Name is required'); return; }
const patch: SimulationPatchInput = {
name: rt.name.trim(),
description: rt.description.trim() || null,
commands: rt.commands.trim() || null,
prerequisites: rt.prerequisites.trim() || null,
executed_at: rt.executed_at || null,
execution_result: rt.execution_result.trim() || null,
};
try {
await updateMutation.mutateAsync(patch);
push('Simulation updated', 'success');
} catch (err) {
setSubmitError(extractApiError(err, 'Could not update simulation'));
}
};
const onSaveSOC = async (e: FormEvent) => {
e.preventDefault();
setSubmitError(null);
const patch: SimulationPatchInput = {
log_source: soc.log_source.trim() || null,
logs: soc.logs.trim() || null,
soc_comment: soc.soc_comment.trim() || null,
incident_number: soc.incident_number.trim() || null,
};
try {
await updateMutation.mutateAsync(patch);
push('SOC report updated', 'success');
} catch (err) {
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
}
};
const onMarkReview = async () => {
try {
await transitionMutation.mutateAsync('review_required');
push('Simulation marked for review', 'success');
} catch (err) {
push(extractApiError(err, 'Transition failed'), 'error');
}
};
const onClose = async () => {
try {
await transitionMutation.mutateAsync('done');
push('Simulation closed', 'success');
} catch (err) {
push(extractApiError(err, 'Transition failed'), 'error');
}
};
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 {
await deleteMutation.mutateAsync(simulationId as number);
push('Simulation deleted', 'success');
navigate(`/engagements/${engagementId}`);
} catch (err) {
push(extractApiError(err, 'Could not delete simulation'), 'error');
}
};
// New simulation form
if (isNew) {
const submitting = createMutation.isPending;
return (
<div className="flex flex-col gap-xl max-w-2xl">
<header>
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement
</Link>
<h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1>
</header>
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
<TextInput
id="sim-name"
name="name"
value={rt.name}
onChange={(e) => setRt({ ...rt, name: e.target.value })}
required
/>
</FormField>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create simulation'}
</button>
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
Cancel
</Link>
</div>
</form>
</div>
);
}
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
return (
<div className="flex flex-col gap-xl max-w-3xl">
<header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm">
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement
</Link>
<h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? (
<div className="flex items-center gap-md">
<SimulationStatusBadge status={status} />
{simulation?.created_by && (
<span className="text-[14px] text-graphite">
Created by <span className="text-ink">{simulation.created_by.username}</span>
</span>
)}
</div>
) : null}
</div>
</header>
{/* Done banner */}
{isDone && (
<div
role="status"
className="rounded-none 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"
data-testid="soc-blocked-banner"
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
>
Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section.
</div>
)}
{/* Red Team card */}
<form
id="rt-form"
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">Red Team</h2>
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
<TextInput
id="sim-name"
name="name"
value={rt.name}
onChange={(e) => setRt({ ...rt, name: e.target.value })}
disabled={rtDisabled}
required
/>
</FormField>
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">MITRE Techniques &amp; Tactics</span>
<MitreTechniquesField
value={simulation?.techniques ?? []}
tactics={simulation?.tactics ?? []}
simulationId={simulationId as number}
engagementId={engagementId as number}
disabled={rtDisabled}
/>
</div>
<FormField label="Description" htmlFor="sim-description">
<TextArea
id="sim-description"
name="description"
value={rt.description}
onChange={(e) => setRt({ ...rt, description: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Commands" htmlFor="sim-commands" hint="One command per line">
<TextArea
id="sim-commands"
name="commands"
value={rt.commands}
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
disabled={rtDisabled}
className="min-h-[160px] font-mono text-[14px]"
/>
</FormField>
<FormField label="Prerequisites" htmlFor="sim-prerequisites">
<TextArea
id="sim-prerequisites"
name="prerequisites"
value={rt.prerequisites}
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Executed at" htmlFor="sim-executed-at">
<TextInput
id="sim-executed-at"
type="datetime-local"
name="executed_at"
value={rt.executed_at}
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Execution result" htmlFor="sim-exec-result">
<TextArea
id="sim-exec-result"
name="execution_result"
value={rt.execution_result}
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
disabled={rtDisabled}
rows={5}
/>
</FormField>
</form>
{/* SOC card */}
<form
id="soc-form"
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">SOC</h2>
<FormField label="Log source" htmlFor="sim-log-source">
<TextInput
id="sim-log-source"
name="log_source"
value={soc.log_source}
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Logs" htmlFor="sim-logs">
<TextArea
id="sim-logs"
name="logs"
value={soc.logs}
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="SOC comment" htmlFor="sim-soc-comment">
<TextArea
id="sim-soc-comment"
name="soc_comment"
value={soc.soc_comment}
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Incident number" htmlFor="sim-incident">
<TextInput
id="sim-incident"
name="incident_number"
value={soc.incident_number}
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })}
disabled={socDisabled}
/>
</FormField>
</form>
{submitError ? (
<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">
{/* 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>
)}
{/* Normal state buttons */}
{!isDone && canEditRT && (
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden />
{updateMutation.isPending ? 'Saving…' : 'Save'}
</button>
)}
{!isDone && canSaveSoc && (
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden />
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
</button>
)}
{showMarkReview && (
<button
type="button"
className="btn-outline"
onClick={onMarkReview}
disabled={transitionMutation.isPending}
>
Mark for review
</button>
)}
{showClose && (
<button
type="button"
className="btn-outline"
onClick={onClose}
disabled={transitionMutation.isPending}
>
Close
</button>
)}
{!isDone && canEditEngagements && simulationId && (
<button
type="button"
className="btn-text-link text-bloom-deep ml-auto"
onClick={() => setShowDeleteConfirm(true)}
disabled={submitting}
>
Delete
</button>
)}
</div>
{showDeleteConfirm && (
<ConfirmDialog
title="Delete simulation"
description="This action is permanent. The simulation will be deleted forever."
confirmLabel="Delete"
cancelLabel="Cancel"
destructive
onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}