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