feat(frontend): full FR i18n via react-i18next + 2-tab engagement detail (sprint 12) #14
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 "Review required" 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 & 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)}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user