diff --git a/.changeset/ranged-metrics-review-notice.md b/.changeset/ranged-metrics-review-notice.md new file mode 100644 index 0000000..b9ec639 --- /dev/null +++ b/.changeset/ranged-metrics-review-notice.md @@ -0,0 +1,8 @@ +--- +"seamless-auth-admin-dashboard": minor +--- + +- Overview's headline tiles and Security's suspicious-activity feed now follow the range selector, which finishes #148 (fells-code/seamless-auth-api#132). Each tile names the window it shows. Against an API that predates ranges, the tiles keep their fixed 24-hour figures and their "fixed window" labels, so a tile never claims to follow the selector while showing something else. The Sessions tile's hint now says "Currently active sessions", which is what it always counted. +- Overview shows a warning while store review accounts are enabled. It lists the addresses, how often the fixed code was used to sign in in the last 30 days, and any wrong codes entered, and says to clear `REVIEW_ACCOUNT_EMAILS` once review is over (fells-code/seamless-auth-api#331). It shows nothing against an API or adapter without the route. + +Requires `@seamless-auth/types` 0.28.0, an auth API with both changes, and a `@seamless-auth/server` that forwards the range and passes the review accounts route through (fells-code/seamless-auth-server#194). diff --git a/e2e/personas.ts b/e2e/personas.ts index df2605b..715b70e 100644 --- a/e2e/personas.ts +++ b/e2e/personas.ts @@ -143,6 +143,19 @@ export function seedDeployment(api: MockApi) { api.get("/internal/auth-events/login-stats", { json: makeLoginStats() }); api.get("/internal/metrics/funnel", { json: makeFunnelMetrics() }); api.get("/internal/metrics/sign-ins", { json: makeSignInMetrics() }); + api.get("/admin/review-accounts", { + json: { + enabled: false, + emails: [], + codeConfigured: false, + recentSignIns: { + days: 30, + count: 0, + failedVerifications: 0, + lastSignInAt: null, + }, + }, + }); api.get("/admin/reports/authentication-coverage", { json: makeCoverageReport(), }); diff --git a/e2e/tests/overview.spec.ts b/e2e/tests/overview.spec.ts index d48446f..4445e9f 100644 --- a/e2e/tests/overview.spec.ts +++ b/e2e/tests/overview.spec.ts @@ -288,3 +288,64 @@ test.describe("Overview", () => { await expect(page.getByText("999")).toBeVisible(); }); }); + +test.describe("Overview with a ranged API", () => { + test("sends the range to the metrics endpoint and labels tiles by it", async ({ + page, + api, + signInAs, + }) => { + api.get("/internal/metrics/dashboard", ({ url }) => ({ + ...makeDashboardMetrics(), + window: { + from: url.searchParams.get("from") ?? "", + to: url.searchParams.get("to") ?? "", + }, + newUsers: 42, + loginSuccess: 900, + loginFailed: 100, + successRate: 0.9, + otpUsage: 50, + passkeyUsage: 600, + })); + + await signInAs("writeAdmin", "/?range=7d"); + + await expect(statCard(page, "Users")).toContainText("42 new in"); + await expect( + page.getByText("24h auth attempts (fixed window)"), + ).toHaveCount(0); + + const call = api.lastCall("GET", "/internal/metrics/dashboard"); + const params = new URL(call!.url).searchParams; + expect(params.get("from")).not.toBeNull(); + expect(params.get("to")).not.toBeNull(); + }); + + test("warns while store review accounts are enabled", async ({ + page, + api, + signInAs, + }) => { + api.get("/admin/review-accounts", { + json: { + enabled: true, + emails: ["review@example.com"], + codeConfigured: true, + recentSignIns: { + days: 30, + count: 4, + failedVerifications: 0, + lastSignInAt: null, + }, + }, + }); + + await signInAs("writeAdmin"); + + await expect( + page.getByText("Store review accounts are enabled"), + ).toBeVisible(); + await expect(page.getByText(/used to sign in 4 times/)).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index b893a88..741ba79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.8.0", "dependencies": { "@seamless-auth/react": "^0.13.0", - "@seamless-auth/types": "^0.27.0", + "@seamless-auth/types": "^0.28.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.104.1", "clsx": "^2.1.1", @@ -1581,9 +1581,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.27.0.tgz", - "integrity": "sha512-kIrPtdnSOxhDKdxI7NfoRO7pHT23jgpT3pgB8Aa2asGMnasU6pPeBceN1CngUgKpU8ZkOv10mRN44bfgVZGNSA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.28.0.tgz", + "integrity": "sha512-VKb83pSd0KyL608I4pqKxs0KYZNTHlc2CaPula9rtURcgj3eZDky39idSTvsRbHQTlxblvPeNLsgOBnyr+P7FQ==", "license": "Apache-2.0", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index f7f83b6..3bf7fe3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "dependencies": { "@seamless-auth/react": "^0.13.0", - "@seamless-auth/types": "^0.27.0", + "@seamless-auth/types": "^0.28.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.104.1", "clsx": "^2.1.1", diff --git a/src/components/ReviewAccountsNotice.test.tsx b/src/components/ReviewAccountsNotice.test.tsx new file mode 100644 index 0000000..0c1d957 --- /dev/null +++ b/src/components/ReviewAccountsNotice.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ReviewAccountsNotice from "./ReviewAccountsNotice"; + +const useReviewAccounts = vi.hoisted(() => vi.fn()); + +vi.mock("../hooks/useReviewAccounts", () => ({ useReviewAccounts })); + +function accounts(overrides: Record = {}) { + return { + enabled: true, + emails: ["review@example.com", "apple@example.com"], + codeConfigured: true, + recentSignIns: { + days: 30, + count: 2, + failedVerifications: 0, + lastSignInAt: "2026-10-01T12:00:00.000Z", + }, + ...overrides, + }; +} + +describe("ReviewAccountsNotice", () => { + beforeEach(() => { + useReviewAccounts.mockReset(); + }); + + it("warns while review accounts are enabled, with how often the code was used", () => { + useReviewAccounts.mockReturnValue({ data: accounts() }); + + render(); + + expect( + screen.getByText("Store review accounts are enabled"), + ).toBeInTheDocument(); + const description = screen.getByText( + /review@example.com, apple@example.com/, + ); + expect(description).toHaveTextContent( + "used to sign in 2 times in the last 30 days", + ); + expect(description).toHaveTextContent("Clear REVIEW_ACCOUNT_EMAILS"); + }); + + it("says when the code has not been used and reports wrong guesses", () => { + useReviewAccounts.mockReturnValue({ + data: accounts({ + recentSignIns: { + days: 30, + count: 0, + failedVerifications: 3, + lastSignInAt: null, + }, + }), + }); + + render(); + + const description = screen.getByText(/fixed code/); + expect(description).toHaveTextContent( + "has not been used to sign in during the last 30 days", + ); + expect(description).toHaveTextContent("3 wrong codes were entered"); + }); + + it("shows nothing when review accounts are off", () => { + useReviewAccounts.mockReturnValue({ data: accounts({ enabled: false }) }); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("shows nothing when the API or adapter does not know the route", () => { + useReviewAccounts.mockReturnValue({ data: undefined, isError: true }); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/ReviewAccountsNotice.tsx b/src/components/ReviewAccountsNotice.tsx new file mode 100644 index 0000000..3b0e5a6 --- /dev/null +++ b/src/components/ReviewAccountsNotice.tsx @@ -0,0 +1,42 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { StateMessage } from "./StateMessage"; +import { useReviewAccounts } from "../hooks/useReviewAccounts"; + +/** + * Shown while store review accounts are enabled. Their sign-in code never rotates, and + * before this the only sign they were on was a warning in the server's boot log, which + * made it easy to leave them live long after review ended. + */ +export default function ReviewAccountsNotice() { + const { data } = useReviewAccounts(); + + if (!data?.enabled) return null; + + const { count, days, failedVerifications, lastSignInAt } = data.recentSignIns; + const addresses = data.emails.join(", "); + const usage = + count === 0 + ? `The fixed code has not been used to sign in during the last ${days} days.` + : `The fixed code was used to sign in ${count} ${count === 1 ? "time" : "times"} in the last ${days} days${ + lastSignInAt + ? `, most recently ${new Date(lastSignInAt).toLocaleString()}` + : "" + }.`; + const failures = + failedVerifications > 0 + ? ` ${failedVerifications} wrong ${failedVerifications === 1 ? "code was" : "codes were"} entered for these addresses.` + : ""; + + return ( + + ); +} diff --git a/src/hooks/fixedEndpointQueries.test.tsx b/src/hooks/fixedEndpointQueries.test.tsx index 3d99d14..b9cd3d0 100644 --- a/src/hooks/fixedEndpointQueries.test.tsx +++ b/src/hooks/fixedEndpointQueries.test.tsx @@ -96,3 +96,39 @@ describe.each(fixedEndpointHooks)("$name", ({ use, path }) => { expect(result.current.error).toBeInstanceOf(Error); }); }); + +// Ranged since the API took from/to on both (fells-code/seamless-auth-api#132). +describe.each([ + { + name: "useDashboard", + use: useDashboard, + path: "/internal/metrics/dashboard", + }, + { + name: "useAnomalies", + use: useAnomalies, + path: "/internal/security/anomalies", + }, +])("$name with a range", ({ use, path }) => { + beforeEach(() => { + apiFetch.mockReset(); + apiFetch.mockResolvedValue({}); + }); + + it("sends the window", async () => { + const { result } = renderHook( + () => + use({ + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-08T00:00:00.000Z", + }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(apiFetch).toHaveBeenCalledWith( + `${path}?from=2026-03-01T00%3A00%3A00.000Z&to=2026-03-08T00%3A00%3A00.000Z`, + ); + }); +}); diff --git a/src/hooks/useAnomalies.ts b/src/hooks/useAnomalies.ts index a214b1c..6e39788 100644 --- a/src/hooks/useAnomalies.ts +++ b/src/hooks/useAnomalies.ts @@ -4,16 +4,25 @@ * See LICENSE file in the project root for full license information */ -// src/hooks/useAnomalies.ts -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { apiFetch } from "../lib/api"; import type { SecurityAnomaliesResponse } from "@seamless-auth/types"; -export function useAnomalies() { +export function useAnomalies(params: { from?: string; to?: string } = {}) { + const query = new URLSearchParams(); + + if (params.from) query.set("from", params.from); + if (params.to) query.set("to", params.to); + + const search = query.toString(); + return useQuery({ - queryKey: ["anomalies"], + queryKey: ["anomalies", params.from, params.to], queryFn: () => - apiFetch("/internal/security/anomalies"), + apiFetch( + `/internal/security/anomalies${search ? `?${search}` : ""}`, + ), + placeholderData: keepPreviousData, // The monitoring screens describe themselves as live, so revalidate on an // interval and when the operator returns to the tab. Manual refresh stays // available for anything more immediate. diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts index 4bfc09a..bf08808 100644 --- a/src/hooks/useDashboard.ts +++ b/src/hooks/useDashboard.ts @@ -4,15 +4,25 @@ * See LICENSE file in the project root for full license information */ -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { apiFetch } from "../lib/api"; import type { DashboardMetricsResponse } from "@seamless-auth/types"; -export function useDashboard() { +export function useDashboard(params: { from?: string; to?: string } = {}) { + const query = new URLSearchParams(); + + if (params.from) query.set("from", params.from); + if (params.to) query.set("to", params.to); + + const search = query.toString(); + return useQuery({ - queryKey: ["dashboard"], + queryKey: ["dashboard", params.from, params.to], queryFn: () => - apiFetch("/internal/metrics/dashboard"), + apiFetch( + `/internal/metrics/dashboard${search ? `?${search}` : ""}`, + ), + placeholderData: keepPreviousData, // The monitoring screens describe themselves as live, so revalidate on an // interval and when the operator returns to the tab. Manual refresh stays // available for anything more immediate. diff --git a/src/hooks/useReviewAccounts.ts b/src/hooks/useReviewAccounts.ts new file mode 100644 index 0000000..bf988a5 --- /dev/null +++ b/src/hooks/useReviewAccounts.ts @@ -0,0 +1,32 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { useQuery } from "@tanstack/react-query"; +import { apiFetch } from "../lib/api"; + +// Restated from the auth API's response schema. It is not in @seamless-auth/types. +export type ReviewAccounts = { + enabled: boolean; + emails: string[]; + codeConfigured: boolean; + recentSignIns: { + days: number; + count: number; + failedVerifications: number; + lastSignInAt: string | null; + }; +}; + +export function useReviewAccounts() { + return useQuery({ + queryKey: ["review-accounts"], + queryFn: () => apiFetch("/admin/review-accounts"), + // Only ever a notice. An API or adapter that predates the route answers 404, + // which is not worth retrying or reporting. + retry: false, + staleTime: 5 * 60_000, + }); +} diff --git a/src/lib/dashboardFigures.test.ts b/src/lib/dashboardFigures.test.ts new file mode 100644 index 0000000..ea4a0d3 --- /dev/null +++ b/src/lib/dashboardFigures.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { describe, expect, it } from "vitest"; +import { dashboardFigures } from "./dashboardFigures"; + +const fixed = { + totalUsers: 40, + activeSessions: 12, + databaseSize: 1024, + newUsers24h: 3, + loginSuccess24h: 90, + loginFailed24h: 10, + successRate24h: 0.9, + otpUsage24h: 5, + passkeyUsage24h: 55, +}; + +describe("dashboardFigures", () => { + it("uses the ranged figures when the API reports a window", () => { + expect( + dashboardFigures({ + ...fixed, + window: { from: "a", to: "b" }, + newUsers: 21, + loginSuccess: 700, + loginFailed: 300, + successRate: 0.7, + otpUsage: 40, + passkeyUsage: 410, + }), + ).toEqual({ + ranged: true, + newUsers: 21, + loginSuccess: 700, + loginFailed: 300, + successRate: 0.7, + passkeyUsage: 410, + }); + }); + + it("falls back to the fixed 24 hours for an API without ranges", () => { + expect(dashboardFigures(fixed)).toEqual({ + ranged: false, + newUsers: 3, + loginSuccess: 90, + loginFailed: 10, + successRate: 0.9, + passkeyUsage: 55, + }); + }); + + it("reports zeros while nothing has loaded", () => { + expect(dashboardFigures(undefined)).toEqual( + expect.objectContaining({ ranged: false, loginSuccess: 0 }), + ); + }); +}); diff --git a/src/lib/dashboardFigures.ts b/src/lib/dashboardFigures.ts new file mode 100644 index 0000000..eed8a38 --- /dev/null +++ b/src/lib/dashboardFigures.ts @@ -0,0 +1,46 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import type { DashboardMetricsResponse } from "@seamless-auth/types"; + +export interface DashboardFigures { + /** True when the figures cover the requested range, false when they are the fixed 24 hours. */ + ranged: boolean; + newUsers: number; + loginSuccess: number; + loginFailed: number; + successRate: number; + passkeyUsage: number; +} + +/** + * The figures that follow the range when the API reports them, and the fixed 24-hour + * ones from an API that predates ranges. The page labels them by `ranged`, so a tile + * never claims to follow the range selector while showing a fixed window. + */ +export function dashboardFigures( + data: DashboardMetricsResponse | undefined, +): DashboardFigures { + if (data?.window && data.loginSuccess !== undefined) { + return { + ranged: true, + newUsers: data.newUsers ?? 0, + loginSuccess: data.loginSuccess, + loginFailed: data.loginFailed ?? 0, + successRate: data.successRate ?? 0, + passkeyUsage: data.passkeyUsage ?? 0, + }; + } + + return { + ranged: false, + newUsers: data?.newUsers24h ?? 0, + loginSuccess: data?.loginSuccess24h ?? 0, + loginFailed: data?.loginFailed24h ?? 0, + successRate: data?.successRate24h ?? 0, + passkeyUsage: data?.passkeyUsage24h ?? 0, + }; +} diff --git a/src/pages/Overview.test.tsx b/src/pages/Overview.test.tsx index b7a2b11..a0ff4bb 100644 --- a/src/pages/Overview.test.tsx +++ b/src/pages/Overview.test.tsx @@ -18,6 +18,9 @@ const mocks = vi.hoisted(() => ({ refetch: vi.fn(), })); +// Covered by its own tests; it fetches from a route this suite does not mock. +vi.mock("../components/ReviewAccountsNotice", () => ({ default: () => null })); + vi.mock("../hooks/useDashboard", () => ({ useDashboard: mocks.useDashboard })); vi.mock("../hooks/useAuthTimeseries", () => ({ useAuthTimeseries: mocks.useAuthTimeseries, @@ -203,6 +206,43 @@ describe("Overview", () => { ).toBeInTheDocument(); }); + it("asks for the selected range and follows it when the API reports one", () => { + mocks.useDashboard.mockReturnValue({ + data: { + ...dashboard, + window: { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-08T00:00:00.000Z", + }, + newUsers: 21, + loginSuccess: 700, + loginFailed: 300, + successRate: 0.7, + otpUsage: 40, + passkeyUsage: 410, + }, + isLoading: false, + isError: false, + error: null, + refetch: mocks.refetch, + isFetching: false, + }); + + renderPage("/?range=7d"); + + const { from, to } = mocks.useAuthTimeseries.mock.lastCall![0]; + expect(mocks.useDashboard).toHaveBeenLastCalledWith({ from, to }); + expect( + screen.queryByText("24h auth attempts (fixed window)"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/Deployment metrics cover a fixed 24-hour window/), + ).not.toBeInTheDocument(); + expect(screen.getByText("1,000")).toBeInTheDocument(); + expect(screen.getByText("410")).toBeInTheDocument(); + expect(screen.getByText(/21 new in/)).toBeInTheDocument(); + }); + it("scopes the chart copy to the selected window", () => { renderPage("/?range=7d"); diff --git a/src/pages/Overview.tsx b/src/pages/Overview.tsx index 9503520..c601467 100644 --- a/src/pages/Overview.tsx +++ b/src/pages/Overview.tsx @@ -9,6 +9,7 @@ import { useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { ArrowRight, ShieldAlert, Users, Waves } from "lucide-react"; import { useDashboard } from "../hooks/useDashboard"; +import { dashboardFigures } from "../lib/dashboardFigures"; import RefreshControl from "../components/RefreshControl"; import { useAuthTimeseries } from "../hooks/useAuthTimeseries"; import { useGroupedEvents } from "../hooks/useGroupedEvents"; @@ -23,6 +24,7 @@ import StatCard from "../components/StatCard"; import { Section } from "../components/Section"; import { QueryErrorState, StateMessage } from "../components/StateMessage"; import RangeFilter from "../components/RangeFilter"; +import ReviewAccountsNotice from "../components/ReviewAccountsNotice"; import { getErrorMessage } from "../lib/errorMessage"; import { formatBytes } from "../lib/formatBytes"; import { formatDuration } from "../lib/formatDuration"; @@ -87,7 +89,7 @@ export default function Overview() { refetch, isFetching, dataUpdatedAt, - } = useDashboard(); + } = useDashboard(bounds); const { data: timeseries, isError: timeseriesError, @@ -122,13 +124,15 @@ export default function Overview() { ? pivotBreakdown(signIns.breakdown, "mailProvider") : []; - const totalAttempts = - (data?.loginSuccess24h ?? 0) + (data?.loginFailed24h ?? 0); + const figures = dashboardFigures(data); + // Said wherever a figure appears, so a tile never claims to follow the range + // selector while an API that predates ranges is still giving it 24 hours. + const figuresWindow = figures.ranged ? rangeLabel : "the last 24 hours"; + const totalAttempts = figures.loginSuccess + figures.loginFailed; // With no attempts in the window there is no rate to report. Deriving one // anyway rendered "100%" next to "Elevated enough to merit review" on the // landing screen, which reads as a complete authentication outage. - const failureRate = - totalAttempts > 0 ? 1 - (data?.successRate24h ?? 0) : null; + const failureRate = totalAttempts > 0 ? 1 - figures.successRate : null; const busiestBucket = timeseries?.timeseries.length ? timeseries.timeseries.reduce((best, point) => { @@ -160,6 +164,8 @@ export default function Overview() { return (
+ +
@@ -176,9 +182,10 @@ export default function Overview() {

Overview

A live snapshot of authentication health, growth, and operator - attention areas across your Seamless Auth deployment. The - range below drives the activity chart and the event - distribution. + attention areas across your Seamless Auth deployment.{" "} + {figures.ranged + ? "The range below drives every figure on this screen apart from the current totals." + : "The range below drives the activity chart and the event distribution."}

@@ -204,7 +211,11 @@ export default function Overview() {
navigate("/users")} actionLabel="Review Users" /> @@ -244,8 +255,8 @@ export default function Overview() { navigate("/events")} actionLabel="Inspect Events" /> @@ -256,9 +267,9 @@ export default function Overview() {

- Deployment metrics cover a fixed 24-hour window. The metrics endpoint - takes no date range, so these figures do not follow the range - selector. + {figures.ranged + ? `Users, Database and Sessions are current totals. The login figures cover ${rangeLabel}.` + : "Deployment metrics cover a fixed 24-hour window. This API's metrics endpoint takes no date range, so these figures do not follow the range selector."}

@@ -271,7 +282,7 @@ export default function Overview() {
0 ? "danger" : "neutral"} + tone={figures.loginFailed > 0 ? "danger" : "neutral"} title="Failed logins" - value={`${data?.loginFailed24h ?? 0}`} + value={`${figures.loginFailed}`} description="Recent login failures are the fastest signal of friction or abuse." actionLabel="Investigate security" onClick={() => navigate("/security")} /> - {/* passkeyUsage24h counts passkey sign-ins. Adoption, the share of + {/* passkeyUsage counts passkey sign-ins. Adoption, the share of accounts holding one, is the funnel figure above, and the two used to share a title. */} navigate("/events")} @@ -519,7 +530,7 @@ export default function Overview() { navigate("/users")} diff --git a/src/pages/Security.test.tsx b/src/pages/Security.test.tsx index 7a1ea9c..86b3999 100644 --- a/src/pages/Security.test.tsx +++ b/src/pages/Security.test.tsx @@ -100,16 +100,47 @@ describe("Security", () => { ).toBeInTheDocument(); }); - it("says the anomaly feed does not follow the range selector", () => { - // /internal/security/anomalies takes no parameters, so this table would - // otherwise look like it was responding to the picker. + it("labels the anomaly feed as fixed when the API reports no window", () => { + // An API that predates ranges ignores the bounds, so the table must not + // look like it was responding to the picker. renderPage(); + expect(screen.getByText(/feed takes no date range/)).toBeInTheDocument(); expect( - screen.getByText(/The feed takes no date range/), + screen.getByText("Suspicious signals (fixed window)"), ).toBeInTheDocument(); + }); + + it("asks for the selected range and follows it when the API reports one", () => { + mocks.useAnomalies.mockReturnValue({ + data: { + suspiciousEvents, + total: 1, + window: { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-02T00:00:00.000Z", + }, + }, + isLoading: false, + isError: false, + error: null, + refetch: mocks.refetch, + isFetching: false, + }); + + renderPage(); + + expect(mocks.useAnomalies).toHaveBeenCalledWith( + mocks.useLoginStats.mock.calls[0]![0], + ); expect( - screen.getByText("Suspicious signals (fixed window)"), + screen.queryByText(/feed takes no date range/), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Suspicious signals (fixed window)"), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/The range below drives every figure on this screen/), ).toBeInTheDocument(); }); diff --git a/src/pages/Security.tsx b/src/pages/Security.tsx index 11b759e..fd0a6fe 100644 --- a/src/pages/Security.tsx +++ b/src/pages/Security.tsx @@ -83,7 +83,7 @@ export default function Security() { error: anomaliesErrorValue, refetch: refetchAnomalies, isFetching: fetchingAnomalies, - } = useAnomalies(); + } = useAnomalies(bounds); const { data: stats, isLoading: loadingStats, @@ -104,6 +104,12 @@ export default function Security() { const returnedCount = suspiciousEvents.length; const reportedTotal = anomalies?.total ?? returnedCount; const isTruncated = reportedTotal > returnedCount; + // An API that predates ranges ignores the bounds and echoes no window, so the feed + // is labelled by what actually came back rather than by what was asked for. + const anomaliesRanged = anomalies?.window !== undefined; + const anomaliesWindow = anomaliesRanged + ? rangeLabel + : "the feed's own fixed window"; const exportSignals = () => exportCsv( @@ -196,8 +202,10 @@ export default function Security() {

Security

Review failed logins, suspicious event types, and the spread - of anomalous activity across IPs and recent auth traffic. The - range below drives the login statistics. + of anomalous activity across IPs and recent auth traffic.{" "} + {anomaliesRanged + ? "The range below drives every figure on this screen." + : "The range below drives the login statistics."}

@@ -209,7 +217,11 @@ export default function Security() {
@@ -323,7 +335,11 @@ export default function Security() {