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:
Michilis
2026-09-04 21:55:59 +00:00
co-authored by Claude Opus 5
parent 6620650e9e
commit 4c39926483
39 changed files with 2767 additions and 11 deletions
@@ -0,0 +1,22 @@
import { isApiError } from '@impuestos/contracts';
import { setRequestLocale } from 'next-intl/server';
import { serverApi } from '@/lib/api-server';
import { UserOverview } from './user-overview';
export default async function UsuarioPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { locale, id } = await params;
setRequestLocale(locale);
// Role management is superadmin only, so the viewer's role decides what renders.
const api = await serverApi();
const session = await api.getSession().catch((error: unknown) => {
if (isApiError(error)) return null;
throw error;
});
return <UserOverview id={id} viewerRole={session?.role ?? 'user'} />;
}
@@ -0,0 +1,227 @@
'use client';
import type { UserRole } from '@impuestos/contracts';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
const ROLES: UserRole[] = ['user', 'accountant', 'staff', 'superadmin'];
/** Flow H: one account at a glance, plus the one destructive thing a superadmin can do. */
export function UserOverview({ id, viewerRole }: { id: string; viewerRole: string }) {
const t = useT();
const overview = useQuery({
queryKey: ['admin', 'user', id],
queryFn: ({ signal }) => api.getUserOverview(id, signal),
});
if (overview.isPending) {
return (
<Card>
<Skeleton name="profile-section" />
</Card>
);
}
if (overview.isError || !overview.data) {
return (
<Card 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 overview.refetch()}>
{t('common.retry')}
</Button>
</Card>
);
}
const { user, profile, counts, recentErrors, lastActivityAt } = overview.data;
return (
<div className="space-y-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">{user.fullName}</h1>
<p className="text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<Link href="/usuarios" className="text-sm text-accent-700 hover:underline">
{t('common.back')}
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{(
[
['documents', counts.documents],
['needsReview', counts.needsReview],
['declarations', counts.declarations],
] as const
).map(([key, value]) => (
<Card key={key} className="p-4">
<p className="text-xs text-[var(--text-muted)]">{t(`admin.users.counts.${key}`)}</p>
<p className="tnum text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
<Card className="space-y-2 p-4 text-sm">
{profile ? (
<dl className="grid gap-4 sm:grid-cols-4">
<Row label={t('admin.users.col.doc')} value={docOf(profile.ruc, profile.rucDv, profile.ci)} />
<Row label={t('admin.role.title')} value={t(`admin.role.${user.role}` as const)} />
<Row
label={t('admin.users.obligations')}
value={profile.obligations
.filter((obligation) => obligation.active)
.map((obligation) => t(`obligation.${obligation.code}` as const))
.join(', ')}
/>
<Row label={t('admin.users.col.created')} value={user.createdAt.slice(0, 10)} />
</dl>
) : (
<p className="text-[var(--text-muted)]">{t('admin.users.noProfile')}</p>
)}
<p className="text-xs text-[var(--text-muted)]">
{lastActivityAt
? // A full date, not "15 ene": in a console the year is part of the fact.
t('admin.users.lastActivity', { date: lastActivityAt.slice(0, 10) })
: t('admin.users.noActivity')}
</p>
</Card>
<section className="space-y-2">
<h2 className="text-sm font-medium">{t('admin.users.openErrors')}</h2>
{recentErrors.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]">{t('admin.users.noErrors')}</p>
) : (
<Card className="p-0">
<ul className="divide-y">
{recentErrors.map((error) => (
<li key={error.id} className="flex flex-wrap gap-x-3 px-4 py-2 text-sm">
<span className="text-[var(--text-muted)]">
{t(`admin.errors.stage.${error.stage}` as const)}
</span>
<span className="min-w-0 flex-1">{error.message}</span>
<span className="tnum text-xs text-[var(--text-muted)]">
{error.createdAt.slice(0, 10)}
</span>
</li>
))}
</ul>
</Card>
)}
{/* The queue itself, for the rows this summary does not show. */}
<Link href="/errores" className="inline-block text-sm text-accent-700 hover:underline">
{t('admin.errors.goToQueue')}
</Link>
</section>
{viewerRole === 'superadmin' ? <RoleManager user={user} /> : null}
</div>
);
}
function RoleManager({ user }: { user: { id: string; email: string; role: UserRole } }) {
const t = useT();
const queryClient = useQueryClient();
const [role, setRole] = useState<UserRole>(user.role);
const [confirming, setConfirming] = useState(false);
const change = useMutation({
mutationFn: () => api.setUserRole(user.id, { role }),
onSuccess: async () => {
setConfirming(false);
await queryClient.invalidateQueries({ queryKey: ['admin', 'user', user.id] });
},
});
return (
<Card className="space-y-3 p-4">
<h2 className="text-sm font-medium">{t('admin.role.title')}</h2>
<div className="flex flex-wrap items-center gap-3">
<Select
aria-label={t('admin.role.title')}
value={role}
onChange={(event) => {
setRole(event.target.value as UserRole);
setConfirming(false);
}}
>
{ROLES.map((value) => (
<option key={value} value={value}>
{t(`admin.role.${value}` as const)}
</option>
))}
</Select>
<Button
type="button"
variant="secondary"
className="h-9 rounded-lg"
disabled={role === user.role || change.isPending}
onClick={() => setConfirming(true)}
>
{t('admin.role.change')}
</Button>
</div>
{confirming ? (
<div className="space-y-2 rounded-lg border p-3">
<p className="text-sm">
{t('admin.role.confirm', { email: user.email, role: t(`admin.role.${role}` as const) })}
</p>
<div className="flex gap-2">
<Button
type="button"
className="h-9 rounded-lg"
disabled={change.isPending}
onClick={() => change.mutate()}
>
{t('common.confirm')}
</Button>
<Button
type="button"
variant="ghost"
className="h-9 rounded-lg"
onClick={() => setConfirming(false)}
>
{t('common.cancel')}
</Button>
</div>
</div>
) : null}
{change.isError ? (
<p role="alert" className="text-sm text-overdue">
{t('common.error.generic')}
</p>
) : null}
{change.isSuccess ? <p className="text-sm text-positive">{t('admin.role.saved')}</p> : null}
</Card>
);
}
/**
* Label above value. Side by side across a wide card, a right aligned value ends up next
* to the following pair's label and the two read as one field.
*/
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<dt className="text-xs text-[var(--text-muted)]">{label}</dt>
<dd className="tnum truncate">{value}</dd>
</div>
);
}
function docOf(ruc: string | null, rucDv: string | null, ci: string | null): string {
if (ruc) return rucDv ? `${ruc}-${rucDv}` : ruc;
return ci ?? '';
}
@@ -0,0 +1,8 @@
import { setRequestLocale } from 'next-intl/server';
import { UsersScreen } from './users-screen';
export default async function UsuariosPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
return <UsersScreen />;
}
@@ -0,0 +1,111 @@
'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>
);
}