Files
mimic/frontend/src/pages/UsersAdminPage.tsx
Knacky 6995c4c860 fix(design): address design-reviewer findings F1-F6 — nav slab, spinner, badge coverage, mono discipline
F3: nav-bar-top bg-paper → bg-slab text-slab-text (3-band slab anchoring restored).
    NavLinks: text-slab-muted default, text-slab-text + border-primary active.
    Logo span: text-ink → text-slab-text.
F2: (you) label extracted from font-mono td into adjacent font-sans span.
F1: Loader2 circular spinner → EXPORTING… text with animate-pulse (terminal-SOC compatible).

Screenshots regenerated:
- All 8 pages light+dark (01→10)
- 05-simulation-form-edit light+dark (F6)
- 11-mitre-matrix-modal light+dark (F6)
- 12-toast-success light+dark (F6)
- 13-confirm-dialog light+dark (F6)
- admin-light/dark-open/closed regenerated from HEAD (F4)

F4: StatusBadge.tsx confirmed single code path — planned → bg-warn-soft (no divergence in code).
    Divergence in prior captures was stale cache; regenerated admin-* confirm consistency.
F5: Simulations seeded (pending/in_progress/review_required/done) via API;
    10-sim-list-badges shows all 4 semantic badge colors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:43:08 +02:00

298 lines
12 KiB
TypeScript

