From 8ac560283714f0c5faca61b9100c7a4ae18444f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 00:08:04 +0200 Subject: [PATCH] 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) --- .planning/STATE.md | 13 + .planning/phase-wave-a-gate-report.md | 50 +++ src/app/api/admin/audit-log/route.ts | 125 +++++++- .../admin/login-overview-section.tsx | 298 ++++++++++++++++-- 4 files changed, 450 insertions(+), 36 deletions(-) create mode 100644 .planning/phase-wave-a-gate-report.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 02584d482..1829c86bd 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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`. diff --git a/.planning/phase-wave-a-gate-report.md b/.planning/phase-wave-a-gate-report.md new file mode 100644 index 000000000..a4522fa01 --- /dev/null +++ b/.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. diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts index e6a5be815..9a096b820 100644 --- a/src/app/api/admin/audit-log/route.ts +++ b/src/app/api/admin/audit-log/route.ts @@ -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 + * `` and `` 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[] = []; + + 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 | 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, + }, + }); }); diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index 4773eb1d9..e6cbb183e 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -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(""); + const [target, setTarget] = useState(""); + const [range, setRange] = useState("7d"); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(50); + const AUTH_ACTION_LABELS: Record = { "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(setter: (v: T) => void): (v: T) => void { + return (v) => { + setPage(1); + setter(v); + }; + } return (
@@ -77,12 +191,14 @@ export function LoginOverviewSection() { {expanded && (
+ {/* Quick-filter pills. Failed-only is the most common + admin "is something wrong" question — keep the one-tap shortcut. */}
@@ -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")}
+ {/* Detailed filter row */} +
+ { + setPage(1); + setActor(e.target.value); + }} + aria-label={t("admin.section.auditLog.filterActor")} + /> + + { + setPage(1); + setTarget(e.target.value); + }} + aria-label={t("admin.section.auditLog.filterTarget")} + /> + +
+ + {/* Toolbar row: per-page + export */} +
+
+ + {t("admin.section.auditLog.perPage")} + + +
+ +
+ {isLoading ? (
- ) : !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 ? ( } title={ filter === "failed" ? t("admin.loginEmptyFailedTitle") - : t("admin.loginEmptyTitle") + : t("admin.section.auditLog.empty") } description={ filter === "failed" @@ -122,7 +346,7 @@ export function LoginOverviewSection() { @@ -193,14 +417,38 @@ export function LoginOverviewSection() { })} - {data && data.meta.total > entries.length && ( -

+

+ {t("admin.showingEntries", { count: entries.length, - total: data.meta.total, + total, })} -

- )} +
+
+ + + {t("admin.section.auditLog.pageOf", { + page, + total: lastPage, + })} + + +
+
)}