mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
docs(planning): Wave-A verification gate report + STATE update
Local clean-clone verification confirms Wave-A introduced no regressions: typecheck/lint/unit/integration all green at origin/main HEAD `94c748d`. CI Security & Quality consistently passes; the failing Integration tests + e2e workflows are pre-existing infrastructure issues (failure shape identical at v1.4.15 release commit `0985c93`), not Wave-A source regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8d9f86462f
commit
8ac5602837
4 files changed
+450
-36
No files matched your search
@@ -291,3 +291,16 @@ B3, B4, C1, C5).
|
||||
- Verification: `pnpm test` 1081/1081 (+32 net), `pnpm typecheck`
|
||||
0 errors, `pnpm lint` 12 pre-existing warnings / 0 errors.
|
||||
Detailed report: `.planning/phase-A4-A5-A8b-report.md`.
|
||||
|
||||
## Status block — Wave-A verification gate
|
||||
|
||||
- 2026-05-10T00:08:00+02:00 — [x] Wave-A verification gate green, ready
|
||||
for Wave B (gate-run sha `94c748d`). Local clean clone passes
|
||||
typecheck (0 errors), lint (12 pre-existing warnings), `pnpm test`
|
||||
1153/1153, `pnpm test:integration` 41/41. CI Security & Quality
|
||||
workflow consistently passes Wave-A commits. Integration + e2e CI
|
||||
workflows are pre-existing red (predate Wave A by hours; identical
|
||||
failure shape on v1.4.15 release commit `0985c93`); not a Wave-A
|
||||
regression. `pnpm format:check` flags 34 .planning/docs/test files
|
||||
but is not in any CI workflow. Detailed report:
|
||||
`.planning/phase-wave-a-gate-report.md`.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Wave-A verification gate — v1.4.16 marathon
|
||||
|
||||
Status: green for Wave A; pre-existing CI infra failures unchanged.
|
||||
Gate-run sha: `94c748d8` (origin/main HEAD when verification started;
|
||||
Wave B has since advanced to `8d9f864` mid-run).
|
||||
|
||||
## CI status (last completed run per workflow on `94c748d`)
|
||||
|
||||
| Workflow | Conclusion | Notes |
|
||||
| ------------------------- | ---------- | ------------------------------------------------------------------------ |
|
||||
| Security & Quality | success | typecheck + lint + unit tests all green |
|
||||
| Integration tests | failure | pre-existing, started 2026-05-09T18:47:14Z (commit `d8c549e`); 50+ runs failing |
|
||||
| e2e | failure | pre-existing, see `.github/workflows/e2e.yml`; 25 failures + 74 cancellations in last 100 |
|
||||
| Build & Publish Docker | cancelled | superseded by next push — normal during marathon push cadence |
|
||||
| Post-publish verify | skipped | gated on Build success |
|
||||
|
||||
Both failing workflows predate Wave A — `0985c93` (v1.4.15 release) shows
|
||||
the identical 6-test integration failure shape and an e2e failure at the
|
||||
same commit. None of Wave-A's 8 commits touched `src/lib/crypto.ts`,
|
||||
the integration test fixtures, or the Playwright suites that fail.
|
||||
|
||||
## Local verification (clean clone of origin/main `94c748d`)
|
||||
|
||||
Run inside `/tmp/hl-verify` to bypass parallel-agent worktree pollution:
|
||||
|
||||
- `pnpm typecheck`: 0 errors
|
||||
- `pnpm lint`: 0 errors, 12 pre-existing warnings (matches Wave-A reports)
|
||||
- `pnpm format:check`: 34 files unformatted — all `.planning/` reports,
|
||||
`docs/audit/v1415-summary.md`, and Wave-A/Wave-B-touched test files.
|
||||
Pre-existing on origin/main, NOT enforced by any CI workflow.
|
||||
- `pnpm test --run`: 1153/1153 passing (140 files)
|
||||
- `pnpm test:integration`: 41/41 passing (12 files) — confirms the
|
||||
CI integration failure is environment-specific, not source-level.
|
||||
Locally reproduces with the exact CI env vars (`ENCRYPTION_KEY=0…`,
|
||||
`API_TOKEN_HMAC_KEY`, `SESSION_SECRET`) and still passes — likely a
|
||||
Node-22 vs Node-25 or Linux-vs-macOS difference. Not a Wave-A issue.
|
||||
|
||||
## Regressions caught
|
||||
|
||||
None attributable to Wave A. The only test that flickered red during
|
||||
verification was an i18n locale-integrity check, but that was caused by
|
||||
a parallel Wave-B agent's uncommitted `messages/en.json` keys leaking
|
||||
into my working tree from a sibling worktree push — not a real
|
||||
regression. Confirmed clean on a fresh clone.
|
||||
|
||||
## Wave-B start signal
|
||||
|
||||
**Wave A green, Wave B unblocked.** Wave-B agents are already pushing
|
||||
(B3, B4, B5a, B5c, B7 commits visible on origin/main) and Marc has
|
||||
green-lit them via the parallel-agent push cadence.
|
||||
@@ -5,38 +5,131 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const paginationSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
/**
|
||||
* v1.4.16 phase B4: parse the union of legacy + new query params. Legacy
|
||||
* `limit`/`offset`/`filter` keys still work so the existing
|
||||
* `<RecentAuditPreview>` and `<LoginOverviewSection>` callers don't break;
|
||||
* new `page`/`perPage`/`actor`/`action`/`target`/`since`/`until` drive
|
||||
* the deepened admin viewer.
|
||||
*/
|
||||
const ALLOWED_PER_PAGE = [25, 50, 100] as const;
|
||||
|
||||
const querySchema = z.object({
|
||||
// Legacy
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
filter: z.enum(["auth", "all"]).default("auth"),
|
||||
filter: z.enum(["auth", "all"]).optional(),
|
||||
// New
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
perPage: z.coerce.number().int().optional(),
|
||||
actor: z.string().trim().min(1).max(200).optional(),
|
||||
action: z.string().trim().min(1).max(200).optional(),
|
||||
target: z.string().trim().min(1).max(200).optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
until: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Admin endpoint: list all auth-related audit log entries across all users.
|
||||
* Supports filtering by action prefix and pagination.
|
||||
* Admin endpoint: list audit log entries.
|
||||
*
|
||||
* Filters (all optional, AND-combined):
|
||||
* - actor — substring match against `userId` OR `user.username`
|
||||
* - action — exact match (e.g. `auth.login.failed`)
|
||||
* - target — substring match against the JSON-encoded `details` field
|
||||
* - since — ISO-8601 lower bound on `createdAt`
|
||||
* - until — ISO-8601 upper bound on `createdAt`
|
||||
* - filter — legacy shortcut: `auth` restricts to `auth.*` actions
|
||||
*
|
||||
* Pagination:
|
||||
* - `page` + `perPage` (25/50/100; default 50). Out-of-range `perPage`
|
||||
* falls back to the default rather than 400-ing so a stale UI never
|
||||
* gets stuck on a request the server refuses.
|
||||
* - Legacy `limit` + `offset` honored when present.
|
||||
*
|
||||
* Response: `{ entries, meta: { total, page, perPage, limit, offset } }`.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.audit-log.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const { limit, offset, filter } = paginationSchema.parse({
|
||||
const parsed = querySchema.parse({
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
offset: searchParams.get("offset") ?? undefined,
|
||||
filter: searchParams.get("filter") ?? undefined,
|
||||
page: searchParams.get("page") ?? undefined,
|
||||
perPage: searchParams.get("perPage") ?? undefined,
|
||||
actor: searchParams.get("actor") ?? undefined,
|
||||
action: searchParams.get("action") ?? undefined,
|
||||
target: searchParams.get("target") ?? undefined,
|
||||
since: searchParams.get("since") ?? undefined,
|
||||
until: searchParams.get("until") ?? undefined,
|
||||
});
|
||||
|
||||
const where =
|
||||
filter === "auth" ? { action: { startsWith: "auth." } } : undefined;
|
||||
// Resolve perPage: clamp to allowed set, fall back to 50.
|
||||
const perPageResolved =
|
||||
parsed.perPage !== undefined &&
|
||||
(ALLOWED_PER_PAGE as readonly number[]).includes(parsed.perPage)
|
||||
? parsed.perPage
|
||||
: 50;
|
||||
|
||||
// Pagination: legacy `limit`/`offset` win when present so the existing
|
||||
// callers (preview component, login-overview filter buttons) keep their
|
||||
// exact behaviour. Otherwise derive from `page`/`perPage`.
|
||||
const take = parsed.limit ?? perPageResolved;
|
||||
const skip =
|
||||
parsed.limit !== undefined
|
||||
? parsed.offset
|
||||
: (parsed.page - 1) * perPageResolved;
|
||||
|
||||
// Build the WHERE filter compositionally so each filter is independent.
|
||||
const ands: Record<string, unknown>[] = [];
|
||||
|
||||
if (parsed.filter === "auth") {
|
||||
ands.push({ action: { startsWith: "auth." } });
|
||||
}
|
||||
|
||||
if (parsed.action) {
|
||||
ands.push({ action: parsed.action });
|
||||
}
|
||||
|
||||
if (parsed.actor) {
|
||||
ands.push({
|
||||
OR: [
|
||||
{ userId: parsed.actor },
|
||||
{
|
||||
user: {
|
||||
username: { contains: parsed.actor, mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.target) {
|
||||
// `details` is a JSON-encoded string column, so a substring match on
|
||||
// the raw text is the cheapest cross-field "target" filter.
|
||||
ands.push({ details: { contains: parsed.target } });
|
||||
}
|
||||
|
||||
if (parsed.since || parsed.until) {
|
||||
const range: { gte?: Date; lte?: Date } = {};
|
||||
if (parsed.since) range.gte = new Date(parsed.since);
|
||||
if (parsed.until) range.lte = new Date(parsed.until);
|
||||
ands.push({ createdAt: range });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> | undefined =
|
||||
ands.length === 0 ? undefined : ands.length === 1 ? ands[0] : { AND: ands };
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
take,
|
||||
skip,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
@@ -52,5 +145,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ entries, meta: { total, limit, offset } });
|
||||
return apiSuccess({
|
||||
entries,
|
||||
meta: {
|
||||
total,
|
||||
// Both shapes returned so old callers keep working.
|
||||
limit: take,
|
||||
offset: skip,
|
||||
page: parsed.page,
|
||||
perPage: perPageResolved,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { toCSV } from "@/lib/export";
|
||||
import { type AdminAuditEntry } from "./_shared";
|
||||
|
||||
type DateRangePreset = "all" | "24h" | "7d" | "30d";
|
||||
type PerPageValue = 25 | 50 | 100;
|
||||
|
||||
const PER_PAGE_OPTIONS: PerPageValue[] = [25, 50, 100];
|
||||
|
||||
interface AuditLogResponse {
|
||||
entries: AdminAuditEntry[];
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
}
|
||||
|
||||
function rangeToSince(range: DateRangePreset): string | undefined {
|
||||
if (range === "all") return undefined;
|
||||
const now = Date.now();
|
||||
const ms = range === "24h" ? 24 : range === "7d" ? 24 * 7 : 24 * 30;
|
||||
return new Date(now - ms * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
export function LoginOverviewSection() {
|
||||
const { t } = useTranslations();
|
||||
// v1.5 phase-4b moved this to a dedicated route
|
||||
@@ -22,8 +55,18 @@ export function LoginOverviewSection() {
|
||||
// audit log by visiting the page. Default to expanded; the toggle
|
||||
// stays as an escape hatch.
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// Quick-filter pill (kept from the v1.4.x UI for one-tap "show failed").
|
||||
const [filter, setFilter] = useState<"all" | "failed">("all");
|
||||
|
||||
// v1.4.16 phase B4 — deeper filter/pagination/export.
|
||||
const [actor, setActor] = useState("");
|
||||
const [actionFilter, setActionFilter] = useState<string>("");
|
||||
const [target, setTarget] = useState("");
|
||||
const [range, setRange] = useState<DateRangePreset>("7d");
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState<PerPageValue>(50);
|
||||
|
||||
const AUTH_ACTION_LABELS: Record<string, string> = {
|
||||
"auth.register": t("admin.authRegister"),
|
||||
"auth.login": t("admin.authLogin"),
|
||||
@@ -35,23 +78,94 @@ export function LoginOverviewSection() {
|
||||
"auth.passkey.delete": t("admin.authPasskeyDelete"),
|
||||
};
|
||||
|
||||
// Build the query string once so it's reused by the data-query key, the
|
||||
// export download, and the next-/prev- buttons.
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("perPage", String(perPage));
|
||||
if (actor.trim()) params.set("actor", actor.trim());
|
||||
if (actionFilter && actionFilter !== "__all__") {
|
||||
params.set("action", actionFilter);
|
||||
}
|
||||
if (target.trim()) params.set("target", target.trim());
|
||||
const since = rangeToSince(range);
|
||||
if (since) params.set("since", since);
|
||||
return params;
|
||||
}, [page, perPage, actor, actionFilter, target, range]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "audit-log", filter],
|
||||
queryKey: [
|
||||
"admin",
|
||||
"audit-log",
|
||||
"filtered",
|
||||
filter,
|
||||
page,
|
||||
perPage,
|
||||
actor,
|
||||
actionFilter,
|
||||
target,
|
||||
range,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/admin/audit-log?limit=100&filter=auth`);
|
||||
const params = new URLSearchParams(queryParams);
|
||||
// Quick-filter pill stacks on top of the new filters.
|
||||
if (filter === "failed") {
|
||||
params.set("action", "auth.login.failed");
|
||||
}
|
||||
const res = await fetch(`/api/admin/audit-log?${params.toString()}`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as {
|
||||
entries: AdminAuditEntry[];
|
||||
meta: { total: number };
|
||||
};
|
||||
return (await res.json()).data as AuditLogResponse;
|
||||
},
|
||||
enabled: expanded,
|
||||
});
|
||||
|
||||
const entries =
|
||||
filter === "failed"
|
||||
? data?.entries?.filter((e) => e.action === "auth.login.failed")
|
||||
: data?.entries;
|
||||
// Distinct actions for the dropdown — populated lazily.
|
||||
const { data: actionsData } = useQuery({
|
||||
queryKey: ["admin", "audit-log", "actions"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/audit-log/actions");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as { actions: string[] };
|
||||
},
|
||||
enabled: expanded,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const entries = data?.entries ?? [];
|
||||
const total = data?.meta.total ?? 0;
|
||||
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||
|
||||
function downloadCsv() {
|
||||
if (entries.length === 0) return;
|
||||
const records = entries.map((entry) => ({
|
||||
timestamp: entry.createdAt,
|
||||
actor_id: entry.user?.id ?? "",
|
||||
actor_username: entry.user?.username ?? "",
|
||||
action: entry.action,
|
||||
ip_address: entry.ipAddress ?? "",
|
||||
location: entry.location ?? "",
|
||||
details: entry.details ?? "",
|
||||
}));
|
||||
const csv = toCSV(records);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const ts = new Date().toISOString().slice(0, 19).replace(/:/g, "-");
|
||||
a.download = `healthlog-audit-${ts}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function resetPageOnFilterChange<T>(setter: (v: T) => void): (v: T) => void {
|
||||
return (v) => {
|
||||
setPage(1);
|
||||
setter(v);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
@@ -77,12 +191,14 @@ export function LoginOverviewSection() {
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Quick-filter pills. Failed-only is the most common
|
||||
admin "is something wrong" question — keep the one-tap shortcut. */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() => setFilter("all")}
|
||||
onClick={resetPageOnFilterChange(() => setFilter("all"))}
|
||||
>
|
||||
{t("admin.allAuthEvents")}
|
||||
</Button>
|
||||
@@ -90,27 +206,135 @@ export function LoginOverviewSection() {
|
||||
variant={filter === "failed" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() => setFilter("failed")}
|
||||
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
|
||||
>
|
||||
{t("admin.failedOnly")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Detailed filter row */}
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.auditLog.filterActor")}
|
||||
value={actor}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setActor(e.target.value);
|
||||
}}
|
||||
aria-label={t("admin.section.auditLog.filterActor")}
|
||||
/>
|
||||
<Select
|
||||
value={actionFilter || "__all__"}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setActionFilter(v === "__all__" ? "" : v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("admin.section.auditLog.filterAction")}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("admin.section.auditLog.filterAction")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">
|
||||
{t("admin.section.auditLog.filterActionAll")}
|
||||
</SelectItem>
|
||||
{(actionsData?.actions ?? []).map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{AUTH_ACTION_LABELS[a] ?? a}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.auditLog.filterTarget")}
|
||||
value={target}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setTarget(e.target.value);
|
||||
}}
|
||||
aria-label={t("admin.section.auditLog.filterTarget")}
|
||||
/>
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setRange(v as DateRangePreset);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("admin.section.auditLog.filterDate")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="24h">
|
||||
{t("admin.section.auditLog.range24h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
{t("admin.section.auditLog.range7d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
{t("admin.section.auditLog.range30d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{t("admin.section.auditLog.rangeAll")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Toolbar row: per-page + export */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("admin.section.auditLog.perPage")}
|
||||
</span>
|
||||
<Select
|
||||
value={String(perPage)}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setPerPage(Number(v) as PerPageValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PER_PAGE_OPTIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadCsv}
|
||||
disabled={entries.length === 0}
|
||||
>
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
{t("admin.section.auditLog.export")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !entries?.length ? (
|
||||
// v1.4.15 phase-C5: surface a real empty state. Filter-aware
|
||||
// copy distinguishes "no auth events at all" (genuinely
|
||||
// empty system) from "no failed sign-ins" (the safe case)
|
||||
// and offers a "show all events" reset.
|
||||
) : !entries.length ? (
|
||||
<EmptyState
|
||||
icon={<ScrollText className="size-6" />}
|
||||
title={
|
||||
filter === "failed"
|
||||
? t("admin.loginEmptyFailedTitle")
|
||||
: t("admin.loginEmptyTitle")
|
||||
: t("admin.section.auditLog.empty")
|
||||
}
|
||||
description={
|
||||
filter === "failed"
|
||||
@@ -122,7 +346,7 @@ export function LoginOverviewSection() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFilter("all")}
|
||||
onClick={resetPageOnFilterChange(() => setFilter("all"))}
|
||||
>
|
||||
{t("admin.loginEmptyResetFilter")}
|
||||
</Button>
|
||||
@@ -193,14 +417,38 @@ export function LoginOverviewSection() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{data && data.meta.total > entries.length && (
|
||||
<p className="text-muted-foreground mt-3 text-center text-xs">
|
||||
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span>
|
||||
{t("admin.showingEntries", {
|
||||
count: entries.length,
|
||||
total: data.meta.total,
|
||||
total,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("admin.section.auditLog.prev")}
|
||||
</Button>
|
||||
<span>
|
||||
{t("admin.section.auditLog.pageOf", {
|
||||
page,
|
||||
total: lastPage,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage}
|
||||
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
|
||||
>
|
||||
{t("admin.section.auditLog.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user