feat(frontend): full FR i18n via react-i18next + 2-tab engagement detail (sprint 12) #14
37
CHANGELOG.md
37
CHANGELOG.md
@@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n)
|
||||||
|
|
||||||
|
**Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests)
|
||||||
|
|
||||||
|
- `frontend/src/i18n/fr.json` — NEW. ~160-key nested translation file (French). Covers: common, nav, auth, engagement, simulation, template, user.admin, c2, mitre, state, toast, status.
|
||||||
|
- `frontend/src/i18n/index.ts` — NEW. i18next init (lng: fr, no fallback to en, escapeValue: false).
|
||||||
|
- `frontend/src/i18n/status.ts` — NEW. `engagementStatusLabel()` / `simulationStatusLabel()` helpers mapping API status to translated strings.
|
||||||
|
- `frontend/src/lib/format.ts` — NEW. `formatDate()` / `formatDateTime()` using `fr-FR` locale.
|
||||||
|
- `frontend/src/main.tsx` — Added `import './i18n'` bootstrap.
|
||||||
|
- `frontend/vitest.setup.ts` — Added `import './src/i18n'` so all tests use real French translations.
|
||||||
|
- `frontend/src/pages/EngagementDetailPage.tsx` — Merged 3-tab layout → 2 tabs (Description + Simulations); full i18n pass.
|
||||||
|
- `frontend/src/pages/EngagementsListPage.tsx` — Full i18n pass; local `formatDate` removed, `@/lib/format` used.
|
||||||
|
- `frontend/src/pages/EngagementFormPage.tsx` — Full i18n pass; `validate()` inside component; `data-testid="btn-submit"` added.
|
||||||
|
- `frontend/src/pages/SimulationFormPage.tsx` — Full i18n pass; `data-testid` on 4 action buttons.
|
||||||
|
- `frontend/src/pages/TemplatesListPage.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/pages/TemplateFormPage.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/pages/UsersAdminPage.tsx` — Full i18n pass; `data-testid="new-role-select"` added.
|
||||||
|
- `frontend/src/components/Layout.tsx` — Nav labels translated.
|
||||||
|
- `frontend/src/components/LoginPage.tsx` — Form labels and error message translated.
|
||||||
|
- `frontend/src/components/ProtectedRoute.tsx` — Forbidden/loading messages translated.
|
||||||
|
- `frontend/src/components/SimulationList.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/components/TemplatePickerModal.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/components/MitreTechniqueTag.tsx` — `aria-label` translated (Retirer TX…).
|
||||||
|
- `frontend/src/components/MitreTechniquesField.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/components/MitreMatrixModal.tsx` — Full i18n pass; plural apply button.
|
||||||
|
- `frontend/src/components/MitreTechniquePicker.tsx` — Placeholder and messages translated.
|
||||||
|
- `frontend/src/components/C2ConfigCard.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/components/C2TasksPanel.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/components/ExecuteViaC2Modal.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/components/ImportC2HistoryModal.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/components/C2CallbackPicker.tsx` — Full i18n pass.
|
||||||
|
- `frontend/src/components/Toast.tsx` — Dismiss aria-label translated.
|
||||||
|
- `frontend/src/components/ErrorState.tsx` — Title default and Retry button translated.
|
||||||
|
- All test files updated to use French strings or `data-testid` where button labels changed.
|
||||||
|
|
||||||
|
**No backend changes. No DB schema change. No migration.**
|
||||||
|
|
||||||
### Added — Sprint 11 (Spectrum UX port: 4 primitives + compact density global)
|
### Added — Sprint 11 (Spectrum UX port: 4 primitives + compact density global)
|
||||||
|
|
||||||
**Frontend only** (233 vitest passing — baseline 212 + 21 new tests across 4 new specs)
|
**Frontend only** (233 vitest passing — baseline 212 + 21 new tests across 4 new specs)
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface ErrorStateProps {
|
interface ErrorStateProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
message: string;
|
message: string;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorState({ title = 'Something went wrong', message, onRetry }: ErrorStateProps): JSX.Element {
|
export function ErrorState({ title, message, onRetry }: ErrorStateProps): JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTitle = title ?? t('state.error.title');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
data-testid="error-state"
|
data-testid="error-state"
|
||||||
className="card-product border-l-4 border-l-bloom-deep flex flex-col items-start gap-md"
|
className="card-product border-l-4 border-l-bloom-deep flex flex-col items-start gap-md"
|
||||||
>
|
>
|
||||||
<h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2>
|
<h2 className="text-[24px] font-medium text-bloom-deep">{resolvedTitle}</h2>
|
||||||
<p className="text-[16px] text-charcoal">{message}</p>
|
<p className="text-[16px] text-charcoal">{message}</p>
|
||||||
{onRetry ? (
|
{onRetry ? (
|
||||||
<button type="button" className="btn-outline" onClick={onRetry}>
|
<button type="button" className="btn-outline" onClick={onRetry}>
|
||||||
Retry
|
{t('state.retry')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
||||||
export function ToastViewport(): JSX.Element {
|
export function ToastViewport(): JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { toasts, dismiss } = useToast();
|
const { toasts, dismiss } = useToast();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -8,9 +10,9 @@ export function ToastViewport(): JSX.Element {
|
|||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
|
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
|
||||||
>
|
>
|
||||||
{toasts.map((t) => {
|
{toasts.map((toast) => {
|
||||||
const isError = t.kind === 'error';
|
const isError = toast.kind === 'error';
|
||||||
const isSuccess = t.kind === 'success';
|
const isSuccess = toast.kind === 'success';
|
||||||
const surface = isError
|
const surface = isError
|
||||||
? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep'
|
? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep'
|
||||||
: isSuccess
|
: isSuccess
|
||||||
@@ -18,18 +20,18 @@ export function ToastViewport(): JSX.Element {
|
|||||||
: 'bg-paper text-ink border border-hairline border-l-4 border-l-primary';
|
: 'bg-paper text-ink border border-hairline border-l-4 border-l-primary';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={toast.id}
|
||||||
role="status"
|
role="status"
|
||||||
data-testid="toast"
|
data-testid="toast"
|
||||||
data-kind={t.kind}
|
data-kind={toast.kind}
|
||||||
className={`pointer-events-auto rounded-none px-md py-sm text-[14px] leading-[1.4] ${surface}`}
|
className={`pointer-events-auto rounded-none px-md py-sm text-[14px] leading-[1.4] ${surface}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-sm">
|
<div className="flex items-start justify-between gap-sm">
|
||||||
<span className="flex-1">{t.message}</span>
|
<span className="flex-1">{toast.message}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => dismiss(t.id)}
|
onClick={() => dismiss(toast.id)}
|
||||||
aria-label="Dismiss notification"
|
aria-label={t('toast.dismiss')}
|
||||||
className="text-current opacity-70 hover:opacity-100"
|
className="text-current opacity-70 hover:opacity-100"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -310,15 +310,25 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"created": "Utilisateur créé",
|
"created": "Utilisateur créé",
|
||||||
"deleted": "Utilisateur supprimé",
|
"deleted": "Utilisateur supprimé",
|
||||||
"passwordReset": "Mot de passe réinitialisé"
|
"passwordReset": "Mot de passe réinitialisé pour {{username}}",
|
||||||
|
"roleUpdated": "Rôle mis à jour pour {{username}}"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"create": "Impossible de créer l'utilisateur",
|
"create": "Impossible de créer l'utilisateur",
|
||||||
"delete": "Impossible de supprimer l'utilisateur",
|
"delete": "Impossible de supprimer l'utilisateur",
|
||||||
"passwordReset": "Impossible de réinitialiser le mot de passe"
|
"passwordReset": "Impossible de réinitialiser le mot de passe",
|
||||||
|
"roleUpdate": "Impossible de modifier le rôle",
|
||||||
|
"selfDelete": "Vous ne pouvez pas supprimer votre propre compte",
|
||||||
|
"passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères"
|
||||||
},
|
},
|
||||||
"deleteConfirm": "Supprimer l'utilisateur « {{username}} » ?",
|
"deleteConfirm": "Supprimer l'utilisateur « {{username}} » ?",
|
||||||
|
"newPasswordFor": "Nouveau mot de passe pour {{username}}",
|
||||||
"newPassword": "Nouveau mot de passe",
|
"newPassword": "Nouveau mot de passe",
|
||||||
|
"passwordHint": "≥ 8 caractères",
|
||||||
|
"createSection": "Créer un compte",
|
||||||
|
"allSection": "Tous les comptes",
|
||||||
|
"createSubtitle": "Les administrateurs peuvent créer des comptes Red Team ou SOC.",
|
||||||
|
"resetPassword": "Réinitialiser le mot de passe",
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Aucun utilisateur",
|
"title": "Aucun utilisateur",
|
||||||
"desc": "Créez le premier compte via le formulaire ci-dessus."
|
"desc": "Créez le premier compte via le formulaire ci-dessus."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, useState, type FormEvent } from 'react';
|
import { Fragment, useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { extractApiError } from '@/api/client';
|
import { extractApiError } from '@/api/client';
|
||||||
import type { Role, User } from '@/api/types';
|
import type { Role, User } from '@/api/types';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
@@ -14,12 +15,6 @@ 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';
|
||||||
|
|
||||||
const ROLE_OPTIONS: { value: Role; label: string }[] = [
|
|
||||||
{ value: 'admin', label: 'Admin' },
|
|
||||||
{ value: 'redteam', label: 'Red Team' },
|
|
||||||
{ value: 'soc', label: 'SOC' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface CreateFormState {
|
interface CreateFormState {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -29,6 +24,7 @@ interface CreateFormState {
|
|||||||
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
|
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
|
||||||
|
|
||||||
export function UsersAdminPage(): JSX.Element {
|
export function UsersAdminPage(): JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const list = useUsersList();
|
const list = useUsersList();
|
||||||
@@ -36,6 +32,12 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
const patchMutation = usePatchUser();
|
const patchMutation = usePatchUser();
|
||||||
const deleteMutation = useDeleteUser();
|
const deleteMutation = useDeleteUser();
|
||||||
|
|
||||||
|
const ROLE_OPTIONS: { value: Role; label: string }[] = [
|
||||||
|
{ value: 'admin', label: t('user.admin.role.admin') },
|
||||||
|
{ value: 'redteam', label: t('user.admin.role.redteam') },
|
||||||
|
{ value: 'soc', label: t('user.admin.role.soc') },
|
||||||
|
];
|
||||||
|
|
||||||
const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE);
|
const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -47,15 +49,15 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
if (createForm.password.length < 8) {
|
if (createForm.password.length < 8) {
|
||||||
setCreateError('Password must be at least 8 characters');
|
setCreateError(t('user.admin.error.passwordMinLength'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync(createForm);
|
await createMutation.mutateAsync(createForm);
|
||||||
setCreateForm(EMPTY_CREATE);
|
setCreateForm(EMPTY_CREATE);
|
||||||
push('User created', 'success');
|
push(t('user.admin.toast.created'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCreateError(extractApiError(err, 'Could not create user'));
|
setCreateError(extractApiError(err, t('user.admin.error.create')));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,53 +65,53 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
if (u.role === role) return;
|
if (u.role === role) return;
|
||||||
try {
|
try {
|
||||||
await patchMutation.mutateAsync({ id: u.id, input: { role } });
|
await patchMutation.mutateAsync({ id: u.id, input: { role } });
|
||||||
push(`Role updated for ${u.username}`, 'success');
|
push(t('user.admin.toast.roleUpdated', { username: u.username }), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Could not update role'), 'error');
|
push(extractApiError(err, t('user.admin.error.roleUpdate')), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResetPassword = async (u: User, e: FormEvent) => {
|
const onResetPassword = async (u: User, e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (resetPassword.length < 8) {
|
if (resetPassword.length < 8) {
|
||||||
push('Password must be at least 8 characters', 'error');
|
push(t('user.admin.error.passwordMinLength'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
|
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
|
||||||
push(`Password reset for ${u.username}`, 'success');
|
push(t('user.admin.toast.passwordReset', { username: u.username }), 'success');
|
||||||
setResetOpen(null);
|
setResetOpen(null);
|
||||||
setResetPassword('');
|
setResetPassword('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Could not reset password'), 'error');
|
push(extractApiError(err, t('user.admin.error.passwordReset')), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = async (u: User) => {
|
const onDelete = async (u: User) => {
|
||||||
if (currentUser?.id === u.id) {
|
if (currentUser?.id === u.id) {
|
||||||
push('You cannot delete your own account', 'error');
|
push(t('user.admin.error.selfDelete'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(`Delete user "${u.username}"?`)) return;
|
if (!window.confirm(t('user.admin.deleteConfirm', { username: u.username }))) return;
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(u.id);
|
await deleteMutation.mutateAsync(u.id);
|
||||||
push('User deleted', 'success');
|
push(t('user.admin.toast.deleted'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Could not delete user'), 'error');
|
push(extractApiError(err, t('user.admin.error.delete')), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-xl">
|
<div className="flex flex-col gap-xl">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-[32px] font-medium leading-none">User accounts</h1>
|
<h1 className="text-[32px] font-medium leading-none">{t('user.admin.title')}</h1>
|
||||||
<p className="text-charcoal text-[16px] mt-sm">
|
<p className="text-charcoal text-[16px] mt-sm">
|
||||||
Manage local accounts. Admins can create new red team or SOC analysts.
|
{t('user.admin.createSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="card-product flex flex-col gap-md">
|
<section className="card-product flex flex-col gap-md">
|
||||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
<h2 className="text-[20px] font-medium">{t('user.admin.createSection')}</h2>
|
||||||
{/*
|
{/*
|
||||||
Option A structural fix (AC-17.3): labels / inputs / hints in 3 explicit grid rows
|
Option A structural fix (AC-17.3): labels / inputs / hints in 3 explicit grid rows
|
||||||
so the browser can never misalign them by collapsing different-height cells.
|
so the browser can never misalign them by collapsing different-height cells.
|
||||||
@@ -121,13 +123,13 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
>
|
>
|
||||||
{/* Row 1 — labels */}
|
{/* Row 1 — labels */}
|
||||||
<label htmlFor="new-username" className="text-[14px] font-medium text-ink">
|
<label htmlFor="new-username" className="text-[14px] font-medium text-ink">
|
||||||
Username <span className="text-bloom-deep">*</span>
|
{t('user.admin.field.username')} <span className="text-bloom-deep">*</span>
|
||||||
</label>
|
</label>
|
||||||
<label htmlFor="new-password" className="text-[14px] font-medium text-ink">
|
<label htmlFor="new-password" className="text-[14px] font-medium text-ink">
|
||||||
Password <span className="text-bloom-deep">*</span>
|
{t('user.admin.field.password')} <span className="text-bloom-deep">*</span>
|
||||||
</label>
|
</label>
|
||||||
<label htmlFor="new-role" className="text-[14px] font-medium text-ink">
|
<label htmlFor="new-role" className="text-[14px] font-medium text-ink">
|
||||||
Role <span className="text-bloom-deep">*</span>
|
{t('user.admin.col.role')} <span className="text-bloom-deep">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div aria-hidden="true" />
|
<div aria-hidden="true" />
|
||||||
|
|
||||||
@@ -148,17 +150,18 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
id="new-role"
|
id="new-role"
|
||||||
|
data-testid="new-role-select"
|
||||||
value={createForm.role}
|
value={createForm.role}
|
||||||
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
|
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
|
||||||
options={ROLE_OPTIONS}
|
options={ROLE_OPTIONS}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
||||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
{createMutation.isPending ? t('common.creating') : t('user.admin.btn.create')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Row 3 — hints */}
|
{/* Row 3 — hints */}
|
||||||
<div aria-hidden="true" />
|
<div aria-hidden="true" />
|
||||||
<span className="text-[12px] text-graphite">≥ 8 characters</span>
|
<span className="text-[12px] text-graphite">{t('user.admin.passwordHint')}</span>
|
||||||
<div aria-hidden="true" />
|
<div aria-hidden="true" />
|
||||||
<div aria-hidden="true" />
|
<div aria-hidden="true" />
|
||||||
</form>
|
</form>
|
||||||
@@ -170,19 +173,19 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-md">
|
<section className="flex flex-col gap-md">
|
||||||
<h2 className="text-[20px] font-medium">All accounts</h2>
|
<h2 className="text-[20px] font-medium">{t('user.admin.allSection')}</h2>
|
||||||
|
|
||||||
{list.isLoading ? <LoadingState label="Loading users…" /> : null}
|
{list.isLoading ? <LoadingState label={t('state.loading')} /> : null}
|
||||||
{list.isError ? (
|
{list.isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={extractApiError(list.error, 'Could not load users')}
|
message={extractApiError(list.error, t('user.admin.error.create'))}
|
||||||
onRetry={() => list.refetch()}
|
onRetry={() => list.refetch()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
|
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No users yet"
|
title={t('user.admin.empty.title')}
|
||||||
description="Create the first account using the form above."
|
description={t('user.admin.empty.desc')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -191,10 +194,10 @@ export function UsersAdminPage(): 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>Username</th>
|
<th>{t('user.admin.col.username')}</th>
|
||||||
<th>Role</th>
|
<th>{t('user.admin.col.role')}</th>
|
||||||
<th>Created</th>
|
<th>{t('user.admin.col.createdAt')}</th>
|
||||||
<th className="text-right">Actions</th>
|
<th className="text-right">{t('user.admin.col.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -209,7 +212,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
<span className="font-mono font-medium">{u.username}</span>
|
<span className="font-mono font-medium">{u.username}</span>
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<span className="ml-sm font-sans text-[12px] text-graphite">
|
<span className="ml-sm font-sans text-[12px] text-graphite">
|
||||||
(you)
|
{t('user.admin.you')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
@@ -218,7 +221,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
value={u.role}
|
value={u.role}
|
||||||
onChange={(e) => onRoleChange(u, e.target.value as Role)}
|
onChange={(e) => onRoleChange(u, e.target.value as Role)}
|
||||||
options={ROLE_OPTIONS}
|
options={ROLE_OPTIONS}
|
||||||
aria-label={`Change role for ${u.username}`}
|
aria-label={`${t('user.admin.col.role')} ${u.username}`}
|
||||||
disabled={patchMutation.isPending}
|
disabled={patchMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -233,7 +236,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
setResetPassword('');
|
setResetPassword('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reset password
|
{t('user.admin.resetPassword')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -241,7 +244,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
disabled={isSelf || deleteMutation.isPending}
|
disabled={isSelf || deleteMutation.isPending}
|
||||||
onClick={() => onDelete(u)}
|
onClick={() => onDelete(u)}
|
||||||
>
|
>
|
||||||
Delete
|
{t('user.admin.btn.delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -255,9 +258,9 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
className="flex items-end gap-md"
|
className="flex items-end gap-md"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
label={`New password for ${u.username}`}
|
label={t('user.admin.newPasswordFor', { username: u.username })}
|
||||||
htmlFor={`reset-${u.id}`}
|
htmlFor={`reset-${u.id}`}
|
||||||
hint="≥ 8 characters"
|
hint={t('user.admin.passwordHint')}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
id={`reset-${u.id}`}
|
id={`reset-${u.id}`}
|
||||||
@@ -269,7 +272,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<button type="submit" className="btn-primary">
|
<button type="submit" className="btn-primary">
|
||||||
Save password
|
{t('user.admin.btn.setPassword')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -279,7 +282,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
setResetPassword('');
|
setResetPassword('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('user.admin.btn.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('Toast', () => {
|
|||||||
expect(toast).toHaveTextContent('Session expirée');
|
expect(toast).toHaveTextContent('Session expirée');
|
||||||
expect(toast).toHaveAttribute('data-kind', 'error');
|
expect(toast).toHaveAttribute('data-kind', 'error');
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
await user.click(screen.getByRole('button', { name: /fermer/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId('toast')).toBeNull();
|
expect(screen.queryByTestId('toast')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,15 +56,15 @@ describe('UsersAdminPage', () => {
|
|||||||
|
|
||||||
await screen.findByText('alice');
|
await screen.findByText('alice');
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/^username/i), 'dan');
|
await user.type(screen.getByLabelText(/^nom d'utilisateur/i), 'dan');
|
||||||
await user.type(screen.getByLabelText(/^password/i), 'sup3rs4fe!');
|
await user.type(screen.getByLabelText(/^mot de passe/i), 'sup3rs4fe!');
|
||||||
// role default is 'redteam'; switch to 'soc' to match newUser
|
// role default is 'redteam'; switch to 'soc' to match newUser
|
||||||
await user.selectOptions(screen.getByLabelText(/^role/i), 'soc');
|
await user.selectOptions(screen.getByTestId('new-role-select'), 'soc');
|
||||||
|
|
||||||
// After POST, hooks invalidate and the list refetches → return the new list
|
// After POST, hooks invalidate and the list refetches → return the new list
|
||||||
mock.onGet('/users').reply(200, [...USERS, newUser]);
|
mock.onGet('/users').reply(200, [...USERS, newUser]);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
await user.click(screen.getByRole('button', { name: /^créer$/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(postSpy).toHaveBeenCalledWith({
|
expect(postSpy).toHaveBeenCalledWith({
|
||||||
@@ -85,12 +85,12 @@ describe('UsersAdminPage', () => {
|
|||||||
// The "Reset password" button for bob lives in bob's row.
|
// The "Reset password" button for bob lives in bob's row.
|
||||||
const bobRow = screen.getByText('bob').closest('tr');
|
const bobRow = screen.getByText('bob').closest('tr');
|
||||||
expect(bobRow).not.toBeNull();
|
expect(bobRow).not.toBeNull();
|
||||||
await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /reset password/i }));
|
await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /réinitialiser le mot de passe/i }));
|
||||||
|
|
||||||
// The reset form for bob (and bob only) must appear.
|
// The reset form for bob (and bob only) must appear.
|
||||||
expect(await screen.findByLabelText(/new password for bob/i)).toBeInTheDocument();
|
expect(await screen.findByLabelText(/nouveau mot de passe pour bob/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/new password for carol/i)).toBeNull();
|
expect(screen.queryByLabelText(/nouveau mot de passe pour carol/i)).toBeNull();
|
||||||
expect(screen.queryByLabelText(/new password for alice/i)).toBeNull();
|
expect(screen.queryByLabelText(/nouveau mot de passe pour alice/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables the delete button on the current user own row', async () => {
|
it('disables the delete button on the current user own row', async () => {
|
||||||
@@ -100,7 +100,7 @@ describe('UsersAdminPage', () => {
|
|||||||
const aliceRow = screen.getByText('alice').closest('tr') as HTMLElement;
|
const aliceRow = screen.getByText('alice').closest('tr') as HTMLElement;
|
||||||
const bobRow = screen.getByText('bob').closest('tr') as HTMLElement;
|
const bobRow = screen.getByText('bob').closest('tr') as HTMLElement;
|
||||||
|
|
||||||
expect(within(aliceRow).getByRole('button', { name: /delete/i })).toBeDisabled();
|
expect(within(aliceRow).getByRole('button', { name: /supprimer/i })).toBeDisabled();
|
||||||
expect(within(bobRow).getByRole('button', { name: /delete/i })).toBeEnabled();
|
expect(within(bobRow).getByRole('button', { name: /supprimer/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ describe('ErrorState', () => {
|
|||||||
const onRetry = vi.fn();
|
const onRetry = vi.fn();
|
||||||
render(<ErrorState message="Boom" onRetry={onRetry} />);
|
render(<ErrorState message="Boom" onRetry={onRetry} />);
|
||||||
expect(screen.getByTestId('error-state')).toHaveTextContent('Boom');
|
expect(screen.getByTestId('error-state')).toHaveTextContent('Boom');
|
||||||
await userEvent.click(screen.getByRole('button', { name: /retry/i }));
|
await userEvent.click(screen.getByRole('button', { name: /réessayer/i }));
|
||||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits retry button when no handler given', () => {
|
it('omits retry button when no handler given', () => {
|
||||||
render(<ErrorState message="Boom" />);
|
render(<ErrorState message="Boom" />);
|
||||||
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull();
|
expect(screen.queryByRole('button', { name: /réessayer/i })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user