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,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 ?? '';
|
||||
}
|
||||
Reference in New Issue
Block a user