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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6620650e9e
commit
4c39926483
@@ -0,0 +1,173 @@
|
||||
'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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { AuditScreen } from './audit-screen';
|
||||
|
||||
export default async function AuditoriaPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
return <AuditScreen />;
|
||||
}
|
||||
Reference in New Issue
Block a user