feat(frontend): i18n shared state components + UsersAdminPage + CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user