Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/ranged-metrics-review-notice.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 13 additions & 0 deletions e2e/personas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
61 changes: 61 additions & 0 deletions e2e/tests/overview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 88 additions & 0 deletions src/components/ReviewAccountsNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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(<ReviewAccountsNotice />);

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(<ReviewAccountsNotice />);

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(<ReviewAccountsNotice />);

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(<ReviewAccountsNotice />);

expect(container).toBeEmptyDOMElement();
});
});
42 changes: 42 additions & 0 deletions src/components/ReviewAccountsNotice.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<StateMessage
tone="warning"
title="Store review accounts are enabled"
description={`${addresses} can sign in with a fixed code that never changes. ${usage}${failures} Clear REVIEW_ACCOUNT_EMAILS on the API once store review is over.`}
/>
);
}
36 changes: 36 additions & 0 deletions src/hooks/fixedEndpointQueries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
});
});
19 changes: 14 additions & 5 deletions src/hooks/useAnomalies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SecurityAnomaliesResponse>("/internal/security/anomalies"),
apiFetch<SecurityAnomaliesResponse>(
`/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.
Expand Down
18 changes: 14 additions & 4 deletions src/hooks/useDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DashboardMetricsResponse>("/internal/metrics/dashboard"),
apiFetch<DashboardMetricsResponse>(
`/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.
Expand Down
32 changes: 32 additions & 0 deletions src/hooks/useReviewAccounts.ts
Original file line number Diff line number Diff line change
@@ -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<ReviewAccounts>("/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,
});
}
Loading
Loading