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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 13:30:51 +02:00
1 parent 180f46c24e
commit dc75fe846a
2 files changed
+126 -5

No files matched your search

@@ -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<unknown>;
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(
<I18nProvider initialLocale="en">
<LoginOverviewSection />
</I18nProvider>,
);
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(&|$)/);
}
});
});
@@ -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() {
<SelectItem value="__all__">
{t("admin.section.auditLog.filterActionAll")}
</SelectItem>
{(actionsData?.actions ?? []).map((a) => (
<SelectItem key={a} value={a}>
{AUTH_ACTION_LABELS[a] ?? a}
</SelectItem>
))}
{(actionsData?.actions ?? [])
.filter((a) => a.startsWith("auth."))
.map((a) => (
<SelectItem key={a} value={a}>
{AUTH_ACTION_LABELS[a] ?? a}
</SelectItem>
))}
</SelectContent>
</Select>
<Input