feat(frontend): full FR i18n via react-i18next + 2-tab engagement detail (sprint 12) #14
@@ -1,3 +1,4 @@
|
|||||||
|
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 { useTemplates } from '@/hooks/useTemplates';
|
import { useTemplates } from '@/hooks/useTemplates';
|
||||||
@@ -23,6 +24,7 @@ export function TemplatePickerModal({
|
|||||||
onSelectTemplate,
|
onSelectTemplate,
|
||||||
isPending = false,
|
isPending = false,
|
||||||
}: TemplatePickerModalProps): JSX.Element {
|
}: TemplatePickerModalProps): JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError, error, refetch } = useTemplates();
|
const { data, isLoading, isError, error, refetch } = useTemplates();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,11 +39,11 @@ export function TemplatePickerModal({
|
|||||||
<div className="relative card-product max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden">
|
<div className="relative card-product max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink">
|
<h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink">
|
||||||
From template…
|
{t('template.picker.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-graphite hover:text-ink text-[20px] leading-none"
|
className="text-graphite hover:text-ink text-[20px] leading-none"
|
||||||
>
|
>
|
||||||
@@ -50,19 +52,18 @@ export function TemplatePickerModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 -mx-xl px-xl">
|
<div className="overflow-y-auto flex-1 -mx-xl px-xl">
|
||||||
{isLoading ? <LoadingState label="Loading templates…" /> : null}
|
{isLoading ? <LoadingState label={t('template.picker.loading')} /> : null}
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={extractApiError(error, 'Could not load templates')}
|
message={extractApiError(error, t('template.form.errorLoad'))}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isLoading && !isError && data && data.length === 0 ? (
|
{!isLoading && !isError && data && data.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No templates available"
|
title={t('template.picker.empty')}
|
||||||
description="Create one from the Templates page."
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -70,22 +71,22 @@ export function TemplatePickerModal({
|
|||||||
<table className="w-full text-left" data-testid="template-picker-table">
|
<table className="w-full text-left" data-testid="template-picker-table">
|
||||||
<thead className="bg-cloud border-b border-hairline">
|
<thead className="bg-cloud border-b border-hairline">
|
||||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||||
<th className="px-md py-sm">Name</th>
|
<th className="px-md py-sm">{t('template.list.col.name')}</th>
|
||||||
<th className="px-md py-sm">MITRE</th>
|
<th className="px-md py-sm">{t('simulation.list.col.mitre')}</th>
|
||||||
<th className="px-md py-sm">Created by</th>
|
<th className="px-md py-sm">{t('template.list.col.createdBy')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((t) => (
|
{data.map((template) => (
|
||||||
<tr
|
<tr
|
||||||
key={t.id}
|
key={template.id}
|
||||||
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
|
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
|
||||||
onClick={() => !isPending && onSelectTemplate(t)}
|
onClick={() => !isPending && onSelectTemplate(template)}
|
||||||
data-testid={`template-row-${t.id}`}
|
data-testid={`template-row-${template.id}`}
|
||||||
>
|
>
|
||||||
<td className="px-md py-sm text-ink font-medium">{t.name}</td>
|
<td className="px-md py-sm text-ink font-medium">{template.name}</td>
|
||||||
<td className="px-md py-sm text-charcoal text-[14px]">{mitreCount(t)}</td>
|
<td className="px-md py-sm text-charcoal text-[14px]">{mitreCount(template)}</td>
|
||||||
<td className="px-md py-sm text-charcoal text-[14px]">{t.created_by.username}</td>
|
<td className="px-md py-sm text-charcoal text-[14px]">{template.created_by.username}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -95,7 +96,7 @@ export function TemplatePickerModal({
|
|||||||
|
|
||||||
<div className="border-t border-hairline pt-sm">
|
<div className="border-t border-hairline pt-sm">
|
||||||
<button type="button" className="btn-outline-ink" onClick={onClose}>
|
<button type="button" className="btn-outline-ink" onClick={onClose}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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, Grid2x2 } from 'lucide-react';
|
import { Save, Grid2x2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { extractApiError } from '@/api/client';
|
import { extractApiError } from '@/api/client';
|
||||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
@@ -25,6 +26,7 @@ interface FormState {
|
|||||||
const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' };
|
const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' };
|
||||||
|
|
||||||
export function TemplateFormPage(): JSX.Element {
|
export function TemplateFormPage(): JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const templateId = id ? Number(id) : undefined;
|
const templateId = id ? Number(id) : undefined;
|
||||||
const isNew = !templateId;
|
const isNew = !templateId;
|
||||||
@@ -47,15 +49,15 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing.data) {
|
if (existing.data) {
|
||||||
const t = existing.data;
|
const tpl = existing.data;
|
||||||
setForm({
|
setForm({
|
||||||
name: t.name,
|
name: tpl.name,
|
||||||
description: t.description ?? '',
|
description: tpl.description ?? '',
|
||||||
commands: t.commands ?? '',
|
commands: tpl.commands ?? '',
|
||||||
prerequisites: t.prerequisites ?? '',
|
prerequisites: tpl.prerequisites ?? '',
|
||||||
});
|
});
|
||||||
setTechniques(t.techniques);
|
setTechniques(tpl.techniques);
|
||||||
setTactics(t.tactics);
|
setTactics(tpl.tactics);
|
||||||
}
|
}
|
||||||
}, [existing.data]);
|
}, [existing.data]);
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
setFormError('Name is required');
|
setFormError(t('template.form.validation.nameRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -73,20 +75,20 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
commands: form.commands.trim() || null,
|
commands: form.commands.trim() || null,
|
||||||
prerequisites: form.prerequisites.trim() || null,
|
prerequisites: form.prerequisites.trim() || null,
|
||||||
technique_ids: techniques.map((t) => t.id),
|
technique_ids: techniques.map((tech) => tech.id),
|
||||||
tactic_ids: tactics.map((t) => t.id),
|
tactic_ids: tactics.map((tac) => tac.id),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const created = await createMutation.mutateAsync(payload);
|
const created = await createMutation.mutateAsync(payload);
|
||||||
push('Template created', 'success');
|
push(t('template.form.toast.created'), 'success');
|
||||||
navigate(`/admin/templates/${created.id}/edit`, { replace: true });
|
navigate(`/admin/templates/${created.id}/edit`, { replace: true });
|
||||||
} else {
|
} else {
|
||||||
await updateMutation.mutateAsync(payload);
|
await updateMutation.mutateAsync(payload);
|
||||||
push('Template saved', 'success');
|
push(t('template.form.toast.saved'), 'success');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFormError(extractApiError(err, 'Could not save template'));
|
setFormError(extractApiError(err, t('template.form.errorSave')));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,10 +96,10 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
if (!templateId) return;
|
if (!templateId) return;
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(templateId);
|
await deleteMutation.mutateAsync(templateId);
|
||||||
push('Template deleted', 'success');
|
push(t('template.form.toast.deleted'), 'success');
|
||||||
navigate('/admin/templates', { replace: true });
|
navigate('/admin/templates', { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Could not delete template'), 'error');
|
push(extractApiError(err, t('template.form.errorDelete')), 'error');
|
||||||
}
|
}
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
};
|
};
|
||||||
@@ -109,16 +111,16 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePickerSelect = (technique: MitreTechnique) => {
|
const handlePickerSelect = (technique: MitreTechnique) => {
|
||||||
if (techniques.some((t) => t.id === technique.id)) return;
|
if (techniques.some((tech) => tech.id === technique.id)) return;
|
||||||
setTechniques((prev) => [...prev, technique]);
|
setTechniques((prev) => [...prev, technique]);
|
||||||
setShowPicker(false);
|
setShowPicker(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isNew && existing.isLoading) return <LoadingState label="Loading template…" />;
|
if (!isNew && existing.isLoading) return <LoadingState label={t('template.form.loading')} />;
|
||||||
if (!isNew && existing.isError) {
|
if (!isNew && existing.isError) {
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={extractApiError(existing.error, 'Could not load template')}
|
message={extractApiError(existing.error, t('template.form.errorLoad'))}
|
||||||
onRetry={() => existing.refetch()}
|
onRetry={() => existing.refetch()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -128,9 +130,9 @@ export function TemplateFormPage(): 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="/admin/templates">Back to templates</BackLink>
|
<BackLink to="/admin/templates">{t('engagement.detail.backTo')}</BackLink>
|
||||||
<h1 className="text-[32px] font-medium leading-none">
|
<h1 className="text-[32px] font-medium leading-none">
|
||||||
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}
|
{isNew ? t('template.form.title.new') : (existing.data?.name ?? t('template.form.title.edit'))}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{!isNew ? (
|
{!isNew ? (
|
||||||
@@ -140,13 +142,13 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
Delete
|
{t('template.form.btn.delete')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="card-product flex flex-col gap-lg max-w-2xl">
|
<form onSubmit={onSubmit} className="card-product flex flex-col gap-lg max-w-2xl">
|
||||||
<FormField label="Name" htmlFor="tpl-name" required error={formError}>
|
<FormField label={t('template.form.field.name')} htmlFor="tpl-name" required error={formError}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="tpl-name"
|
id="tpl-name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
@@ -155,56 +157,56 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Description" htmlFor="tpl-desc">
|
<FormField label={t('template.form.field.description')} htmlFor="tpl-desc">
|
||||||
<TextArea
|
<TextArea
|
||||||
id="tpl-desc"
|
id="tpl-desc"
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="What does this simulation cover?"
|
placeholder={t('template.form.field.descriptionPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Commands" htmlFor="tpl-commands" hint="One command per line">
|
<FormField label={t('template.form.field.commands')} htmlFor="tpl-commands" hint={t('template.form.field.commandsHint')}>
|
||||||
<TextArea
|
<TextArea
|
||||||
id="tpl-commands"
|
id="tpl-commands"
|
||||||
value={form.commands}
|
value={form.commands}
|
||||||
onChange={(e) => setForm({ ...form, commands: e.target.value })}
|
onChange={(e) => setForm({ ...form, commands: e.target.value })}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="e.g. mimikatz.exe sekurlsa::logonpasswords"
|
placeholder={t('template.form.field.commandsPlaceholder')}
|
||||||
className="font-mono text-[14px]"
|
className="font-mono text-[14px]"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Prerequisites" htmlFor="tpl-prereqs">
|
<FormField label={t('template.form.field.prerequisites')} htmlFor="tpl-prereqs">
|
||||||
<TextArea
|
<TextArea
|
||||||
id="tpl-prereqs"
|
id="tpl-prereqs"
|
||||||
value={form.prerequisites}
|
value={form.prerequisites}
|
||||||
onChange={(e) => setForm({ ...form, prerequisites: e.target.value })}
|
onChange={(e) => setForm({ ...form, prerequisites: e.target.value })}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="e.g. Local admin access required"
|
placeholder={t('template.form.field.prerequisitesPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex flex-col gap-sm">
|
<div className="flex flex-col gap-sm">
|
||||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques & Tactics</span>
|
<span className="text-[14px] font-medium text-ink">{t('template.form.field.mitre')}</span>
|
||||||
|
|
||||||
{techniques.length === 0 && tactics.length === 0 ? (
|
{techniques.length === 0 && tactics.length === 0 ? (
|
||||||
<p className="text-[13px] text-graphite">No techniques selected</p>
|
<p className="text-[13px] text-graphite">{t('template.form.field.mitreEmpty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
|
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
|
||||||
{tactics.map((t) => (
|
{tactics.map((tac) => (
|
||||||
<MitreTacticTag
|
<MitreTacticTag
|
||||||
key={t.id}
|
key={tac.id}
|
||||||
tactic={t}
|
tactic={tac}
|
||||||
onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== t.id))}
|
onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== tac.id))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{techniques.map((t) => (
|
{techniques.map((tech) => (
|
||||||
<MitreTechniqueTag
|
<MitreTechniqueTag
|
||||||
key={t.id}
|
key={tech.id}
|
||||||
technique={t}
|
technique={tech}
|
||||||
onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== t.id))}
|
onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== tech.id))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,13 +222,13 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
|
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
|
||||||
onClick={() => setShowPicker(true)}
|
onClick={() => setShowPicker(true)}
|
||||||
>
|
>
|
||||||
Search technique (e.g. T1059)…
|
{t('template.form.field.mitreSearch')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Open MITRE matrix"
|
aria-label={t('template.form.field.mitreOpenMatrix')}
|
||||||
onClick={() => { setShowPicker(false); setShowMatrix(true); }}
|
onClick={() => { setShowPicker(false); setShowMatrix(true); }}
|
||||||
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink"
|
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink"
|
||||||
>
|
>
|
||||||
@@ -237,10 +239,10 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
|
|
||||||
<div className="flex items-center gap-md pt-xs border-t border-hairline">
|
<div className="flex items-center gap-md pt-xs border-t border-hairline">
|
||||||
<button type="submit" className="btn-primary" disabled={isPending}>
|
<button type="submit" className="btn-primary" disabled={isPending}>
|
||||||
<Save size={14} aria-hidden /> {isPending ? 'Saving…' : 'Save'}
|
<Save size={14} aria-hidden /> {isPending ? t('template.form.btn.saving') : t('template.form.btn.save')}
|
||||||
</button>
|
</button>
|
||||||
<Link to="/admin/templates" className="btn-outline-ink">
|
<Link to="/admin/templates" className="btn-outline-ink">
|
||||||
Cancel
|
{t('template.form.btn.cancel')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -255,9 +257,10 @@ export function TemplateFormPage(): JSX.Element {
|
|||||||
|
|
||||||
{showDeleteConfirm ? (
|
{showDeleteConfirm ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete template"
|
title={t('template.form.deleteConfirm.title')}
|
||||||
description={`Delete "${existing.data?.name ?? 'this template'}"? This cannot be undone. Simulations already created from it are unaffected.`}
|
description={t('template.form.deleteConfirm.desc')}
|
||||||
confirmLabel="Delete"
|
confirmLabel={t('template.form.deleteConfirm.confirm')}
|
||||||
|
cancelLabel={t('template.form.deleteConfirm.cancel')}
|
||||||
onConfirm={onDelete}
|
onConfirm={onDelete}
|
||||||
onCancel={() => setShowDeleteConfirm(false)}
|
onCancel={() => setShowDeleteConfirm(false)}
|
||||||
destructive
|
destructive
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Plus } from 'lucide-react';
|
import { 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 { useDeleteTemplate, useTemplates } from '@/hooks/useTemplates';
|
import { useDeleteTemplate, useTemplates } from '@/hooks/useTemplates';
|
||||||
@@ -7,28 +8,25 @@ import { useToast } from '@/hooks/useToast';
|
|||||||
import { LoadingState } from '@/components/LoadingState';
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
import { ErrorState } from '@/components/ErrorState';
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
import { EmptyState } from '@/components/EmptyState';
|
import { EmptyState } from '@/components/EmptyState';
|
||||||
|
import { formatDate } from '@/lib/format';
|
||||||
|
|
||||||
function mitreCount(t: SimulationTemplate): number {
|
function mitreCount(t: SimulationTemplate): number {
|
||||||
return t.techniques.length + t.tactics.length;
|
return t.techniques.length + t.tactics.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | null): string {
|
|
||||||
if (!value) return '—';
|
|
||||||
return value.slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplatesListPage(): JSX.Element {
|
export function TemplatesListPage(): JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError, error, refetch } = useTemplates();
|
const { data, isLoading, isError, error, refetch } = useTemplates();
|
||||||
const deleteMutation = useDeleteTemplate();
|
const deleteMutation = useDeleteTemplate();
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
|
|
||||||
const onDelete = async (t: SimulationTemplate) => {
|
const onDelete = async (template: SimulationTemplate) => {
|
||||||
if (!window.confirm(`Delete template "${t.name}"? This cannot be undone.`)) return;
|
if (!window.confirm(`${t('template.list.title')} « ${template.name} » ?`)) return;
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(t.id);
|
await deleteMutation.mutateAsync(template.id);
|
||||||
push('Template deleted', 'success');
|
push(t('template.form.toast.deleted'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Could not delete template'), 'error');
|
push(extractApiError(err, t('template.form.errorDelete')), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,32 +34,32 @@ export function TemplatesListPage(): JSX.Element {
|
|||||||
<div className="flex flex-col gap-xl">
|
<div className="flex flex-col gap-xl">
|
||||||
<header className="flex items-end justify-between gap-md">
|
<header className="flex items-end justify-between gap-md">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[32px] font-medium leading-none">Templates</h1>
|
<h1 className="text-[32px] font-medium leading-none">{t('template.list.title')}</h1>
|
||||||
<p className="text-charcoal text-[16px] mt-sm">
|
<p className="text-charcoal text-[16px] mt-sm">
|
||||||
Reusable simulation blueprints for red team operations.
|
{t('template.list.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/admin/templates/new" className="btn-primary">
|
<Link to="/admin/templates/new" className="btn-primary">
|
||||||
<Plus size={14} aria-hidden /> New
|
<Plus size={14} aria-hidden /> {t('template.list.new')}
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isLoading ? <LoadingState label="Loading templates…" /> : null}
|
{isLoading ? <LoadingState label={t('common.loading')} /> : null}
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={extractApiError(error, 'Could not load templates')}
|
message={extractApiError(error, t('template.form.errorLoad'))}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isLoading && !isError && data && data.length === 0 ? (
|
{!isLoading && !isError && data && data.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No templates yet"
|
title={t('template.list.empty.title')}
|
||||||
description="Create your first template to speed up simulation setup."
|
description={t('template.list.empty.desc')}
|
||||||
action={
|
action={
|
||||||
<Link to="/admin/templates/new" className="btn-primary">
|
<Link to="/admin/templates/new" className="btn-primary">
|
||||||
<Plus size={14} aria-hidden /> New template
|
<Plus size={14} aria-hidden /> {t('template.list.new')}
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -72,41 +70,41 @@ export function TemplatesListPage(): JSX.Element {
|
|||||||
<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('template.list.col.name')}</th>
|
||||||
<th>MITRE</th>
|
<th>{t('template.list.col.mitre')}</th>
|
||||||
<th>Created by</th>
|
<th>{t('template.list.col.createdBy')}</th>
|
||||||
<th>Updated</th>
|
<th>{t('template.list.col.updated')}</th>
|
||||||
<th className="text-right">Actions</th>
|
<th className="text-right">{t('engagement.list.col.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((t) => (
|
{data.map((template) => (
|
||||||
<tr key={t.id}>
|
<tr key={template.id}>
|
||||||
<td>
|
<td>
|
||||||
<Link
|
<Link
|
||||||
to={`/admin/templates/${t.id}/edit`}
|
to={`/admin/templates/${template.id}/edit`}
|
||||||
className="text-ink font-medium hover:underline"
|
className="text-ink font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{t.name}
|
{template.name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-charcoal">
|
<td className="text-charcoal">
|
||||||
{mitreCount(t) === 0 ? '—' : mitreCount(t)}
|
{mitreCount(template) === 0 ? '—' : mitreCount(template)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-charcoal">{t.created_by.username}</td>
|
<td className="text-charcoal">{template.created_by.username}</td>
|
||||||
<td className="text-charcoal font-mono">{formatDate(t.updated_at)}</td>
|
<td className="text-charcoal font-mono">{formatDate(template.updated_at)}</td>
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<div className="inline-flex gap-sm">
|
<div className="inline-flex gap-sm">
|
||||||
<Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link">
|
<Link to={`/admin/templates/${template.id}/edit`} className="btn-text-link">
|
||||||
Edit
|
{t('template.list.edit')}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-text-link text-bloom-deep"
|
className="btn-text-link text-bloom-deep"
|
||||||
onClick={() => onDelete(t)}
|
onClick={() => onDelete(template)}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
Delete
|
{t('template.list.delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -92,22 +92,20 @@ describe('TemplateFormPage — new mode', () => {
|
|||||||
|
|
||||||
it('renders the form with name field in empty state', () => {
|
it('renders the form with name field in empty state', () => {
|
||||||
renderNew();
|
renderNew();
|
||||||
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/Description/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/^Description/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/Commands/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/^Commandes/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/Prerequisites/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/^Prérequis/i)).toBeInTheDocument();
|
||||||
// All inputs should be empty
|
expect(screen.getByLabelText(/^Nom/i)).toHaveValue('');
|
||||||
expect(screen.getByLabelText(/Name/i)).toHaveValue('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows validation error when name is empty on submit', async () => {
|
it('shows validation error when name is empty on submit', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderNew();
|
renderNew();
|
||||||
// Name field is empty by default — click Save directly
|
const saveBtn = screen.getByRole('button', { name: /Enregistrer/i });
|
||||||
const saveBtn = screen.getByRole('button', { name: /Save/i });
|
|
||||||
await user.click(saveBtn);
|
await user.click(saveBtn);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
expect(screen.getByText('Le nom est obligatoire')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,8 +113,8 @@ describe('TemplateFormPage — new mode', () => {
|
|||||||
mock.onPost('/templates').reply(201, { ...TEMPLATE, id: 99 });
|
mock.onPost('/templates').reply(201, { ...TEMPLATE, id: 99 });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderNew();
|
renderNew();
|
||||||
await user.type(screen.getByLabelText(/Name/i), 'My Template');
|
await user.type(screen.getByLabelText(/^Nom/i), 'My Template');
|
||||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mock.history.post.length).toBe(1);
|
expect(mock.history.post.length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -128,8 +126,8 @@ describe('TemplateFormPage — new mode', () => {
|
|||||||
mock.onPost('/templates').reply(409, { error: 'template name already exists' });
|
mock.onPost('/templates').reply(409, { error: 'template name already exists' });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderNew();
|
renderNew();
|
||||||
await user.type(screen.getByLabelText(/Name/i), 'Duplicate');
|
await user.type(screen.getByLabelText(/^Nom/i), 'Duplicate');
|
||||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('template name already exists')).toBeInTheDocument();
|
expect(screen.getByText('template name already exists')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -137,7 +135,7 @@ describe('TemplateFormPage — new mode', () => {
|
|||||||
|
|
||||||
it('does not show Delete button in new mode', () => {
|
it('does not show Delete button in new mode', () => {
|
||||||
renderNew();
|
renderNew();
|
||||||
expect(screen.queryByText('Delete')).toBeNull();
|
expect(screen.queryByText('Supprimer')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,7 +174,7 @@ describe('TemplateFormPage — edit mode', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
expect(screen.getByText('Supprimer')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits PATCH on save', async () => {
|
it('submits PATCH on save', async () => {
|
||||||
@@ -187,7 +185,7 @@ describe('TemplateFormPage — edit mode', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
await user.click(screen.getByRole('button', { name: /Enregistrer/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mock.history.patch.length).toBe(1);
|
expect(mock.history.patch.length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -201,14 +199,14 @@ describe('TemplateFormPage — edit mode', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderEdit(5);
|
renderEdit(5);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
expect(screen.getByText('Supprimer')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
await user.click(screen.getByText('Delete'));
|
await user.click(screen.getByText('Supprimer'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
// Click the Delete button inside the dialog
|
// Click the Supprimer button inside the dialog
|
||||||
const dialogDeleteBtn = screen.getAllByText('Delete').find(
|
const dialogDeleteBtn = screen.getAllByText('Supprimer').find(
|
||||||
(el) => el.tagName === 'BUTTON' && el.closest('[role="dialog"]')
|
(el) => el.tagName === 'BUTTON' && el.closest('[role="dialog"]')
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
await user.click(dialogDeleteBtn);
|
await user.click(dialogDeleteBtn);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe('TemplatePickerModal', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText(/No templates available/i)).toBeInTheDocument();
|
expect(screen.getByText(/Aucun template disponible/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists templates with name and MITRE count', async () => {
|
it('lists templates with name and MITRE count', async () => {
|
||||||
@@ -128,9 +128,9 @@ describe('TemplatePickerModal', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
expect(screen.getByText('Annuler')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
await user.click(screen.getByText('Cancel'));
|
await user.click(screen.getByText('Annuler'));
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe('TemplatesListPage', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getAllByText(/New/i).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/Nouveau/i).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Edit and Delete actions', async () => {
|
it('shows Edit and Delete actions', async () => {
|
||||||
@@ -109,8 +109,8 @@ describe('TemplatesListPage', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getAllByText('Edit').length).toBe(2);
|
expect(screen.getAllByText('Modifier').length).toBe(2);
|
||||||
expect(screen.getAllByText('Delete').length).toBe(2);
|
expect(screen.getAllByText('Supprimer').length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls delete endpoint on confirm', async () => {
|
it('calls delete endpoint on confirm', async () => {
|
||||||
@@ -124,10 +124,10 @@ describe('TemplatesListPage', () => {
|
|||||||
|
|
||||||
renderWithProviders(<TemplatesListPage />);
|
renderWithProviders(<TemplatesListPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Supprimer')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteButtons = screen.getAllByText('Delete');
|
const deleteButtons = screen.getAllByText('Supprimer');
|
||||||
await user.click(deleteButtons[0]);
|
await user.click(deleteButtons[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user