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>
174 lines
5.9 KiB
TypeScript
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;
|
|
}
|