feat(frontend): i18n engagement pages (list, detail, form) + fr-FR date formatting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagement } from '@/hooks/useEngagements';
|
||||
@@ -11,10 +12,12 @@ import { SimulationList } from '@/components/SimulationList';
|
||||
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import { Tabs } from '@/components/Tabs';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
type TabId = 'description' | 'simulations';
|
||||
|
||||
export function EngagementDetailPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const { canEditEngagements } = useAuth();
|
||||
@@ -25,35 +28,36 @@ export function EngagementDetailPage(): JSX.Element {
|
||||
const [activeTabRaw, setActiveTab] = useHashTab('description');
|
||||
const activeTab = activeTabRaw as TabId;
|
||||
|
||||
if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (detail.isLoading) return <LoadingState label={t('engagement.detail.loading')} />;
|
||||
if (detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
message={extractApiError(detail.error, t('engagement.detail.errorLoad'))}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!detail.data) return <ErrorState message="Engagement not found" />;
|
||||
if (!detail.data) return <ErrorState message={t('engagement.detail.notFound')} />;
|
||||
|
||||
const eng = detail.data;
|
||||
const simCount = simsQuery.data?.length;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'description', label: 'Description' },
|
||||
{ id: 'simulations', label: 'Simulations', count: simCount },
|
||||
{ id: 'description', label: t('engagement.detail.tabs.description') },
|
||||
{ id: 'simulations', label: t('engagement.detail.tabs.simulations'), count: simCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<BackLink to="/engagements">Back to engagements</BackLink>
|
||||
<BackLink to="/engagements">{t('engagement.detail.backTo')}</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
|
||||
<div className="flex items-center gap-md">
|
||||
<StatusBadge status={eng.status} />
|
||||
<span className="text-[14px] text-graphite">
|
||||
Created by <span className="text-ink">{eng.created_by.username}</span>
|
||||
{t('engagement.detail.createdBy')}{' '}
|
||||
<span className="text-ink">{eng.created_by.username}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,25 +77,25 @@ export function EngagementDetailPage(): JSX.Element {
|
||||
<div className="card-product flex flex-col gap-md">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<dl className="grid grid-cols-2 gap-x-lg gap-y-xxs text-caption-md">
|
||||
<dt className="text-graphite">Start date</dt>
|
||||
<dt className="text-graphite">{t('engagement.detail.schedule.startDate')}</dt>
|
||||
<dd className="font-mono">{eng.start_date}</dd>
|
||||
<dt className="text-graphite">End date</dt>
|
||||
<dt className="text-graphite">{t('engagement.detail.schedule.endDate')}</dt>
|
||||
<dd className="font-mono">{eng.end_date ?? '—'}</dd>
|
||||
<dt className="text-graphite">Status</dt>
|
||||
<dt className="text-graphite">{t('engagement.detail.schedule.status')}</dt>
|
||||
<dd><StatusBadge status={eng.status} /></dd>
|
||||
<dt className="text-graphite">Created at</dt>
|
||||
<dd className="font-mono">{eng.created_at}</dd>
|
||||
<dt className="text-graphite">{t('engagement.detail.schedule.createdAt')}</dt>
|
||||
<dd className="font-mono">{formatDateTime(eng.created_at)}</dd>
|
||||
</dl>
|
||||
{canEditEngagements ? (
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline shrink-0">
|
||||
Edit
|
||||
{t('engagement.detail.edit')}
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
<hr className="border-hairline" />
|
||||
<h2 className="text-display-sm">Description</h2>
|
||||
<h2 className="text-display-sm">{t('engagement.detail.tabs.description')}</h2>
|
||||
<p className="text-[16px] text-charcoal whitespace-pre-line">
|
||||
{eng.description?.trim() ? eng.description : 'No description provided.'}
|
||||
{eng.description?.trim() ? eng.description : t('engagement.detail.noDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { EngagementInput, EngagementStatus } from '@/api/types';
|
||||
import {
|
||||
@@ -13,12 +14,7 @@ import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { C2ConfigCard } from '@/components/C2ConfigCard';
|
||||
|
||||
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||
{ value: 'planned', label: 'Planned' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
];
|
||||
import { engagementStatusLabel } from '@/i18n/status';
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
@@ -36,17 +32,8 @@ const EMPTY: FormState = {
|
||||
status: 'planned',
|
||||
};
|
||||
|
||||
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
|
||||
const errors: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!state.name.trim()) errors.name = 'Name is required';
|
||||
if (!state.start_date) errors.start_date = 'Start date is required';
|
||||
if (state.end_date && state.start_date && state.end_date < state.start_date) {
|
||||
errors.end_date = 'End date must be on or after start date';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function EngagementFormPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const editing = Boolean(id);
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
@@ -62,6 +49,22 @@ export function EngagementFormPage(): JSX.Element {
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||
{ value: 'planned', label: engagementStatusLabel('planned') },
|
||||
{ value: 'active', label: engagementStatusLabel('active') },
|
||||
{ value: 'closed', label: engagementStatusLabel('closed') },
|
||||
];
|
||||
|
||||
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
|
||||
const errs: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!state.name.trim()) errs.name = t('engagement.form.validation.nameRequired');
|
||||
if (!state.start_date) errs.start_date = t('engagement.form.validation.startDateRequired');
|
||||
if (state.end_date && state.start_date && state.end_date < state.start_date) {
|
||||
errs.end_date = t('engagement.form.validation.endDateAfterStart');
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
// Hydrate edit form when data arrives.
|
||||
useEffect(() => {
|
||||
if (editing && detail.data) {
|
||||
@@ -75,11 +78,11 @@ export function EngagementFormPage(): JSX.Element {
|
||||
}
|
||||
}, [editing, detail.data]);
|
||||
|
||||
if (editing && detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (editing && detail.isLoading) return <LoadingState label={t('engagement.form.loading')} />;
|
||||
if (editing && detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
message={extractApiError(detail.error, t('engagement.form.error.load'))}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -109,15 +112,15 @@ export function EngagementFormPage(): JSX.Element {
|
||||
try {
|
||||
if (editing && numericId) {
|
||||
await patchMutation.mutateAsync(payload);
|
||||
push('Engagement updated', 'success');
|
||||
push(t('engagement.form.toast.updated'), 'success');
|
||||
navigate(`/engagements/${numericId}`);
|
||||
} else {
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
push('Engagement created', 'success');
|
||||
push(t('engagement.form.toast.created'), 'success');
|
||||
navigate(`/engagements/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not save engagement'));
|
||||
setSubmitError(extractApiError(err, t('engagement.form.error.save')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,12 +130,10 @@ export function EngagementFormPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header>
|
||||
<h1 className="text-[32px] font-medium leading-none">
|
||||
{editing ? 'Edit engagement' : 'New engagement'}
|
||||
{editing ? t('engagement.form.title.edit') : t('engagement.form.title.new')}
|
||||
</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
{editing
|
||||
? 'Update the engagement metadata.'
|
||||
: 'Create a new red team mission to host simulations.'}
|
||||
{editing ? t('engagement.form.subtitle.edit') : t('engagement.form.subtitle.new')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -144,7 +145,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
}
|
||||
>
|
||||
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
|
||||
<FormField label={t('engagement.form.field.name')} htmlFor="eng-name" required error={errors.name}>
|
||||
<TextInput
|
||||
id="eng-name"
|
||||
name="name"
|
||||
@@ -154,7 +155,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="eng-description">
|
||||
<FormField label={t('engagement.form.field.description')} htmlFor="eng-description">
|
||||
<TextArea
|
||||
id="eng-description"
|
||||
name="description"
|
||||
@@ -165,7 +166,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<FormField
|
||||
label="Start date"
|
||||
label={t('engagement.form.field.startDate')}
|
||||
htmlFor="eng-start"
|
||||
required
|
||||
error={errors.start_date}
|
||||
@@ -181,9 +182,9 @@ export function EngagementFormPage(): JSX.Element {
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="End date"
|
||||
label={t('engagement.form.field.endDate')}
|
||||
htmlFor="eng-end"
|
||||
hint="Leave empty to clear / leave open-ended"
|
||||
hint={t('engagement.form.field.endDateHint')}
|
||||
error={errors.end_date}
|
||||
>
|
||||
<TextInput
|
||||
@@ -196,7 +197,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Status" htmlFor="eng-status" required>
|
||||
<FormField label={t('engagement.form.field.status')} htmlFor="eng-status" required>
|
||||
<Select
|
||||
id="eng-status"
|
||||
name="status"
|
||||
@@ -213,14 +214,18 @@ export function EngagementFormPage(): JSX.Element {
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-md pt-sm">
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
|
||||
<button type="submit" className="btn-primary" disabled={submitting} data-testid="btn-submit">
|
||||
{submitting
|
||||
? t('engagement.form.btn.saving')
|
||||
: editing
|
||||
? t('engagement.form.btn.save')
|
||||
: t('engagement.form.btn.create')}
|
||||
</button>
|
||||
<Link
|
||||
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
|
||||
className="btn-outline-ink"
|
||||
>
|
||||
Cancel
|
||||
{t('engagement.form.btn.cancel')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Engagement } from '@/api/types';
|
||||
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
|
||||
@@ -9,25 +10,22 @@ import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return value;
|
||||
}
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
export function EngagementsListPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementsList();
|
||||
const { canEditEngagements } = useAuth();
|
||||
const { push } = useToast();
|
||||
const deleteMutation = useDeleteEngagement();
|
||||
|
||||
const onDelete = async (eng: Engagement) => {
|
||||
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
|
||||
if (!window.confirm(t('engagement.list.deleteConfirm', { name: eng.name }))) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(eng.id);
|
||||
push('Engagement deleted', 'success');
|
||||
push(t('engagement.list.toast.deleted'), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not delete engagement'), 'error');
|
||||
push(extractApiError(err, t('engagement.list.error.delete')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,32 +33,32 @@ export function EngagementsListPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-end justify-between gap-md">
|
||||
<div>
|
||||
<h1 className="text-[32px] font-medium leading-none">Engagements</h1>
|
||||
<h1 className="text-[32px] font-medium leading-none">{t('engagement.list.title')}</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
Red team missions and their lifecycle status.
|
||||
{t('engagement.list.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> New
|
||||
<Plus size={14} aria-hidden /> {t('engagement.list.new')}
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{isLoading ? <LoadingState label="Loading engagements…" /> : null}
|
||||
{isLoading ? <LoadingState label={t('common.loading')} /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState message={extractApiError(error, 'Could not load engagements')} onRetry={() => refetch()} />
|
||||
<ErrorState message={extractApiError(error, t('engagement.list.error.load'))} onRetry={() => refetch()} />
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No engagements yet"
|
||||
description="Create your first engagement to start tracking red team missions."
|
||||
title={t('engagement.list.empty.title')}
|
||||
description={t('engagement.list.empty.desc')}
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> New
|
||||
<Plus size={14} aria-hidden /> {t('engagement.list.new')}
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
@@ -72,12 +70,12 @@ export function EngagementsListPage(): JSX.Element {
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Created by</th>
|
||||
<th className="text-right">Actions</th>
|
||||
<th>{t('engagement.list.col.name')}</th>
|
||||
<th>{t('engagement.list.col.status')}</th>
|
||||
<th>{t('engagement.list.col.start')}</th>
|
||||
<th>{t('engagement.list.col.end')}</th>
|
||||
<th>{t('engagement.list.col.createdBy')}</th>
|
||||
<th className="text-right">{t('engagement.list.col.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -97,12 +95,12 @@ export function EngagementsListPage(): JSX.Element {
|
||||
<td className="text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<Link to={`/engagements/${eng.id}`} className="btn-text-link">
|
||||
View
|
||||
{t('engagement.list.view')}
|
||||
</Link>
|
||||
{canEditEngagements ? (
|
||||
<>
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-text-link">
|
||||
Edit
|
||||
{t('engagement.list.edit')}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
@@ -110,7 +108,7 @@ export function EngagementsListPage(): JSX.Element {
|
||||
onClick={() => onDelete(eng)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
{t('engagement.list.delete')}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('EngagementFormPage — C2 config card visibility', () => {
|
||||
routerProps: { initialEntries: ['/engagements/5/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('btn-submit')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-config-card')).toBeNull();
|
||||
});
|
||||
@@ -101,7 +101,7 @@ describe('EngagementFormPage — C2 config card visibility', () => {
|
||||
routerProps: { initialEntries: ['/engagements/new'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /create engagement/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('btn-submit')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-config-card')).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user