Files
MichilisandClaude Opus 5 4c39926483 phase-6: the staff console, and a log that says who did what
Flow H, three screens behind a role check: find an account, work the
ingestion error queue, read and export the audit log. Superadmins can
change a role, never their own.

The error queue merges ingest errors and dead jobs into one table with a
cursor that pages both sources; only a job can be retried and only an
ingest row resolved, with a note that migration 003 gives it somewhere
to live.

writeAudit no longer defaults a missing subject to the actor, which had
been recording a user search as staff looking themselves up. Omitting
the subject still means acting on yourself; null now means the action
has no subject, which is what a search, a retry and an export are.

Reading the log is not audited. Exporting it is: a copy leaving the
building is a different act from looking.

e2e/global-setup.ts asks for every screen once before the suite starts,
so a dev server's first-request compile is paid before the first test
rather than by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:55:59 +00:00

112 lines
3.7 KiB
TypeScript

'use client';
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, Td, Th, Tr } from '@/components/ui/table';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
/** Two characters is the shortest thing the server treats as a search. */
const MIN_LENGTH = 2;
/** Flow H: search by email, RUC or name, then open one account. */
export function UsersScreen() {
const t = useT();
const [draft, setDraft] = useState('');
const [term, setTerm] = useState('');
const results = useQuery({
queryKey: ['admin', 'users', term],
queryFn: ({ signal }) => api.searchUsers(term, signal),
enabled: term.length >= MIN_LENGTH,
});
function submit(event: FormEvent) {
event.preventDefault();
setTerm(draft.trim());
}
return (
<div className="space-y-5">
<h1 className="text-xl font-semibold tracking-tight">{t('admin.users.title')}</h1>
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
<div className="min-w-64 flex-1 space-y-1">
<label htmlFor="admin-user-search" className="text-xs font-medium">
{t('admin.users.searchLabel')}
</label>
<Input
id="admin-user-search"
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="h-10 rounded-lg text-sm"
autoComplete="off"
/>
<p className="text-xs text-[var(--text-muted)]">{t('admin.users.searchHint')}</p>
</div>
<Button type="submit" className="h-10 rounded-lg">
{t('common.search')}
</Button>
</form>
{term.length < MIN_LENGTH ? <EmptyState title={t('admin.users.start')} /> : null}
{results.isPending && term.length >= MIN_LENGTH ? (
<Skeleton name="admin-user-row" count={4} />
) : null}
{results.isError ? (
<div className="space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" className="h-9 rounded-lg" onClick={() => void results.refetch()}>
{t('common.retry')}
</Button>
</div>
) : null}
{results.data && results.data.items.length === 0 ? (
<EmptyState title={t('admin.users.empty')} />
) : null}
{results.data && results.data.items.length > 0 ? (
<Table>
<thead>
<tr>
<Th>{t('admin.users.col.email')}</Th>
<Th>{t('admin.users.col.name')}</Th>
<Th>{t('admin.users.col.doc')}</Th>
<Th>{t('admin.users.col.role')}</Th>
<Th>{t('admin.users.col.created')}</Th>
</tr>
</thead>
<tbody>
{results.data.items.map((user) => (
<Tr key={user.id}>
<Td>
<Link
href={`/usuarios/${user.id}`}
className="font-medium text-accent-700 hover:underline"
>
{user.email}
</Link>
</Td>
<Td>{user.fullName}</Td>
<Td className="tnum">{user.doc ?? t('common.none')}</Td>
<Td>{t(`admin.role.${user.role}` as const)}</Td>
<Td className="tnum whitespace-nowrap">{user.createdAt.slice(0, 10)}</Td>
</Tr>
))}
</tbody>
</Table>
) : null}
</div>
);
}