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

174 lines
5.9 KiB
TypeScript

'use client';
import type { AdminAuditDto, AdminAuditQuery } from '@impuestos/contracts';
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, Td, Th, Tr } from '@/components/ui/table';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
const EMPTY: AdminAuditQuery = {};
/**
* Flow H: read only, filterable, exportable. The action is shown as the code it is stored
* as rather than a translated phrase: it is the same token the filter takes, and an
* operator searching a log wants to match what they see.
*/
export function AuditScreen() {
const t = useT();
const [draft, setDraft] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<AdminAuditQuery>(EMPTY);
const [pages, setPages] = useState<AdminAuditDto[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const audit = useQuery({
queryKey: ['admin', 'audit', filters, cursor],
queryFn: ({ signal }) => api.listAudit({ ...filters, ...(cursor ? { cursor } : {}) }, signal),
});
// Pages accumulate, so "show more" appends rather than replacing what is on screen.
const rows = cursor ? [...pages, ...(audit.data?.items ?? [])] : (audit.data?.items ?? []);
function apply(event: FormEvent) {
event.preventDefault();
setPages([]);
setCursor(undefined);
setFilters(clean(draft));
}
function clear() {
setDraft({});
setPages([]);
setCursor(undefined);
setFilters(EMPTY);
}
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold tracking-tight">{t('admin.audit.title')}</h1>
<a
href={api.auditCsvUrl(filters)}
className="text-sm text-accent-700 hover:underline"
download
>
{t('admin.audit.export')}
</a>
</div>
<form onSubmit={apply} className="flex flex-wrap items-end gap-3">
{(
[
['actor', 'admin.audit.filterActor', 'text'],
['action', 'admin.audit.filterAction', 'text'],
['subject', 'admin.audit.filterSubject', 'text'],
['from', 'admin.audit.filterFrom', 'date'],
['to', 'admin.audit.filterTo', 'date'],
] as const
).map(([key, label, type]) => (
<div key={key} className="space-y-1">
<label htmlFor={`audit-${key}`} className="block text-xs font-medium">
{t(label)}
</label>
<Input
id={`audit-${key}`}
type={type}
value={draft[key] ?? ''}
onChange={(event) => setDraft({ ...draft, [key]: event.target.value })}
className="h-9 w-44 rounded-lg text-sm"
autoComplete="off"
/>
</div>
))}
{/* Grouped, so the two buttons wrap together rather than one at a time. */}
<div className="flex gap-2">
<Button type="submit" className="h-9 rounded-lg">
{t('admin.audit.apply')}
</Button>
<Button type="button" variant="ghost" className="h-9 rounded-lg" onClick={clear}>
{t('admin.audit.clear')}
</Button>
</div>
</form>
{audit.data ? (
<p className="text-xs text-[var(--text-muted)]">
{t('admin.audit.total', { count: audit.data.total })}
</p>
) : null}
{audit.isPending && rows.length === 0 ? <Skeleton name="admin-user-row" count={6} /> : null}
{audit.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 audit.refetch()}>
{t('common.retry')}
</Button>
</div>
) : null}
{audit.data && rows.length === 0 ? <EmptyState title={t('admin.audit.empty')} /> : null}
{rows.length > 0 ? (
<Table>
<thead>
<tr>
<Th>{t('admin.audit.col.when')}</Th>
<Th>{t('admin.audit.col.actor')}</Th>
<Th>{t('admin.audit.col.action')}</Th>
<Th>{t('admin.audit.col.subject')}</Th>
<Th>{t('admin.audit.col.resource')}</Th>
<Th>{t('admin.audit.col.ip')}</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<Tr key={row.id}>
<Td className="tnum whitespace-nowrap">
{row.createdAt.slice(0, 16).replace('T', ' ')}
</Td>
<Td className="whitespace-nowrap">{row.actorEmail ?? row.actorUserId}</Td>
<Td className="whitespace-nowrap font-medium">{row.action}</Td>
<Td className="whitespace-nowrap">{row.subjectEmail ?? t('common.none')}</Td>
<Td className="max-w-64 truncate" title={row.resource}>
{row.resource}
</Td>
<Td className="tnum whitespace-nowrap">{row.ip ?? t('common.none')}</Td>
</Tr>
))}
</tbody>
</Table>
) : null}
{audit.data?.cursor ? (
<Button
type="button"
variant="secondary"
className="h-9 rounded-lg"
onClick={() => {
setPages(rows);
setCursor(audit.data.cursor);
}}
>
{t('common.loadMore')}
</Button>
) : null}
</div>
);
}
function clean(draft: Record<string, string>): AdminAuditQuery {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(draft)) {
if (value.trim().length > 0) out[key] = value.trim();
}
return out as AdminAuditQuery;
}