feat(frontend): i18n shared state components + UsersAdminPage + CHANGELOG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-06-21 23:56:47 +02:00
parent ad0a3f5cac
commit 2931e4aaf9
8 changed files with 125 additions and 69 deletions

View File

@@ -1,21 +1,25 @@
import { useTranslation } from 'react-i18next';
interface ErrorStateProps {
title?: string;
message: string;
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 (
<div
role="alert"
data-testid="error-state"
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>
{onRetry ? (
<button type="button" className="btn-outline" onClick={onRetry}>
Retry
{t('state.retry')}
</button>
) : null}
</div>

View File

@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import { useToast } from '@/hooks/useToast';
export function ToastViewport(): JSX.Element {
const { t } = useTranslation();
const { toasts, dismiss } = useToast();
return (
<div
@@ -8,9 +10,9 @@ export function ToastViewport(): JSX.Element {
aria-atomic="true"
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
>
{toasts.map((t) => {
const isError = t.kind === 'error';
const isSuccess = t.kind === 'success';
{toasts.map((toast) => {
const isError = toast.kind === 'error';
const isSuccess = toast.kind === 'success';
const surface = isError
? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep'
: isSuccess
@@ -18,18 +20,18 @@ export function ToastViewport(): JSX.Element {
: 'bg-paper text-ink border border-hairline border-l-4 border-l-primary';
return (
<div
key={t.id}
key={toast.id}
role="status"
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}`}
>
<div className="flex items-start justify-between gap-sm">
<span className="flex-1">{t.message}</span>
<span className="flex-1">{toast.message}</span>
<button
type="button"
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
onClick={() => dismiss(toast.id)}
aria-label={t('toast.dismiss')}
className="text-current opacity-70 hover:opacity-100"
>
×

View File

@@ -310,15 +310,25 @@
"toast": {
"created": "Utilisateur créé",
"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": {
"create": "Impossible de créer 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}} » ?",
"newPasswordFor": "Nouveau mot de passe pour {{username}}",
"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": {
"title": "Aucun utilisateur",
"desc": "Créez le premier compte via le formulaire ci-dessus."

View File

@@ -1,4 +1,5 @@
import { Fragment, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client';
import type { Role, User } from '@/api/types';
import { useAuth } from '@/hooks/useAuth';
@@ -14,12 +15,6 @@ import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
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 {
username: string;
password: string;
@@ -29,6 +24,7 @@ interface CreateFormState {
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
export function UsersAdminPage(): JSX.Element {
const { t } = useTranslation();
const { user: currentUser } = useAuth();
const { push } = useToast();
const list = useUsersList();
@@ -36,6 +32,12 @@ export function UsersAdminPage(): JSX.Element {
const patchMutation = usePatchUser();
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 [createError, setCreateError] = useState<string | null>(null);
@@ -47,15 +49,15 @@ export function UsersAdminPage(): JSX.Element {
e.preventDefault();
setCreateError(null);
if (createForm.password.length < 8) {
setCreateError('Password must be at least 8 characters');
setCreateError(t('user.admin.error.passwordMinLength'));
return;
}
try {
await createMutation.mutateAsync(createForm);
setCreateForm(EMPTY_CREATE);
push('User created', 'success');
push(t('user.admin.toast.created'), 'success');
} 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;
try {
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) {
push(extractApiError(err, 'Could not update role'), 'error');
push(extractApiError(err, t('user.admin.error.roleUpdate')), 'error');
}
};
const onResetPassword = async (u: User, e: FormEvent) => {
e.preventDefault();
if (resetPassword.length < 8) {
push('Password must be at least 8 characters', 'error');
push(t('user.admin.error.passwordMinLength'), 'error');
return;
}
try {
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);
setResetPassword('');
} catch (err) {
push(extractApiError(err, 'Could not reset password'), 'error');
push(extractApiError(err, t('user.admin.error.passwordReset')), 'error');
}
};
const onDelete = async (u: User) => {
if (currentUser?.id === u.id) {
push('You cannot delete your own account', 'error');
push(t('user.admin.error.selfDelete'), 'error');
return;
}
if (!window.confirm(`Delete user "${u.username}"?`)) return;
if (!window.confirm(t('user.admin.deleteConfirm', { username: u.username }))) return;
try {
await deleteMutation.mutateAsync(u.id);
push('User deleted', 'success');
push(t('user.admin.toast.deleted'), 'success');
} catch (err) {
push(extractApiError(err, 'Could not delete user'), 'error');
push(extractApiError(err, t('user.admin.error.delete')), 'error');
}
};
return (
<div className="flex flex-col gap-xl">
<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">
Manage local accounts. Admins can create new red team or SOC analysts.
{t('user.admin.createSubtitle')}
</p>
</header>
<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
so the browser can never misalign them by collapsing different-height cells.
@@ -121,13 +123,13 @@ export function UsersAdminPage(): JSX.Element {
>
{/* Row 1 — labels */}
<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 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 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>
<div aria-hidden="true" />
@@ -148,17 +150,18 @@ export function UsersAdminPage(): JSX.Element {
/>
<Select
id="new-role"
data-testid="new-role-select"
value={createForm.role}
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
options={ROLE_OPTIONS}
/>
<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>
{/* Row 3 — hints */}
<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" />
</form>
@@ -170,19 +173,19 @@ export function UsersAdminPage(): JSX.Element {
</section>
<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 ? (
<ErrorState
message={extractApiError(list.error, 'Could not load users')}
message={extractApiError(list.error, t('user.admin.error.create'))}
onRetry={() => list.refetch()}
/>
) : null}
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
<EmptyState
title="No users yet"
description="Create the first account using the form above."
title={t('user.admin.empty.title')}
description={t('user.admin.empty.desc')}
/>
) : null}
@@ -191,10 +194,10 @@ export function UsersAdminPage(): JSX.Element {
<table className="table-compact w-full text-left">
<thead className="bg-cloud border-b border-hairline">
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th className="text-right">Actions</th>
<th>{t('user.admin.col.username')}</th>
<th>{t('user.admin.col.role')}</th>
<th>{t('user.admin.col.createdAt')}</th>
<th className="text-right">{t('user.admin.col.actions')}</th>
</tr>
</thead>
<tbody>
@@ -209,7 +212,7 @@ export function UsersAdminPage(): JSX.Element {
<span className="font-mono font-medium">{u.username}</span>
{isSelf ? (
<span className="ml-sm font-sans text-[12px] text-graphite">
(you)
{t('user.admin.you')}
</span>
) : null}
</td>
@@ -218,7 +221,7 @@ export function UsersAdminPage(): JSX.Element {
value={u.role}
onChange={(e) => onRoleChange(u, e.target.value as Role)}
options={ROLE_OPTIONS}
aria-label={`Change role for ${u.username}`}
aria-label={`${t('user.admin.col.role')} ${u.username}`}
disabled={patchMutation.isPending}
/>
</td>
@@ -233,7 +236,7 @@ export function UsersAdminPage(): JSX.Element {
setResetPassword('');
}}
>
Reset password
{t('user.admin.resetPassword')}
</button>
<button
type="button"
@@ -241,7 +244,7 @@ export function UsersAdminPage(): JSX.Element {
disabled={isSelf || deleteMutation.isPending}
onClick={() => onDelete(u)}
>
Delete
{t('user.admin.btn.delete')}
</button>
</div>
</td>
@@ -255,9 +258,9 @@ export function UsersAdminPage(): JSX.Element {
className="flex items-end gap-md"
>
<FormField
label={`New password for ${u.username}`}
label={t('user.admin.newPasswordFor', { username: u.username })}
htmlFor={`reset-${u.id}`}
hint="≥ 8 characters"
hint={t('user.admin.passwordHint')}
>
<TextInput
id={`reset-${u.id}`}
@@ -269,7 +272,7 @@ export function UsersAdminPage(): JSX.Element {
/>
</FormField>
<button type="submit" className="btn-primary">
Save password
{t('user.admin.btn.setPassword')}
</button>
<button
type="button"
@@ -279,7 +282,7 @@ export function UsersAdminPage(): JSX.Element {
setResetPassword('');
}}
>
Cancel
{t('user.admin.btn.cancel')}
</button>
</form>
</td>