Files
HealthLog/src/components/admin/recent-audit-preview.tsx
T
Marc-André Bombeck ba42354733 fix(admin): replace hand-rolled read-error boxes with the shared primitive
Eight admin surfaces rendered a failed read as their own destructive-
tinted box, and most offered no way to recover. Sweep them onto the shared
QueryErrorRow with retry so the console uses one error form (§13). The
statically renderable sections were watched fail first against the
hand-rolled boxes.
2026-08-09 04:32:54 +02:00

151 lines
5.5 KiB
TypeScript

"use client";
/**
* `<RecentAuditPreview>` — compact list of the 10 most recent audit
* entries for the `/admin` overview landing page. The full filterable
* viewer lives at `/admin/login-overview`.
*
* Reuses `/api/admin/audit-log` so the data source matches the
* detail-page section (`<LoginOverviewSection>`).
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
CheckCircle2,
Loader2,
ScrollText,
XCircle,
} from "lucide-react";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
import { apiGet } from "@/lib/api/api-fetch";
const PREVIEW_LIMIT = 10;
interface AuditLogResponse {
entries: AdminAuditEntry[];
meta: { total: number; limit: number; offset: number };
}
export function RecentAuditPreview() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.adminAuditOverview(),
queryFn: async () => {
return apiGet<AuditLogResponse>(
`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`,
);
},
// Avoid hitting the DB on every page focus; the overview is the
// type of page admins keep open and re-render frequently.
staleTime: 30_000,
});
const AUTH_ACTION_LABELS = useAuthActionLabels();
const entries = data?.entries ?? [];
return (
<SettingsCard as="section" aria-labelledby="admin-overview-audit-heading">
<SettingsCardHeader
icon={ScrollText}
titleId="admin-overview-audit-heading"
title={t("admin.overview.auditTitle")}
status={
<Link
href="/admin/login-overview"
className="text-primary inline-flex items-center gap-1 text-sm underline-offset-2 hover:underline"
>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
}
/>
<div>
{isLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">
{t("admin.overview.auditLoading")}
</span>
</div>
) : isError ? (
<QueryErrorRow
message={t("admin.overview.auditLoadError")}
onRetry={() => void refetch()}
/>
) : entries.length === 0 ? (
// v1.4.15 phase-C5: compact + plain so it nests inside the
// overview card without doubling the dashed border. Reuses
// the existing audit-empty string.
<EmptyState
size="compact"
variant="plain"
icon={<ScrollText className="size-5" />}
title={t("admin.overview.auditEmpty")}
/>
) : (
// `admin-audit-rows` names the read entries. The branches above
// are a spinner, a load error and an empty state, so the marker
// stands for a card that has actually answered.
<ul data-slot="admin-audit-rows" className="divide-border divide-y">
{entries.map((entry) => {
const isFailed = entry.action === "auth.login.failed";
// F-31 (v1.4.19): each row links into the full
// /admin/login-overview viewer so admins can investigate
// failed logins without re-finding the row by hand.
return (
<li key={entry.id}>
<Link
href="/admin/login-overview"
className="hover:bg-muted/40 -mx-2 flex items-center gap-3 rounded px-2 py-2 text-sm transition-colors"
>
<span className="shrink-0" aria-hidden="true">
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-success h-4 w-4" />
)}
</span>
<span
className={`min-w-0 flex-1 truncate font-medium ${isFailed ? "text-destructive" : ""}`}
>
{entry.user?.username ?? t("common.unknown")}
</span>
<span
className={`hidden truncate sm:inline ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
>
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
</span>
{entry.ipAddress && (
<span className="text-muted-foreground hidden shrink-0 font-mono text-xs sm:inline">
{entry.ipAddress}
</span>
)}
<span className="text-muted-foreground shrink-0 text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</SettingsCard>
);
}