From a8ddfc65eccedd256d28b2ece1e490b2c4085a23 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 9 Oct 2026 16:09:27 +0000 Subject: [PATCH] feat: isolate admin sign-in caches, retain cached probe outcomes and clear recovered mailbox issues per measured source --- admin/src/app/auth/LoginPage.test.tsx | 81 +++++++++++++++++++ admin/src/app/auth/LoginPage.tsx | 5 ++ .../dashboard/health/HealthCoverage.test.tsx | 10 ++- .../src/app/dashboard/health/ServicesTab.tsx | 4 +- docs/content/docs/development/admin-panel.mdx | 2 + .../docs/development/instance-health.mdx | 2 +- web/src/app/app/emails/page.test.tsx | 35 ++++++-- web/src/app/app/emails/page.tsx | 29 ++++--- .../hooks/app/analytics/useAccountStatuses.ts | 2 +- 9 files changed, 148 insertions(+), 22 deletions(-) create mode 100644 admin/src/app/auth/LoginPage.test.tsx diff --git a/admin/src/app/auth/LoginPage.test.tsx b/admin/src/app/auth/LoginPage.test.tsx new file mode 100644 index 000000000..c286f0dd8 --- /dev/null +++ b/admin/src/app/auth/LoginPage.test.tsx @@ -0,0 +1,81 @@ +import { act, type PropsWithChildren } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LoginPage from "./LoginPage"; + +const state = vi.hoisted(() => ({ + login: vi.fn(), confirm: vi.fn(), verify: vi.fn(), saveToken: vi.fn(), + onToken: (_value: string): Promise => Promise.resolve(), + onCode: (_value: string): void => {}, +})); +vi.mock("@/lib/api/client/auth", () => ({ getAuthConfig: () => Promise.resolve({ captcha: false }), login: state.login, loginConfirm: state.confirm, verifyTwoFA: state.verify })); +vi.mock("@/lib/auth/storage", () => ({ setToken: state.saveToken })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("motion/react", () => ({ AnimatePresence: ({ children }: PropsWithChildren) => children, motion: { div: ({ children }: PropsWithChildren) =>
{children}
} })); +vi.mock("@/components/captcha/TurnstileModal", () => ({ TurnstileModal: ({ onToken }: { onToken: typeof state.onToken }) => { state.onToken = onToken; return null; } })); +vi.mock("@/components/ui/input-otp", () => ({ InputOTP: ({ onChange }: { onChange: typeof state.onCode }) => { state.onCode = onChange; return null; }, InputOTPGroup: () => null, InputOTPSlot: () => null })); + +const token = { access_token: "fixture-access", refresh_token: "fixture-refresh", access_token_expires_at: "2099-01-01T00:00:00Z", refresh_token_expires_at: "2099-01-01T00:00:00Z" }; +let root: Root; +let client: QueryClient; +let clear: ReturnType; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(["me"], { id: "previous-admin", admin_permissions: 0xffff }); + client.setQueryData(["system", "mail-status"], { private: "previous-session" }); + clear = vi.spyOn(client, "clear"); + root = createRoot(document.createElement("div")); + await act(async () => root.render()); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function expectClearedBeforeToken() { + expect(client.getQueryCache().getAll()).toHaveLength(0); + expect(state.saveToken).toHaveBeenCalledWith(token); + expect(clear.mock.invocationCallOrder[0]).toBeLessThan(state.saveToken.mock.invocationCallOrder[0]); +} + +describe("admin sign-in session isolation", () => { + it("clears cached identity and private data before a direct successful login", async () => { + state.login.mockResolvedValue({ code_required: false, token }); + await act(async () => state.onToken("fixture-captcha")); + expectClearedBeforeToken(); + }); + + it("keeps cache during an email-code challenge and clears it on confirmation", async () => { + state.login.mockResolvedValue({ code_required: true, session: "fixture-session" }); + state.confirm.mockResolvedValue(token); + await act(async () => state.onToken("fixture-captcha")); + expect(clear).not.toHaveBeenCalled(); + await act(async () => state.onCode("123456")); + expectClearedBeforeToken(); + }); + + it("keeps cache during a second-factor challenge and clears it on verification", async () => { + state.login.mockResolvedValue({ code_required: false, two_fa_required: true, pending_token: "fixture-pending" }); + state.verify.mockResolvedValue(token); + await act(async () => state.onToken("fixture-captcha")); + expect(clear).not.toHaveBeenCalled(); + await act(async () => state.onCode("123456")); + expectClearedBeforeToken(); + }); + + it("does not replace the session or clear caches on a failed login", async () => { + state.login.mockRejectedValue(new Error("fixture-login-failure")); + await act(async () => state.onToken("fixture-captcha")); + expect(clear).not.toHaveBeenCalled(); + expect(state.saveToken).not.toHaveBeenCalled(); + expect(client.getQueryCache().getAll()).toHaveLength(2); + }); +}); diff --git a/admin/src/app/auth/LoginPage.tsx b/admin/src/app/auth/LoginPage.tsx index ea63e38a1..6886215f1 100644 --- a/admin/src/app/auth/LoginPage.tsx +++ b/admin/src/app/auth/LoginPage.tsx @@ -14,6 +14,7 @@ import { useEffect, useRef, useState, type FormEvent } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { AnimatePresence, motion, type Transition } from "motion/react"; +import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Eye, EyeOff, Loader2, ArrowLeft, ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -40,6 +41,7 @@ function errorMessage(err: unknown): string { export default function LoginPage() { const nav = useNavigate(); + const queryClient = useQueryClient(); const loc = useLocation(); const [phase, setPhase] = useState<"credentials" | "code" | "twofa">("credentials"); const [pendingToken, setPendingToken] = useState(""); @@ -118,6 +120,7 @@ export default function LoginPage() { return; } if (res.token) { + queryClient.clear(); setToken(res.token); const dest = (loc.state as { from?: string } | null)?.from ?? "/"; nav(dest, { replace: true }); @@ -166,6 +169,7 @@ export default function LoginPage() { return; } if (!res.access_token) throw new Error("The server returned an unexpected sign-in response."); + queryClient.clear(); setToken(res as LoginResponse); const dest = (loc.state as { from?: string } | null)?.from ?? "/"; nav(dest, { replace: true }); @@ -188,6 +192,7 @@ export default function LoginPage() { setSubmitting(true); try { const tok = await verifyTwoFA({ pending_token: pendingToken, code: value }); + queryClient.clear(); setToken(tok); const dest = (loc.state as { from?: string } | null)?.from ?? "/"; nav(dest, { replace: true }); diff --git a/admin/src/app/dashboard/health/HealthCoverage.test.tsx b/admin/src/app/dashboard/health/HealthCoverage.test.tsx index 56b619028..0dd508946 100644 --- a/admin/src/app/dashboard/health/HealthCoverage.test.tsx +++ b/admin/src/app/dashboard/health/HealthCoverage.test.tsx @@ -74,7 +74,15 @@ describe("configuration and service coverage", () => { state.services.data!.data = [{ name: "postgres", ok: true, latency_ms: 5 }]; state.services.data!.checked_at = new Date(Date.now() - 300_000).toISOString(); expect(render("services")).toContain("Current service coverage unknown"); - expect(render("services")).toContain("Cached probe"); + expect(render("services")).toContain("Cached: passed"); + expect(render("services")).not.toContain("Probe passed"); + }); + + it("retains a cached failure even when the earlier probe supplied no error details", () => { + state.services.data!.data = [{ name: "postgres", ok: true, latency_ms: 5 }, { name: "redis", ok: false, latency_ms: 20 }]; + state.services.isError = true; + expect(render("services")).toContain("Cached: passed"); + expect(render("services")).toContain("Cached: failed"); expect(render("services")).not.toContain("Probe passed"); }); }); diff --git a/admin/src/app/dashboard/health/ServicesTab.tsx b/admin/src/app/dashboard/health/ServicesTab.tsx index 3d8315255..93e80a786 100644 --- a/admin/src/app/dashboard/health/ServicesTab.tsx +++ b/admin/src/app/dashboard/health/ServicesTab.tsx @@ -162,8 +162,8 @@ function ComponentRow({ component: c, cached }: { component: SystemComponentStat {c.latency_ms} ms - - {cached ? "Cached probe" : c.ok ? "Probe passed" : "Probe failed"} + + {cached ? c.ok ? "Cached: passed" : "Cached: failed" : c.ok ? "Probe passed" : "Probe failed"} diff --git a/docs/content/docs/development/admin-panel.mdx b/docs/content/docs/development/admin-panel.mdx index 50ba0ece3..e72bb0a1e 100644 --- a/docs/content/docs/development/admin-panel.mdx +++ b/docs/content/docs/development/admin-panel.mdx @@ -9,6 +9,8 @@ The admin panel is the operator's view of an instance: every worker, user, organ The panel runs on `:5174` in the default stack (`http://localhost:5174` on a stock install, or the host you proxied to it). Sign in with the same account as the dashboard. The first owner holds every admin bit from the moment they claim the instance, and `warmblyctl user create --admin` creates a further account that already holds them. A page the account has no bit for says which bit it needs instead of rendering. +A completed admin sign-in discards previously cached operator data and permissions before storing the new session. An unsuccessful sign-in or a pending email-code/second-factor step does not replace the session. + Every admin route refuses a session that did not present a second factor. Turn on 2FA or add a passkey under **Settings > Security** in the dashboard, then sign in again; the panel says so rather than failing silently. diff --git a/docs/content/docs/development/instance-health.mdx b/docs/content/docs/development/instance-health.mdx index 847a7cbdc..a413b3ac3 100644 --- a/docs/content/docs/development/instance-health.mdx +++ b/docs/content/docs/development/instance-health.mdx @@ -82,7 +82,7 @@ The backend collects with an eight-second overall timeout, three seconds per sou - Counts, affected mailboxes, affected organizations and rows with unknown organization are separate quantities. Metrics and per-worker scopes may overlap. The attention total counts recorded signals, not failed sends or unique customers; do not add metric counts together. - Query errors, permission denials, unsupported capability, missing dependencies/schema and timeouts do not produce an all-clear. No sources or no metrics means unknown coverage. Raw provider/connection errors, credentials, payloads and mailbox addresses are not rendered by the monitoring panel. -The Overview and Operations tab share the same frontend query cache, scoped to admin identity and permission mask. Monitoring polls at most once per minute while mounted in a visible tab, does not refetch on focus or retry automatically, and offers a refresh control that respects `refresh_after`. Service probes poll every 15 seconds while their tab is mounted; passed probes establish reachability at that time only. Failed or stale probes remain explicit and raw component connection errors are withheld. The existing platform mail card is shown only with `manage_settings`, which its inspection and send-test endpoints require. +The Overview and Operations tab share the same frontend query cache, scoped to admin identity and permission mask. Monitoring polls at most once per minute while mounted in a visible tab, does not refetch on focus or retry automatically, and offers a refresh control that respects `refresh_after`. Service probes poll every 15 seconds while their tab is mounted; passed probes establish reachability at that time only. Failed or stale probes remain explicit, including whether a cached probe previously passed or failed, and raw component connection errors are withheld. The existing platform mail card is shown only with `manage_settings`, which its inspection and send-test endpoints require. This is observation-only tooling. It does not subscribe to brokers, create streams/groups, reconnect mailboxes, move assignments or offsets, reclaim tasks, replay results, clear holds, notify customers or deploy changes. Investigation links open existing admin pages when those pages cover the measured population; actions there retain their existing confirmations and permissions. Aggregate dispatch and send measurements have no direct link because the existing campaign-only detail tabs exclude some counted pending or warmup tasks. Keep the count and its evidence time in view while investigating through authorized backend diagnostics. diff --git a/web/src/app/app/emails/page.test.tsx b/web/src/app/app/emails/page.test.tsx index a5af337c5..36108d8c3 100644 --- a/web/src/app/app/emails/page.test.tsx +++ b/web/src/app/app/emails/page.test.tsx @@ -9,7 +9,7 @@ import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; import AddressesPage from "./page"; const { tags, rows, statusState } = vi.hoisted(() => ({ - statusState: { data: [] as AccountStatus[], observations: [] as { status: AccountStatus; observedAt: number }[], isLoading: false, isFetching: false, isError: false, cloudLoading: false, cloudUnavailable: false }, + statusState: { data: [] as AccountStatus[], observations: [] as { status: AccountStatus; observedAt: number; current: boolean }[], isLoading: false, isFetching: false, isError: false, cloudLoading: false, cloudUnavailable: false, emailsLoading: false }, tags: [ { id: "sending", title: "Sending account", color: "#0088cc", position: 0 }, { id: "serveblink", title: "Serveblink.com", color: "#008800", position: 1 }, @@ -30,7 +30,7 @@ vi.mock("@/hooks/context/user", async (original) => ({ vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: vi.fn() }) })); vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true })); vi.mock("@/lib/api/hooks/app/emails/useEmails", () => ({ - default: ({ tag }: { tag: string }) => ({ emails: tag ? rows.filter((row) => row.tags.includes(tag)) : rows }), + default: ({ tag }: { tag: string }) => ({ emails: statusState.emailsLoading ? [] : tag ? rows.filter((row) => row.tags.includes(tag)) : rows, isLoading: statusState.emailsLoading, isPending: statusState.emailsLoading }), })); vi.mock("@/lib/api/hooks/app/analytics/useAccountStatuses", () => ({ default: () => statusState })); vi.mock("@/lib/api/hooks/app/subscription/useFeatureStatus", () => ({ default: () => ({ data: { can_use_warmup: true } }) })); @@ -142,14 +142,23 @@ describe("mailbox status while account and Cloud checks settle", () => { sessionStorage.clear(); vi.spyOn(window, "scrollTo").mockImplementation(() => {}); statusState.data = []; + statusState.observations = []; statusState.isLoading = false; statusState.isFetching = false; statusState.isError = false; statusState.cloudLoading = false; statusState.cloudUnavailable = false; + statusState.emailsLoading = false; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + it("keeps summary measurements pending until the mailbox inventory loads", async () => { + statusState.emailsLoading = true; + await renderPage(); + expect(screen.getAllByText("Checking…").length).toBeGreaterThanOrEqual(4); + expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument(); + }); + it("does not show Idle or Healthy while Cloud and account status are loading, then shows the real error", async () => { statusState.cloudLoading = true; statusState.data = rows.map(measuredStatus); @@ -195,7 +204,7 @@ describe("mailbox status while account and Cloud checks settle", () => { it("retains a recently observed error as historical evidence while refreshing, then clears it on a fresh result", async () => { statusState.data = rows.map(measuredStatus); statusState.data[0] = { ...measuredStatus(rows[0]), health: { status: "error", score: 40 }, errors: [{ id: "error-1", error_code: "PROVIDER_UNAVAILABLE", severity: "critical", title: "Provider unavailable", message: "Provider refused the last attempt", created_at: new Date("2026-10-09T00:00:00Z") }] }; - statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now() })); + statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now(), current: true })); await renderPage(); expect(screen.getByText("Issue 40")).toBeInTheDocument(); statusState.isFetching = true; @@ -212,7 +221,7 @@ describe("mailbox status while account and Cloud checks settle", () => { expect(screen.getAllByText("Last reported issue").length).toBeGreaterThan(0); statusState.data = rows.map(measuredStatus); - statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now() })); + statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now(), current: true })); statusState.cloudLoading = false; fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); await waitFor(() => expect(screen.queryAllByText("Last reported issue")).toHaveLength(0)); @@ -223,7 +232,7 @@ describe("mailbox status while account and Cloud checks settle", () => { statusState.data = rows.map(measuredStatus); statusState.data[0] = { ...measuredStatus(rows[0]), health: { status: "error", score: 40 }, errors: [{ id: "error-1", error_code: "PROVIDER_UNAVAILABLE", severity: "critical", title: "Provider unavailable", message: "Provider refused the last attempt", created_at: new Date("2026-10-09T00:00:00Z") }] }; const observedAt = Date.now(); - statusState.observations = statusState.data.map((status) => ({ status, observedAt })); + statusState.observations = statusState.data.map((status) => ({ status, observedAt, current: true })); await renderPage(); statusState.isFetching = true; fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); @@ -233,4 +242,20 @@ describe("mailbox status while account and Cloud checks settle", () => { expect(screen.queryAllByText("Last reported issue")).toHaveLength(0); clock.mockRestore(); }); + + it("does not resurrect a recovered mailbox's old issue when another mailbox is missing", async () => { + statusState.data = rows.map(measuredStatus); + statusState.data[0] = { ...measuredStatus(rows[0]), health: { status: "error", score: 40 }, errors: [{ id: "error-1", error_code: "PROVIDER_UNAVAILABLE", severity: "critical", title: "Provider unavailable", message: "Provider refused the last attempt", created_at: new Date("2026-10-09T00:00:00Z") }] }; + statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now(), current: true })); + await renderPage(); + statusState.data = [measuredStatus(rows[0])]; + statusState.observations = statusState.data.map((status) => ({ status, observedAt: Date.now(), current: true })); + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + await waitFor(() => expect(screen.getByText("Healthy 100")).toBeInTheDocument()); + statusState.isFetching = true; + statusState.observations = statusState.observations.map((observation) => ({ ...observation, current: false })); + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + expect(screen.queryAllByText("Last reported issue")).toHaveLength(0); + expect(screen.queryByText("Last observed: Issue 40")).not.toBeInTheDocument(); + }); }); diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 2333dbf09..96adddb45 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -170,7 +170,7 @@ export default function AddressesPage() { // Self-hosted instances can hand warmup to the Warmbly pool; the banner, // row badges and menu items below key off this. const cloud = useCloudPool(); - const { connected: cloudConnected, rowFor: cloudRowFor } = cloud; + const { connected: cloudConnected, rowFor: cloudRowFor, unavailable: cloudUnavailable } = cloud; const [cloudDialog, setCloudDialog] = React.useState(false); const authConfigLoading = useAuthConfig().isLoading; @@ -201,7 +201,7 @@ export default function AddressesPage() { for (const s of accountStatuses) m.set(s.id, s); return m; }, [accountStatuses]); - const statusChecking = cloudChecking || statuses.isLoading || statuses.isFetching; + const statusChecking = emailsData.isLoading || cloudChecking || statuses.isLoading || statuses.isFetching; const cloudStandingUnavailable = cloud.connected && (emailsData.emails ?? []).some((box) => { const row = cloud.rowFor(box.id); return row?.enrolled && !row.cloud?.health; @@ -213,24 +213,29 @@ export default function AddressesPage() { const next = new Map(previous); const now = Date.now(); for (const [id, issue] of next) if (now - issue.observedAt > LAST_REPORTED_ISSUE_MS) next.delete(id); - for (const { status, observedAt } of statuses.observations ?? []) { + for (const { status, observedAt, current } of statuses.observations ?? []) { if (!observedAt || now - observedAt > LAST_REPORTED_ISSUE_MS || observedAt < (next.get(status.id)?.observedAt ?? 0)) continue; if (status.errors?.length || (status.health && status.health.status !== "healthy")) { next.set(status.id, { status, observedAt }); - } else if (!statusChecking && !statusUnavailable) { - next.delete(status.id); + } else if (current && !cloudChecking && !cloudUnavailable) { + const linked = cloudConnected ? cloudRowFor(status.id) : undefined; + if (!linked?.enrolled || linked.cloud?.health) next.delete(status.id); } } return next.size === previous.size && [...next].every(([id, value]) => previous.get(id)?.status === value.status && previous.get(id)?.observedAt === value.observedAt) ? previous : next; }); - }, [statuses.observations, statusChecking, statusUnavailable]); + }, [statuses.observations, cloudChecking, cloudUnavailable, cloudConnected, cloudRowFor]); useEffect(() => { - const interval = setInterval(() => setLastReportedIssues((previous) => { - const next = new Map([...previous].filter(([, issue]) => Date.now() - issue.observedAt <= LAST_REPORTED_ISSUE_MS)); + let expiresAt = Infinity; + for (const issue of lastReportedIssues.values()) expiresAt = Math.min(expiresAt, issue.observedAt + LAST_REPORTED_ISSUE_MS); + if (!Number.isFinite(expiresAt)) return; + const timeout = setTimeout(() => setLastReportedIssues((previous) => { + const now = Date.now(); + const next = new Map([...previous].filter(([, issue]) => now - issue.observedAt <= LAST_REPORTED_ISSUE_MS)); return next.size === previous.size ? previous : next; - }), 30_000); - return () => clearInterval(interval); - }, []); + }), Math.max(0, expiresAt - Date.now()) + 1); + return () => clearTimeout(timeout); + }, [lastReportedIssues]); // Proactively notify the user when a mailbox's health drops. const prevHealth = useRef>(new Map()); useEffect(() => { @@ -454,7 +459,7 @@ export default function AddressesPage() { - } sub="connected" /> + } sub="connected" /> } sub="no reported health issues" accent={!statusChecking && !statusUnavailable && stats.healthy > 0} /> } sub="ramping up" /> } sub="paused or failing" last /> diff --git a/web/src/lib/api/hooks/app/analytics/useAccountStatuses.ts b/web/src/lib/api/hooks/app/analytics/useAccountStatuses.ts index 8fe6504d2..f6355053b 100644 --- a/web/src/lib/api/hooks/app/analytics/useAccountStatuses.ts +++ b/web/src/lib/api/hooks/app/analytics/useAccountStatuses.ts @@ -34,7 +34,7 @@ export default function useAccountStatuses(emailIds: string[], options?: { enabl // stable across renders until a chunk actually updates. combine: (results) => ({ data: results.flatMap((r) => (r.data ?? []) as AccountStatus[]), - observations: results.flatMap((r) => (r.data ?? []).map((status) => ({ status, observedAt: r.dataUpdatedAt }))), + observations: results.flatMap((r) => (r.data ?? []).map((status) => ({ status, observedAt: r.dataUpdatedAt, current: r.isSuccess && !r.isFetching }))), isLoading: results.some((r) => r.isLoading), isFetching: results.some((r) => r.isFetching), isError: results.some((r) => r.isError),