feat(frontend): i18n simulation pages (SimulationFormPage, SimulationList)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-06-21 23:31:21 +02:00
parent ea870af324
commit 5b93f880a3
3 changed files with 101 additions and 96 deletions

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { ChevronDown, Plus } from 'lucide-react'; import { ChevronDown, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { SimulationTemplate } from '@/api/types'; import type { SimulationTemplate } from '@/api/types';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations'; import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { formatDateTime } from '@/lib/format';
import { LoadingState } from './LoadingState'; import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState'; import { ErrorState } from './ErrorState';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
@@ -16,12 +18,8 @@ interface SimulationListProps {
engagementId: number; engagementId: number;
} }
function formatDate(value: string | null): string {
if (!value) return '—';
return value.replace('T', ' ').slice(0, 16);
}
function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element { function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { push } = useToast(); const { push } = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -61,10 +59,10 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
try { try {
const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id }); const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id });
setShowPicker(false); setShowPicker(false);
push(`Created "${sim.name}" from template`, 'success'); push(t('simulation.form.toast.created'), 'success');
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`); navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not create simulation from template'), 'error'); push(extractApiError(err, t('simulation.form.error.create')), 'error');
} }
}; };
@@ -77,7 +75,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
onClick={handleBlank} onClick={handleBlank}
data-testid="new-simulation-btn" data-testid="new-simulation-btn"
> >
<Plus size={14} aria-hidden /> New <Plus size={14} aria-hidden /> {t('simulation.list.new')}
</button> </button>
<button <button
type="button" type="button"
@@ -133,16 +131,17 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
} }
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element { export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
const { t } = useTranslation();
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId); const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
const { canEditEngagements } = useAuth(); const { canEditEngagements } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
if (isLoading) return <LoadingState label="Loading simulations…" />; if (isLoading) return <LoadingState label={t('common.loading')} />;
if (isError) { if (isError) {
return ( return (
<ErrorState <ErrorState
message={extractApiError(error, 'Could not load simulations')} message={extractApiError(error, t('simulation.list.error'))}
onRetry={() => refetch()} onRetry={() => refetch()}
/> />
); );
@@ -151,8 +150,8 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
<EmptyState <EmptyState
title="No simulations yet" title={t('simulation.list.empty.title')}
description="Create the first simulation to start tracking red team tests." description={t('simulation.list.empty.desc')}
action={ action={
canEditEngagements ? ( canEditEngagements ? (
<NewSimulationDropdown engagementId={engagementId} /> <NewSimulationDropdown engagementId={engagementId} />
@@ -165,7 +164,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
return ( return (
<div className="flex flex-col gap-md"> <div className="flex flex-col gap-md">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-[24px] font-medium text-ink">Simulations</h2> <h2 className="text-[24px] font-medium text-ink">{t('engagement.detail.tabs.simulations')}</h2>
{canEditEngagements ? ( {canEditEngagements ? (
<NewSimulationDropdown engagementId={engagementId} /> <NewSimulationDropdown engagementId={engagementId} />
) : null} ) : null}
@@ -175,10 +174,10 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
<table className="table-compact w-full text-left"> <table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline"> <thead className="bg-cloud border-b border-hairline">
<tr> <tr>
<th>Name</th> <th>{t('simulation.list.col.name')}</th>
<th>MITRE</th> <th>{t('simulation.list.col.mitre')}</th>
<th>Status</th> <th>{t('simulation.list.col.status')}</th>
<th>Executed at</th> <th>{t('simulation.list.col.executedAt')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -202,8 +201,8 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
<td className="text-charcoal font-mono"> <td className="text-charcoal font-mono">
{(() => { {(() => {
const items = [ const items = [
...(sim.tactics ?? []).map((t) => t.id), ...(sim.tactics ?? []).map((tactic) => tactic.id),
...sim.techniques.map((t) => t.id), ...sim.techniques.map((tech) => tech.id),
]; ];
if (items.length === 0) return '—'; if (items.length === 0) return '—';
if (items.length === 1) return items[0]; if (items.length === 1) return items[0];
@@ -214,7 +213,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
<SimulationStatusBadge status={sim.status} /> <SimulationStatusBadge status={sim.status} />
</td> </td>
<td className="text-charcoal font-mono"> <td className="text-charcoal font-mono">
{formatDate(sim.executed_at)} {sim.executed_at ? formatDateTime(sim.executed_at) : '—'}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { Save, RotateCcw } from 'lucide-react'; import { Save, RotateCcw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; 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';
@@ -58,6 +59,7 @@ const EMPTY_SOC: SocFormState = {
}; };
export function SimulationFormPage(): JSX.Element { export function SimulationFormPage(): JSX.Element {
const { t } = useTranslation();
const { eid, sid } = useParams<{ eid: string; sid: string }>(); const { eid, sid } = useParams<{ eid: string; sid: string }>();
const engagementId = eid ? Number(eid) : undefined; const engagementId = eid ? Number(eid) : undefined;
const simulationId = sid ? Number(sid) : undefined; const simulationId = sid ? Number(sid) : undefined;
@@ -115,11 +117,11 @@ export function SimulationFormPage(): JSX.Element {
} }
}, [isNew, detail.data]); }, [isNew, detail.data]);
if (!isNew && detail.isLoading) return <LoadingState label="Loading simulation…" />; if (!isNew && detail.isLoading) return <LoadingState label={t('simulation.form.loading')} />;
if (!isNew && detail.isError) { if (!isNew && detail.isError) {
return ( return (
<ErrorState <ErrorState
message={extractApiError(detail.error, 'Could not load simulation')} message={extractApiError(detail.error, t('simulation.form.error.load'))}
onRetry={() => detail.refetch()} onRetry={() => detail.refetch()}
/> />
); );
@@ -148,13 +150,13 @@ export function SimulationFormPage(): JSX.Element {
e.preventDefault(); e.preventDefault();
setNameError(null); setNameError(null);
setSubmitError(null); setSubmitError(null);
if (!rt.name.trim()) { setNameError('Name is required'); return; } if (!rt.name.trim()) { setNameError(t('simulation.form.validation.nameRequired')); return; }
try { try {
const created = await createMutation.mutateAsync({ name: rt.name.trim() }); const created = await createMutation.mutateAsync({ name: rt.name.trim() });
push('Simulation created', 'success'); push(t('simulation.form.toast.created'), 'success');
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`); navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not create simulation')); setSubmitError(extractApiError(err, t('simulation.form.error.create')));
} }
}; };
@@ -162,7 +164,7 @@ export function SimulationFormPage(): JSX.Element {
e.preventDefault(); e.preventDefault();
setNameError(null); setNameError(null);
setSubmitError(null); setSubmitError(null);
if (!rt.name.trim()) { setNameError('Name is required'); return; } if (!rt.name.trim()) { setNameError(t('simulation.form.validation.nameRequired')); return; }
const patch: SimulationPatchInput = { const patch: SimulationPatchInput = {
name: rt.name.trim(), name: rt.name.trim(),
description: rt.description.trim() || null, description: rt.description.trim() || null,
@@ -173,9 +175,9 @@ export function SimulationFormPage(): JSX.Element {
}; };
try { try {
await updateMutation.mutateAsync(patch); await updateMutation.mutateAsync(patch);
push('Simulation updated', 'success'); push(t('simulation.form.toast.updated'), 'success');
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not update simulation')); setSubmitError(extractApiError(err, t('simulation.form.error.update')));
} }
}; };
@@ -190,36 +192,36 @@ export function SimulationFormPage(): JSX.Element {
}; };
try { try {
await updateMutation.mutateAsync(patch); await updateMutation.mutateAsync(patch);
push('SOC report updated', 'success'); push(t('simulation.form.toast.socUpdated'), 'success');
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not update SOC fields')); setSubmitError(extractApiError(err, t('simulation.form.error.soc')));
} }
}; };
const onMarkReview = async () => { const onMarkReview = async () => {
try { try {
await transitionMutation.mutateAsync('review_required'); await transitionMutation.mutateAsync('review_required');
push('Simulation marked for review', 'success'); push(t('simulation.form.toast.markedReview'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Transition failed'), 'error'); push(extractApiError(err, t('simulation.form.error.transition')), 'error');
} }
}; };
const onClose = async () => { const onClose = async () => {
try { try {
await transitionMutation.mutateAsync('done'); await transitionMutation.mutateAsync('done');
push('Simulation closed', 'success'); push(t('simulation.form.toast.closed'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Transition failed'), 'error'); push(extractApiError(err, t('simulation.form.error.transition')), 'error');
} }
}; };
const onReopen = async () => { const onReopen = async () => {
try { try {
await transitionMutation.mutateAsync('review_required'); await transitionMutation.mutateAsync('review_required');
push('Simulation reopened', 'success'); push(t('simulation.form.toast.reopened'), 'success');
} catch (err) { } catch (err) {
push(extractApiError(err, 'Transition failed'), 'error'); push(extractApiError(err, t('simulation.form.error.transition')), 'error');
} }
}; };
@@ -227,10 +229,10 @@ export function SimulationFormPage(): JSX.Element {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
try { try {
await deleteMutation.mutateAsync(simulationId as number); await deleteMutation.mutateAsync(simulationId as number);
push('Simulation deleted', 'success'); push(t('simulation.form.toast.deleted'), 'success');
navigate(`/engagements/${engagementId}`); navigate(`/engagements/${engagementId}`);
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not delete simulation'), 'error'); push(extractApiError(err, t('simulation.form.error.delete')), 'error');
} }
}; };
@@ -240,12 +242,12 @@ export function SimulationFormPage(): JSX.Element {
return ( return (
<div className="flex flex-col gap-xl max-w-2xl"> <div className="flex flex-col gap-xl max-w-2xl">
<header> <header>
<BackLink to={`/engagements/${engagementId}`}>Back to engagement</BackLink> <BackLink to={`/engagements/${engagementId}`}>{t('engagement.detail.backTo')}</BackLink>
<h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1> <h1 className="text-[32px] font-medium leading-none mt-sm">{t('simulation.form.title.new')}</h1>
</header> </header>
<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="Name" htmlFor="sim-name" required error={nameError}> <FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
<TextInput <TextInput
id="sim-name" id="sim-name"
name="name" name="name"
@@ -260,11 +262,11 @@ export function SimulationFormPage(): JSX.Element {
) : null} ) : null}
<div className="flex items-center gap-md pt-sm"> <div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}> <button type="submit" className="btn-primary" disabled={submitting} data-testid="create-sim-btn">
{submitting ? 'Creating' : 'Create simulation'} {submitting ? t('simulation.form.btn.creating') : t('simulation.form.btn.create')}
</button> </button>
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink"> <Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
Cancel {t('simulation.form.btn.cancel')}
</Link> </Link>
</div> </div>
</form> </form>
@@ -279,14 +281,15 @@ export function SimulationFormPage(): JSX.Element {
<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">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<BackLink to={`/engagements/${engagementId}`}>Back to engagement</BackLink> <BackLink to={`/engagements/${engagementId}`}>{t('engagement.detail.backTo')}</BackLink>
<h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1> <h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? ( {status ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<SimulationStatusBadge status={status} /> <SimulationStatusBadge status={status} />
{simulation?.created_by && ( {simulation?.created_by && (
<span className="text-[14px] text-graphite"> <span className="text-[14px] text-graphite">
Created by <span className="text-ink">{simulation.created_by.username}</span> {t('engagement.detail.createdBy')}{' '}
<span className="text-ink">{simulation.created_by.username}</span>
</span> </span>
)} )}
</div> </div>
@@ -297,7 +300,7 @@ export function SimulationFormPage(): JSX.Element {
{/* Done banner */} {/* Done banner */}
{isDone && ( {isDone && (
<AlertBanner variant="success"> <AlertBanner variant="success">
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes. {t('simulation.form.banner.done')}
</AlertBanner> </AlertBanner>
)} )}
@@ -305,7 +308,7 @@ export function SimulationFormPage(): JSX.Element {
{socBlocked && ( {socBlocked && (
<div data-testid="soc-blocked-banner"> <div data-testid="soc-blocked-banner">
<AlertBanner variant="warn"> <AlertBanner variant="warn">
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. {t('simulation.form.banner.socNotReady')}
</AlertBanner> </AlertBanner>
</div> </div>
)} )}
@@ -321,9 +324,9 @@ export function SimulationFormPage(): JSX.Element {
noValidate noValidate
className="card-product flex flex-col gap-md" className="card-product flex flex-col gap-md"
> >
<h2 className="text-[20px] font-medium text-ink">Red Team</h2> <h2 className="text-[20px] font-medium text-ink">{t('simulation.form.header.redTeam')}</h2>
<FormField label="Name" htmlFor="sim-name" required error={nameError}> <FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
<TextInput <TextInput
id="sim-name" id="sim-name"
name="name" name="name"
@@ -335,7 +338,7 @@ export function SimulationFormPage(): JSX.Element {
</FormField> </FormField>
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">MITRE Techniques &amp; Tactics</span> <span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
<MitreTechniquesField <MitreTechniquesField
value={simulation?.techniques ?? []} value={simulation?.techniques ?? []}
tactics={simulation?.tactics ?? []} tactics={simulation?.tactics ?? []}
@@ -345,7 +348,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</div> </div>
<FormField label="Description" htmlFor="sim-description"> <FormField label={t('simulation.form.field.description')} htmlFor="sim-description">
<TextArea <TextArea
id="sim-description" id="sim-description"
name="description" name="description"
@@ -355,7 +358,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Commands" htmlFor="sim-commands" hint="One command per line"> <FormField label={t('simulation.form.field.commands')} htmlFor="sim-commands" hint={t('simulation.form.field.commandsHint')}>
<TextArea <TextArea
id="sim-commands" id="sim-commands"
name="commands" name="commands"
@@ -366,7 +369,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Prerequisites" htmlFor="sim-prerequisites"> <FormField label={t('simulation.form.field.prerequisites')} htmlFor="sim-prerequisites">
<TextArea <TextArea
id="sim-prerequisites" id="sim-prerequisites"
name="prerequisites" name="prerequisites"
@@ -376,7 +379,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Executed at" htmlFor="sim-executed-at"> <FormField label={t('simulation.form.field.executedAt')} htmlFor="sim-executed-at">
<TextInput <TextInput
id="sim-executed-at" id="sim-executed-at"
type="datetime-local" type="datetime-local"
@@ -387,7 +390,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Execution result" htmlFor="sim-exec-result"> <FormField label={t('simulation.form.field.executionResult')} htmlFor="sim-exec-result">
<TextArea <TextArea
id="sim-exec-result" id="sim-exec-result"
name="execution_result" name="execution_result"
@@ -406,7 +409,7 @@ export function SimulationFormPage(): JSX.Element {
className="btn-outline" className="btn-outline"
onClick={() => setShowC2Modal(true)} onClick={() => setShowC2Modal(true)}
> >
Execute via C2 {t('simulation.form.btn.executeC2')}
</button> </button>
<button <button
type="button" type="button"
@@ -414,7 +417,7 @@ export function SimulationFormPage(): JSX.Element {
className="btn-outline" className="btn-outline"
onClick={() => setShowImportModal(true)} onClick={() => setShowImportModal(true)}
> >
Import C2 history {t('simulation.form.btn.importC2History')}
</button> </button>
</div> </div>
)} )}
@@ -433,9 +436,9 @@ export function SimulationFormPage(): JSX.Element {
noValidate noValidate
className="card-product flex flex-col gap-md" className="card-product flex flex-col gap-md"
> >
<h2 className="text-[20px] font-medium text-ink">SOC</h2> <h2 className="text-[20px] font-medium text-ink">{t('simulation.form.header.soc')}</h2>
<FormField label="Log source" htmlFor="sim-log-source"> <FormField label={t('simulation.form.field.logSource')} htmlFor="sim-log-source">
<TextInput <TextInput
id="sim-log-source" id="sim-log-source"
name="log_source" name="log_source"
@@ -445,7 +448,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Logs" htmlFor="sim-logs"> <FormField label={t('simulation.form.field.logs')} htmlFor="sim-logs">
<TextArea <TextArea
id="sim-logs" id="sim-logs"
name="logs" name="logs"
@@ -455,7 +458,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="SOC comment" htmlFor="sim-soc-comment"> <FormField label={t('simulation.form.field.socComment')} htmlFor="sim-soc-comment">
<TextArea <TextArea
id="sim-soc-comment" id="sim-soc-comment"
name="soc_comment" name="soc_comment"
@@ -465,7 +468,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="Incident number" htmlFor="sim-incident"> <FormField label={t('simulation.form.field.incidentNumber')} htmlFor="sim-incident">
<TextInput <TextInput
id="sim-incident" id="sim-incident"
name="incident_number" name="incident_number"
@@ -493,7 +496,7 @@ export function SimulationFormPage(): JSX.Element {
data-testid="reopen-btn" data-testid="reopen-btn"
> >
<RotateCcw size={14} aria-hidden /> <RotateCcw size={14} aria-hidden />
Reopen {t('simulation.form.btn.reopen')}
</button> </button>
)} )}
@@ -501,13 +504,13 @@ export function SimulationFormPage(): JSX.Element {
{!isDone && canEditRT && ( {!isDone && canEditRT && (
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}> <button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden /> <Save size={14} aria-hidden />
{updateMutation.isPending ? 'Saving' : 'Save'} {updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.save')}
</button> </button>
)} )}
{!isDone && canSaveSoc && ( {!isDone && canSaveSoc && (
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}> <button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden /> <Save size={14} aria-hidden />
{updateMutation.isPending ? 'Saving' : 'Save SOC'} {updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.saveSoc')}
</button> </button>
)} )}
{showMarkReview && ( {showMarkReview && (
@@ -516,8 +519,9 @@ export function SimulationFormPage(): JSX.Element {
className="btn-outline" className="btn-outline"
onClick={onMarkReview} onClick={onMarkReview}
disabled={transitionMutation.isPending} disabled={transitionMutation.isPending}
data-testid="mark-review-btn"
> >
Mark for review {t('simulation.form.btn.markReview')}
</button> </button>
)} )}
{showClose && ( {showClose && (
@@ -526,8 +530,9 @@ export function SimulationFormPage(): JSX.Element {
className="btn-outline" className="btn-outline"
onClick={onClose} onClick={onClose}
disabled={transitionMutation.isPending} disabled={transitionMutation.isPending}
data-testid="close-btn"
> >
Close {t('simulation.form.btn.close')}
</button> </button>
)} )}
{!isDone && canEditEngagements && simulationId && ( {!isDone && canEditEngagements && simulationId && (
@@ -536,18 +541,19 @@ export function SimulationFormPage(): JSX.Element {
className="btn-text-link text-bloom-deep ml-auto" className="btn-text-link text-bloom-deep ml-auto"
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
disabled={submitting} disabled={submitting}
data-testid="delete-btn"
> >
Delete {t('simulation.form.btn.delete')}
</button> </button>
)} )}
</div> </div>
{showDeleteConfirm && ( {showDeleteConfirm && (
<ConfirmDialog <ConfirmDialog
title="Delete simulation" title={t('simulation.form.deleteConfirm.title')}
description="This action is permanent. The simulation will be deleted forever." description={t('simulation.form.deleteConfirm.desc')}
confirmLabel="Delete" confirmLabel={t('simulation.form.deleteConfirm.confirm')}
cancelLabel="Cancel" cancelLabel={t('simulation.form.deleteConfirm.cancel')}
destructive destructive
onConfirm={onDelete} onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}

View File

@@ -88,12 +88,12 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
}); });
expect(screen.getByLabelText(/Description/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Description/i)).not.toBeDisabled();
expect(screen.getByLabelText(/Commands/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Commandes/i)).not.toBeDisabled();
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Exécuté le/i)).not.toBeDisabled();
}); });
it('shows "Mark for review" button when status is pending', async () => { it('shows "Mark for review" button when status is pending', async () => {
@@ -102,7 +102,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument(); expect(screen.getByTestId('mark-review-btn')).toBeInTheDocument();
}); });
}); });
@@ -111,8 +111,8 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => screen.getByRole('button', { name: /Mark for review/i })); await waitFor(() => screen.getByTestId('mark-review-btn'));
expect(screen.queryByRole('button', { name: /^Close$/i })).toBeNull(); expect(screen.queryByTestId('close-btn')).toBeNull();
}); });
it('shows "Mark for review" for in_progress status', async () => { it('shows "Mark for review" for in_progress status', async () => {
@@ -122,7 +122,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument(); expect(screen.getByTestId('mark-review-btn')).toBeInTheDocument();
}); });
}); });
@@ -133,7 +133,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument(); expect(screen.getByTestId('close-btn')).toBeInTheDocument();
}); });
}); });
@@ -143,7 +143,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /^Delete$/i })).toBeInTheDocument(); expect(screen.getByTestId('delete-btn')).toBeInTheDocument();
}); });
}); });
}); });
@@ -179,10 +179,10 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).toBeDisabled(); expect(screen.getByLabelText(/^Source de log/i)).toBeDisabled();
}); });
expect(screen.getByLabelText(/Incident number/i)).toBeDisabled(); expect(screen.getByLabelText(/^Numéro d'incident/i)).toBeDisabled();
}); });
it('Red Team inputs are disabled for SOC', async () => { it('Red Team inputs are disabled for SOC', async () => {
@@ -191,10 +191,10 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).toBeDisabled();
}); });
expect(screen.getByLabelText(/Description/i)).toBeDisabled(); expect(screen.getByLabelText(/^Description/i)).toBeDisabled();
}); });
}); });
@@ -218,10 +218,10 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled();
}); });
expect(screen.getByLabelText(/Incident number/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Numéro d'incident/i)).not.toBeDisabled();
}); });
it('Red Team inputs remain disabled for SOC even when review_required', async () => { it('Red Team inputs remain disabled for SOC even when review_required', async () => {
@@ -230,7 +230,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).toBeDisabled();
}); });
}); });
@@ -240,7 +240,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled();
}); });
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull(); expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
@@ -252,7 +252,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument(); expect(screen.getByTestId('close-btn')).toBeInTheDocument();
}); });
}); });
}); });
@@ -273,8 +273,8 @@ describe('SimulationFormPage — new simulation', () => {
renderWithProviders(<NewPage />, { renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/new'] }, routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
}); });
expect(screen.getByLabelText(/^Name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument(); expect(screen.getByTestId('create-sim-btn')).toBeInTheDocument();
}); });
}); });
@@ -311,7 +311,7 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
}); });
expect(screen.queryByTestId('c2-execute-btn')).toBeNull(); expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
}); });
@@ -369,7 +369,7 @@ describe('SimulationFormPage — C2 tasks panel visibility', () => {
}); });
// Wait for page data to load then confirm no panel // Wait for page data to load then confirm no panel
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
}); });
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull(); expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
}); });