From dc75fe846aa8b7aa53b3a2c76d1ebe0e489c0af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 13:30:51 +0200 Subject: [PATCH] fix(admin): restrict login overview to auth events The Login Overview page promised "authentication and admin events" in its subtitle but the underlying audit-log query never set `filter=auth`, so the table mixed insights.* / admin.* / data.* rows in with passkey + password logins. Default the page request to `filter=auth` (an explicit action picker still overrides), and restrict the action dropdown to actions starting with `auth.` so the filter UI stays in sync with what the page actually shows. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../login-overview-auth-filter.test.tsx | 112 ++++++++++++++++++ .../admin/login-overview-section.tsx | 19 ++- 2 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 src/components/admin/__tests__/login-overview-auth-filter.test.tsx diff --git a/src/components/admin/__tests__/login-overview-auth-filter.test.tsx b/src/components/admin/__tests__/login-overview-auth-filter.test.tsx new file mode 100644 index 000000000..41f4a1a13 --- /dev/null +++ b/src/components/admin/__tests__/login-overview-auth-filter.test.tsx @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.4.19 A8 / F-02 — `/admin/login-overview` was rendering insights events + * (`insights.weight-status.en`, `insights.bmi-status.en`, …) because the + * underlying fetch never restricted the `audit-log` API to authentication + * actions. The page subtitle promises "authentication and admin events" so + * everything else is data we don't want to leak into the login viewer. + * + * The fix: the section must call the audit-log endpoint with + * `filter=auth` whenever no explicit action filter is set. This test pins + * the request URL so the regression cannot recur. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/login-overview", +})); + +const fetchCalls: string[] = []; + +vi.mock("@tanstack/react-query", async () => { + // Implement a lightweight stub that records the URL passed to queryFn so + // we can assert on the request the section would issue. + return { + useQuery: ({ + queryFn, + enabled, + }: { + queryFn?: () => Promise; + enabled?: boolean; + }) => { + if (enabled !== false && queryFn) { + // Best-effort fire-and-forget — the static-markup render only needs + // the side-effect, not the result. + try { + void queryFn(); + } catch { + /* ignored */ + } + } + return { + data: { entries: [], meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 } }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + }, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + }), + }; +}); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "ADMIN", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { LoginOverviewSection } from "../login-overview-section"; + +beforeEach(() => { + fetchCalls.length = 0; + globalThis.fetch = vi.fn(async (url: RequestInfo | URL) => { + fetchCalls.push(typeof url === "string" ? url : url.toString()); + return new Response( + JSON.stringify({ + data: { + entries: [], + meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("LoginOverviewSection — restricts to auth events", () => { + it("requests /api/admin/audit-log with filter=auth by default", () => { + renderToStaticMarkup( + + + , + ); + const auditCalls = fetchCalls.filter((u) => + u.includes("/api/admin/audit-log?"), + ); + expect(auditCalls.length).toBeGreaterThan(0); + for (const url of auditCalls) { + expect(url).toMatch(/[?&]filter=auth(&|$)/); + } + }); +}); diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index e6cbb183e..1212a7770 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -84,6 +84,13 @@ export function LoginOverviewSection() { const params = new URLSearchParams(); params.set("page", String(page)); params.set("perPage", String(perPage)); + // F-02 (v1.4.19): The page is the auth/admin "Login Overview" — restrict + // to `auth.*` actions so insights/admin/data events don't leak into the + // viewer (the previous behaviour rendered rows like + // `insights.weight-status.en` next to "Passkey login"). When the user + // picks a specific action below we trust their explicit choice and let + // it overwrite this default. + params.set("filter", "auth"); if (actor.trim()) params.set("actor", actor.trim()); if (actionFilter && actionFilter !== "__all__") { params.set("action", actionFilter); @@ -242,11 +249,13 @@ export function LoginOverviewSection() { {t("admin.section.auditLog.filterActionAll")} - {(actionsData?.actions ?? []).map((a) => ( - - {AUTH_ACTION_LABELS[a] ?? a} - - ))} + {(actionsData?.actions ?? []) + .filter((a) => a.startsWith("auth.")) + .map((a) => ( + + {AUTH_ACTION_LABELS[a] ?? a} + + ))}