sprint/2-simulations #5
@@ -44,7 +44,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
className="btn-primary"
|
||||
data-testid="new-simulation-btn"
|
||||
>
|
||||
Nouvelle simulation
|
||||
New simulation
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
className="btn-primary"
|
||||
data-testid="new-simulation-btn"
|
||||
>
|
||||
Nouvelle simulation
|
||||
New simulation
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
}
|
||||
try {
|
||||
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
|
||||
push('Simulation créée', 'success');
|
||||
push('Simulation created', 'success');
|
||||
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not create simulation'));
|
||||
@@ -164,7 +164,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync(patch);
|
||||
push('Simulation mise à jour', 'success');
|
||||
push('Simulation updated', 'success');
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not update simulation'));
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync(patch);
|
||||
push('Rapport SOC mis à jour', 'success');
|
||||
push('SOC report updated', 'success');
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
|
||||
}
|
||||
@@ -190,18 +190,18 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const onMarkReview = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('review_required');
|
||||
push('Simulation marquée en revue', 'success');
|
||||
push('Simulation marked for review', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Transition impossible'), 'error');
|
||||
push(extractApiError(err, 'Transition failed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('done');
|
||||
push('Simulation clôturée', 'success');
|
||||
push('Simulation closed', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Transition impossible'), 'error');
|
||||
push(extractApiError(err, 'Transition failed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,10 +209,10 @@ export function SimulationFormPage(): JSX.Element {
|
||||
setShowDeleteConfirm(false);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(simulationId as number);
|
||||
push('Simulation supprimée', 'success');
|
||||
push('Simulation deleted', 'success');
|
||||
navigate(`/engagements/${engagementId}`);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Suppression impossible'), 'error');
|
||||
push(extractApiError(err, 'Could not delete simulation'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,7 +225,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
|
||||
← Back to engagement
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none mt-sm">Nouvelle simulation</h1>
|
||||
<h1 className="text-[44px] font-medium leading-none mt-sm">New simulation</h1>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
|
||||
@@ -290,8 +290,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
data-testid="soc-blocked-banner"
|
||||
className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
|
||||
>
|
||||
Simulation pas encore en revue — la redteam doit la marquer comme "Review required" avant
|
||||
que vous puissiez intervenir.
|
||||
Simulation not yet ready for review — the red team must mark it as "Review required" before you can fill in the SOC section.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -361,36 +360,27 @@ export function SimulationFormPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<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="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">
|
||||
<TextInput
|
||||
id="sim-exec-result"
|
||||
name="execution_result"
|
||||
value={rt.execution_result}
|
||||
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{canEditRT && (
|
||||
<div className="flex items-center gap-md pt-sm border-t border-hairline">
|
||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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 */}
|
||||
@@ -442,13 +432,6 @@ export function SimulationFormPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{canSaveSoc && (
|
||||
<div className="flex items-center gap-md pt-sm border-t border-hairline">
|
||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{submitError ? (
|
||||
@@ -457,8 +440,18 @@ export function SimulationFormPage(): JSX.Element {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Workflow + delete footer */}
|
||||
<div className="flex items-center gap-md flex-wrap">
|
||||
{/* 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'}
|
||||
</button>
|
||||
)}
|
||||
{canSaveSoc && (
|
||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
||||
</button>
|
||||
)}
|
||||
{showMarkReview && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -466,7 +459,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
onClick={onMarkReview}
|
||||
disabled={transitionMutation.isPending}
|
||||
>
|
||||
Marquer en revue
|
||||
Mark for review
|
||||
</button>
|
||||
)}
|
||||
{showClose && (
|
||||
@@ -476,27 +469,27 @@ export function SimulationFormPage(): JSX.Element {
|
||||
onClick={onClose}
|
||||
disabled={transitionMutation.isPending}
|
||||
>
|
||||
Clôturer
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
{canEditEngagements && simulationId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep"
|
||||
className="btn-text-link text-bloom-deep ml-auto"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Supprimer
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title="Supprimer la simulation"
|
||||
description="Cette action est irréversible. La simulation sera définitivement supprimée."
|
||||
confirmLabel="Supprimer"
|
||||
cancelLabel="Annuler"
|
||||
title="Delete simulation"
|
||||
description="This action is permanent. The simulation will be deleted forever."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
|
||||
@@ -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-end">
|
||||
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-start">
|
||||
<FormField label="Username" htmlFor="new-username" required>
|
||||
<TextInput
|
||||
id="new-username"
|
||||
@@ -137,9 +137,11 @@ export function UsersAdminPage(): JSX.Element {
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
<div className="self-end">
|
||||
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{createError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
|
||||
@@ -95,54 +95,54 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows "Marquer en revue" button when status is pending', async () => {
|
||||
it('shows "Mark for review" button when status is pending', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show "Clôturer" when status is pending', async () => {
|
||||
it('does not show "Close" when status is pending', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => screen.getByRole('button', { name: /Marquer en revue/i }));
|
||||
expect(screen.queryByRole('button', { name: /Clôturer/i })).toBeNull();
|
||||
await waitFor(() => screen.getByRole('button', { name: /Mark for review/i }));
|
||||
expect(screen.queryByRole('button', { name: /^Close$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Marquer en revue" for in_progress status', async () => {
|
||||
it('shows "Mark for review" for in_progress status', async () => {
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Clôturer" button when status is review_required', async () => {
|
||||
it('shows "Close" button when status is review_required', async () => {
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Supprimer" button for redteam', async () => {
|
||||
it('shows "Delete" button for redteam', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Supprimer/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Delete$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -242,13 +242,13 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
||||
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Clôturer" for SOC when review_required', async () => {
|
||||
it('shows "Close" for SOC when review_required', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user