import { Fragment, useState, type FormEvent } from 'react';
import { extractApiError } from '@/api/client';
import type { Role, User } from '@/api/types';
import { useAuth } from '@/hooks/useAuth';
import {
useCreateUser,
useDeleteUser,
usePatchUser,
useUsersList,
} from '@/hooks/useUsers';
import { useToast } from '@/hooks/useToast';
import { FormField, Select, TextInput } from '@/components/FormField';
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;
role: Role;
}
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
export function UsersAdminPage(): JSX.Element {
const { user: currentUser } = useAuth();
const { push } = useToast();
const list = useUsersList();
const createMutation = useCreateUser();
const patchMutation = usePatchUser();
const deleteMutation = useDeleteUser();
const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE);
const [createError, setCreateError] = useState<string | null>(null);
// Per-row password reset state. Only one row open at a time.
const [resetOpen, setResetOpen] = useState<number | null>(null);
const [resetPassword, setResetPassword] = useState('');
const onCreate = async (e: FormEvent) => {
e.preventDefault();
setCreateError(null);
if (createForm.password.length < 8) {
setCreateError('Password must be at least 8 characters');
return;
}
try {
await createMutation.mutateAsync(createForm);
setCreateForm(EMPTY_CREATE);
push('User created', 'success');
} catch (err) {
setCreateError(extractApiError(err, 'Could not create user'));
}
};
const onRoleChange = async (u: User, role: Role) => {
if (u.role === role) return;
try {
await patchMutation.mutateAsync({ id: u.id, input: { role } });
push(`Role updated for ${u.username}`, 'success');
} catch (err) {
push(extractApiError(err, 'Could not update role'), 'error');
}
};
const onResetPassword = async (u: User, e: FormEvent) => {
e.preventDefault();
if (resetPassword.length < 8) {
push('Password must be at least 8 characters', 'error');
return;
}
try {
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
push(`Password reset for ${u.username}`, 'success');
setResetOpen(null);
setResetPassword('');
} catch (err) {
push(extractApiError(err, 'Could not reset password'), 'error');
}
};
const onDelete = async (u: User) => {
if (currentUser?.id === u.id) {
push('You cannot delete your own account', 'error');
return;
}
if (!window.confirm(`Delete user "${u.username}"?`)) return;
try {
await deleteMutation.mutateAsync(u.id);
push('User deleted', 'success');
} catch (err) {
push(extractApiError(err, 'Could not delete user'), 'error');
}
};
return (
<div className="flex flex-col gap-xl">
<header>
<h1 className="text-[32px] font-medium leading-none">User accounts</h1>
<p className="text-charcoal text-[16px] mt-sm">
Manage local accounts. Admins can create new red team or SOC analysts.
</p>
</header>
<section className="card-product flex flex-col gap-md">
<h2 className="text-[20px] font-medium">Create account</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.
grid-rows-[auto_auto_auto] ensures row 1 = labels, row 2 = inputs, row 3 = hints.
*/}
<form
onSubmit={onCreate}
className="grid grid-cols-1 md:grid-cols-4 md:grid-rows-[auto_auto_auto] gap-x-md gap-y-xs"
>
{/* Row 1 — labels */}
<label htmlFor="new-username" className="text-[14px] font-medium text-ink">
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>
</label>
<label htmlFor="new-role" className="text-[14px] font-medium text-ink">
Role <span className="text-bloom-deep">*</span>
</label>
<div aria-hidden="true" />
{/* Row 2 — inputs + button (all same height = h-11) */}
<TextInput
id="new-username"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
required
/>
<TextInput
id="new-password"
type="password"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
required
minLength={8}
/>
<Select
id="new-role"
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'}
</button>
{/* Row 3 — hints */}
<div aria-hidden="true" />
<span className="text-[12px] text-graphite"> 8 characters</span>
<div aria-hidden="true" />
<div aria-hidden="true" />
</form>
{createError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{createError}
</div>
) : null}
</section>
<section className="flex flex-col gap-md">
<h2 className="text-[20px] font-medium">All accounts</h2>
{list.isLoading ? <LoadingState label="Loading users…" /> : null}
{list.isError ? (
<ErrorState
message={extractApiError(list.error, 'Could not load users')}
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."
/>
) : null}
{!list.isLoading && !list.isError && list.data && list.data.length > 0 ? (
<div className="card-product overflow-hidden p-0">
<table className="w-full text-left">
<thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
<th className="px-xl py-md">Username</th>
<th className="px-xl py-md">Role</th>
<th className="px-xl py-md">Created</th>
<th className="px-xl py-md text-right">Actions</th>
</tr>
</thead>
<tbody>
{list.data.map((u) => {
const isSelf = currentUser?.id === u.id;
return (
// Fragment must carry the key — `<>` cannot, which broke
// per-row reconciliation (reset-password state leaked across rows).
<Fragment key={u.id}>
<tr className="border-b border-hairline last:border-0">
<td className="px-xl py-md text-ink">
<span className="font-mono font-medium">{u.username}</span>
{isSelf ? (
<span className="ml-sm font-sans text-[12px] text-graphite">
(you)
</span>
) : null}
</td>
<td className="px-xl py-md">
<Select
value={u.role}
onChange={(e) => onRoleChange(u, e.target.value as Role)}
options={ROLE_OPTIONS}
aria-label={`Change role for ${u.username}`}
disabled={patchMutation.isPending}
/>
</td>
<td className="px-xl py-md text-charcoal font-mono">{u.created_at}</td>
<td className="px-xl py-md text-right">
<div className="inline-flex gap-sm">
<button
type="button"
className="btn-text-link"
onClick={() => {
setResetOpen(resetOpen === u.id ? null : u.id);
setResetPassword('');
}}
>
Reset password
</button>
<button
type="button"
className="btn-text-link text-bloom-deep disabled:text-steel"
disabled={isSelf || deleteMutation.isPending}
onClick={() => onDelete(u)}
>
Delete
</button>
</div>
</td>
</tr>
{resetOpen === u.id ? (
<tr className="border-b border-hairline last:border-0 bg-cloud">
<td colSpan={4} className="px-xl py-md">
<form
onSubmit={(e) => onResetPassword(u, e)}
className="flex items-end gap-md"
>
<FormField
label={`New password for ${u.username}`}
htmlFor={`reset-${u.id}`}
hint="≥ 8 characters"
>
<TextInput
id={`reset-${u.id}`}
type="password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
minLength={8}
required
/>
</FormField>
<button type="submit" className="btn-primary">
Save password
</button>
<button
type="button"
className="btn-outline-ink"
onClick={() => {
setResetOpen(null);
setResetPassword('');
}}
>
Cancel
</button>
</form>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
) : null}
</section>
</div>
);
}