Merge branch 'fix/v1474-auth' into release/v1.47.4

# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:32:28 +01:00
commit 212ff9ee55
33 files changed
+1177 -151

No files matched your search

@@ -0,0 +1,143 @@
/**
* Taking over a managed profile, driven by the claim page's buttons.
*
* Both ways in start a session for a different account than whatever this
* browser held before: the password claim stays in the single-page app,
* starts from an empty cache and replaces the claim page in the history; the
* single-sign-on claim leaves for the identity provider and clears the
* offline copies before it goes.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { queryKeys } from "@/lib/query-keys";
import {
stubFetch,
type FetchStub,
} from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { recordHistory } from "../../../../tests/browser/url";
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
const loadDocument = vi.hoisted(() => vi.fn());
const showErrorToast = vi.hoisted(() => vi.fn());
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: clearOffline,
}));
vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
vi.mock("@/lib/errors/error-toast", () => ({ showErrorToast }));
import { ClaimProfile } from "../claim-profile";
const TOKEN = `hlp_${"c".repeat(64)}`;
let api: FetchStub;
beforeEach(() => {
window.history.replaceState(null, "", "/auth/claim");
api = stubFetch()
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
.on("POST", "/api/auth/claim/preview", {
data: {
displayName: "Robin",
expiresAt: "2030-01-01T00:00:00.000Z",
guardians: [],
methods: ["password", "sso"],
ssoProviderName: "Example ID",
},
})
.on("POST", "/api/auth/claim", {
data: { userId: "claimed", username: "robin" },
})
.on("POST", "/api/auth/claim/sso", {
data: { redirectTo: "/api/auth/oidc/login?claim=1" },
});
});
afterEach(() => {
clearOffline.mockClear();
loadDocument.mockClear();
showErrorToast.mockClear();
});
async function fill(selector: string, value: string): Promise<void> {
await expect.poll(() => document.querySelector(selector)).toBeTruthy();
await page.elementLocator(document.querySelector(selector)!).fill(value);
}
async function click(slot: string): Promise<void> {
await page
.elementLocator(document.querySelector(`[data-slot="${slot}"]`)!)
.click();
}
describe("ClaimProfile", () => {
it("starts the claimed account from an empty cache", async () => {
const history = recordHistory();
const { queryClient } = await renderWithApp(
<ClaimProfile token={TOKEN} />,
{
user: null,
},
);
queryClient.setQueryData(queryKeys.dashboardSnapshot(), { stale: true });
await fill("#claim-username", "robin");
await fill("#claim-email", "robin@example.com");
await fill("#claim-password", "a long enough passphrase");
await click("claim-submit");
await expect
.poll(() => window.location.pathname)
.toBe("/onboarding/handover");
expect(api.calls("POST", "/api/auth/claim")).toHaveLength(1);
expect(history.pushes()).toEqual([]);
expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe(
undefined,
);
});
it("clears the offline copies before leaving for the identity provider", async () => {
await renderWithApp(<ClaimProfile token={TOKEN} />, { user: null });
await fill("#claim-username", "robin");
await click("claim-sso-continue");
await expect.poll(() => loadDocument.mock.calls.length).toBe(1);
expect(loadDocument.mock.calls[0][0]).toMatch(
/^\/api\/auth\/oidc\/login\?claim=1/,
);
expect(clearOffline).toHaveBeenCalledTimes(1);
expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan(
loadDocument.mock.invocationCallOrder[0],
);
});
});
describe("ClaimProfile, already signed in", () => {
it("keeps the session's page and says so when the server refuses the sign-out", async () => {
api.on("POST", "/api/auth/logout", {
status: 403,
error: "Demo mode: modifications are disabled",
meta: { demo: true, errorCode: "demo.readOnly" },
});
const { queryClient } = await renderWithApp(
<ClaimProfile token={TOKEN} />,
{
user: { username: "signed-in" },
},
);
await click("claim-sign-out");
await expect.poll(() => showErrorToast.mock.calls.length).toBe(1);
expect(showErrorToast.mock.calls[0][0]).toBe(
"Could not sign out. You are still signed in.",
);
expect(clearOffline).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.authMe())).toBeDefined();
expect(window.location.search).toBe("");
});
});
+13 -12
View File
@@ -14,15 +14,16 @@ import { Label } from "@/components/ui/label";
import { Logo } from "@/components/ui/logo";
import { PasswordStrength } from "@/components/ui/password-strength";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import { ApiError, apiFetch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import {
useClaimProfile,
useClaimProfileSso,
useProfileClaimPreview,
} from "@/lib/queries/use-profile-claim";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.42 (#959) — the person a managed profile describes takes it over as
@@ -78,7 +79,7 @@ export function ClaimProfile({
async function signOut() {
setSigningOut(true);
try {
await apiFetchRaw("/api/auth/logout", { method: "POST" });
await apiFetch("/api/auth/logout", { method: "POST" });
clearCachesForSessionEnd(queryClient);
// Back to this page with the link intact: the token only rides the URL.
router.replace(
@@ -87,6 +88,8 @@ export function ClaimProfile({
: "/auth/claim",
);
router.refresh();
} catch (failure) {
showErrorToast(t("auth.signOutFailed"), { cause: failure });
} finally {
setSigningOut(false);
}
@@ -97,14 +100,15 @@ export function ClaimProfile({
claimSso.mutate(
{ token, username },
{
onSuccess: ({ redirectTo }) => {
// Only the path and query of the server's answer, so a response
// could never send the browser off this origin.
onSuccess: async ({ redirectTo }) => {
// Path and query only, so an answer can never leave this origin.
const next = new URL(redirectTo, window.location.origin);
if (clientTransport) {
next.searchParams.set("client_proto", clientTransport.protocol);
next.searchParams.set("client_host", clientTransport.host);
}
// The session starts on the way back; the last account's copies go first.
await clearOfflineCachesForSessionEnd();
loadDocument(`${next.pathname}${next.search}`);
},
},
@@ -117,12 +121,9 @@ export function ClaimProfile({
claim.mutate(
{ token, username, email, password },
{
onSuccess: async () => {
queryClient.removeQueries({
queryKey: queryKeys.profileClaimPreview(),
});
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/handover");
onSuccess: () => {
queryClient.clear(); // as after any sign-in
router.replace("/onboarding/handover");
},
},
);
+3 -4
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { currentClientTransport } from "@/lib/auth/client-transport";
/** The server's refusal for a page whose sign-in cookie cannot stick. */
@@ -73,9 +74,7 @@ export function MfaLoginStep({
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
? t("auth.insecureTransport.title")
: err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
: localizedApiError(err, t, "auth.mfa.verifyFailed"),
);
} finally {
setLoading(false);
@@ -102,7 +101,7 @@ export function MfaLoginStep({
onSuccess();
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || t("auth.mfa.verifyFailed"));
setError(localizedApiError(err, t, "auth.mfa.verifyFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
@@ -0,0 +1,116 @@
/**
* The app shell's scroll position across navigation.
*
* `<main>` is the shell's one scroller and stays mounted across routes, and
* the browser restores only the document's offset on Back. So the shell
* keeps the offsets itself: a new page starts at the top, Back returns to
* where the list was left.
*/
import { useEffect, useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { usePathname } from "next/navigation";
import { URL_STATE_EVENT } from "@/lib/url-state";
import { renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
// Chrome that is not the subject.
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
vi.mock("../top-bar", () => ({ TopBar: () => null }));
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
AchievementUnlockNotifier: () => null,
}));
vi.mock("@/components/insights/layout-coach-fab", () => ({
LayoutCoachFab: () => null,
}));
vi.mock("@/components/insights/layout-coach-mount", () => ({
LayoutCoachMount: () => null,
}));
vi.mock("@/components/onboarding/tour-launcher", () => ({
TourLauncher: () => null,
}));
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
CommandPaletteMount: () => null,
}));
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
import { AuthShell } from "../auth-shell";
/**
* A long list on `/labs`, a short detail page anywhere else. The list is
* short while it loads (a skeleton), and each page mounts afresh.
*/
function Page() {
const pathname = usePathname();
return <PageBody key={pathname} pathname={pathname} />;
}
function PageBody({ pathname }: { pathname: string }) {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setLoaded(true), 200);
return () => clearTimeout(timer);
}, []);
const long = pathname === "/labs" && loaded;
return (
<div
data-slot="page"
data-path={pathname}
data-loaded={loaded || undefined}
style={{ height: long ? 4000 : 300 }}
/>
);
}
function main(): HTMLElement {
return document.getElementById("main-content")!;
}
async function nextFrames(): Promise<void> {
for (let i = 0; i < 3; i += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}
/** A link tap: the router pushes the entry and renders the next page. */
async function openPage(path: string): Promise<void> {
window.history.pushState(null, "", path);
window.dispatchEvent(new Event(URL_STATE_EVENT));
await expect
.poll(() => document.querySelector(`[data-path="${path}"]`))
.toBeTruthy();
}
describe("AuthShell scroll position", () => {
it("opens a new page at the top and returns to the list's offset on Back, once the list has loaded", async () => {
stubFetch();
window.history.replaceState(null, "", "/labs");
await renderWithApp(
<AuthShell>
<Page />
</AuthShell>,
{ user: { id: "signed-in" } },
);
await expect
.poll(() => document.querySelector("[data-loaded]"))
.toBeTruthy();
main().scrollTop = 1500;
await nextFrames();
expect(main().scrollTop).toBe(1500);
await openPage("/labs/biomarker-1");
await expect.poll(() => main().scrollTop).toBe(0);
window.history.back();
await expect
.poll(() => document.querySelector('[data-path="/labs"]'))
.toBeTruthy();
await expect.poll(() => main().scrollTop).toBe(1500);
});
});
@@ -0,0 +1,201 @@
/**
* What the shell does once the server says there is no session.
*
* On an `/auth/*` page it clears the previous account's offline copies, once,
* so a session that ended without a sign-out (an expired cookie the edge sent
* straight to the sign-in page) leaves nothing on the device for the next
* account. It never clears on an answer that is not a verdict.
*
* On a protected page it sends the visitor to the sign-in page, carrying the
* page along when the session ended on its own, and not after a deliberate
* sign-out: the next person on the device starts from a bare sign-in page.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { authUser, renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: clearOffline,
clearOfflineCachesForRecordSwitch: vi.fn(async () => {}),
}));
// Chrome that is not the subject.
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
vi.mock("../top-bar", () => ({ TopBar: () => null }));
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
AchievementUnlockNotifier: () => null,
}));
vi.mock("@/components/insights/layout-coach-fab", () => ({
LayoutCoachFab: () => null,
}));
vi.mock("@/components/insights/layout-coach-mount", () => ({
LayoutCoachMount: () => null,
}));
vi.mock("@/components/onboarding/tour-launcher", () => ({
TourLauncher: () => null,
}));
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
CommandPaletteMount: () => null,
}));
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
import { clearCachesForSessionEnd } from "@/hooks/use-auth";
import { AuthShell } from "../auth-shell";
const ME = "/api/auth/me";
function openAt(path: string): void {
window.history.replaceState(null, "", path);
}
/** The tree `renderWithApp` mounts, for rendering it again. */
function shellTree(queryClient: QueryClient) {
return (
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<AuthShell>
<div data-slot="page" />
</AuthShell>
</I18nProvider>
</QueryClientProvider>
);
}
async function mountShell(user: Parameters<typeof authUser>[0] | null) {
return renderWithApp(
<AuthShell>
<div data-slot="page" />
</AuthShell>,
{ user },
);
}
async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 150));
}
afterEach(() => {
clearOffline.mockClear();
});
describe("AuthShell on a sign-in page", () => {
it("clears the offline copies once the server says there is no session", async () => {
const fetchStub = stubFetch().on("GET", ME, {
status: 401,
error: "Unauthorized",
});
openAt("/auth/login");
const { queryClient } = await mountShell(null);
await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
// A second answer of the same kind does not clear again.
await queryClient.refetchQueries();
await settle();
expect(fetchStub.calls("GET", ME).length).toBeGreaterThan(1);
expect(clearOffline).toHaveBeenCalledTimes(1);
});
it("does the same on the profile take-over page", async () => {
stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" });
openAt("/auth/claim");
await mountShell(null);
await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
});
it("leaves them alone for a signed-in visitor", async () => {
stubFetch();
openAt("/auth/register");
await mountShell({ id: "signed-in" });
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
it("leaves them alone when the answer carries no verdict", async () => {
// An offline launch or a 5xx says nothing about the session; clearing on
// it is what used to destroy the offline copies of a signed-in device.
const fetchStub = stubFetch().on("GET", ME, {
status: 503,
error: "Unavailable",
});
openAt("/auth/login");
await mountShell(null);
// The account query retries a 5xx once; wait for both answers.
await expect
.poll(() => fetchStub.calls("GET", ME).length, { timeout: 4000 })
.toBe(2);
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
it("does not clear on a public page outside the sign-in flow", async () => {
stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" });
openAt("/about");
await mountShell(null);
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
});
describe("AuthShell on a protected page", () => {
/** Signed in on `path`, with the marker a confirmed session leaves. */
async function signedInAt(path: string) {
let session = true;
stubFetch().on("GET", ME, () =>
session
? { data: authUser({ id: "signed-in" }) }
: { status: 401, error: "Unauthorized" },
);
openAt(path);
const view = await mountShell(null);
await expect
.poll(() => document.querySelector('[data-slot="page"]'))
.toBeTruthy();
return {
...view,
endSession: () => {
session = false;
},
};
}
it("returns to the page after a session that ended on its own", async () => {
// Signed in once on this browser; the session has since expired while
// the cookie stayed, so the next visit gets past the edge and the
// account answer is the first to say so.
const first = await signedInAt("/");
first.endSession();
await first.screen.unmount();
openAt("/measurements?add=WEIGHT");
await mountShell(null);
await expect.poll(() => window.location.pathname).toBe("/auth/login");
expect(new URLSearchParams(window.location.search).get("next")).toBe(
"/measurements?add=WEIGHT",
);
});
it("does not carry the page past a deliberate sign-out", async () => {
const { screen, queryClient, endSession } =
await signedInAt("/medications");
// What the sign-out does once the server confirmed it; the navigation
// it starts renders the shell again, which asks for the account anew.
endSession();
clearCachesForSessionEnd(queryClient);
await screen.rerender(shellTree(queryClient));
await expect.poll(() => window.location.pathname).toBe("/auth/login");
expect(window.location.search).toBe("");
});
});
@@ -0,0 +1,70 @@
/**
* The "machine translated" strip is not part of signing in.
*
* It belongs to the app, where the language can be changed and a correction
* reported; on the sign-in, registration and take-over pages it was the first
* thing a visitor in eight of ten languages read, above the form.
*/
import { describe, expect, it, vi } from "vitest";
import { renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
// Chrome that is not the subject.
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
vi.mock("../top-bar", () => ({ TopBar: () => null }));
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
AchievementUnlockNotifier: () => null,
}));
vi.mock("@/components/insights/layout-coach-fab", () => ({
LayoutCoachFab: () => null,
}));
vi.mock("@/components/insights/layout-coach-mount", () => ({
LayoutCoachMount: () => null,
}));
vi.mock("@/components/onboarding/tour-launcher", () => ({
TourLauncher: () => null,
}));
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
CommandPaletteMount: () => null,
}));
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
import { AuthShell } from "../auth-shell";
const BANNER = '[data-slot="maintainership-banner"]';
async function mountAt(path: string, user: { id: string } | null) {
window.history.replaceState(null, "", path);
await renderWithApp(
<AuthShell>
<div data-slot="page" />
</AuthShell>,
{ user, locale: "fr" },
);
await expect
.poll(() => document.querySelector('[data-slot="page"]'))
.toBeTruthy();
}
describe("translation notice", () => {
it("is not shown on the sign-in page", async () => {
stubFetch().on("GET", "/api/auth/me", {
status: 401,
error: "Unauthorized",
});
await mountAt("/auth/login", null);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(document.querySelector(BANNER)).toBeNull();
});
it("is still shown inside the app", async () => {
stubFetch();
await mountAt("/", { id: "signed-in" });
await expect.poll(() => document.querySelector(BANNER)).toBeTruthy();
});
});
+113 -56
View File
@@ -10,11 +10,17 @@ import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import {
clearCachesForSessionEnd,
readWasAuthenticated,
useAuth,
} from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { signInHref } from "@/lib/api/read-failure";
import { useTranslations } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
// The leaf module, not the `@/lib/record-settings` barrel. This shell wraps
// every authenticated route, and the barrel also re-exports the managed
// settings-patch schemas — so one destination predicate that reads a slug
@@ -77,6 +83,92 @@ const PUBLIC_PATHS = [
"/claim/",
];
/**
* `<main id="main-content">` is the app shell's one scroller and stays mounted
* across routes, so its offset would carry from page to page, and the
* browser's own restoration on Back moves only the document, never `<main>`.
* A forward navigation starts at the top; Back and Forward return to the
* offset that entry was left at.
*/
function useShellScrollMemory(pathname: string): void {
// Recorded while scrolling: once the pathname has changed, `<main>` holds
// the next page and the offset is gone.
const offsets = useRef(new Map<string, number>());
// Set by `popstate` for the entry it lands on, taken by the next render.
const restore = useRef<{ url: string; top: number } | null>(null);
useEffect(() => {
const here = () => `${location.pathname}${location.search}`;
const remember = (event: Event) => {
const el = event.target;
if (el instanceof HTMLElement && el.id === "main-content") {
offsets.current.set(here(), el.scrollTop);
}
};
const recall = () => {
restore.current = { url: here(), top: offsets.current.get(here()) ?? 0 };
};
const capture = { capture: true, passive: true } as const;
document.addEventListener("scroll", remember, capture);
window.addEventListener("popstate", recall);
return () => {
document.removeEventListener("scroll", remember, capture);
window.removeEventListener("popstate", recall);
};
}, []);
useEffect(() => {
const target = restore.current;
restore.current = null;
const main = document.getElementById("main-content");
if (target?.url !== `${location.pathname}${location.search}`) {
if (main) main.scrollTop = 0;
// The body-scrolled shells (sign-in, setup, legal pages) scroll the
// document instead.
window.scrollTo(0, 0);
return;
}
if (!main) return;
// A list still painting its skeleton is shorter than the offset; keep
// trying until it has the height, for at most a second.
const until = performance.now() + 1000;
let frame = 0;
const apply = () => {
main.scrollTop = target.top;
if (main.scrollTop < target.top - 1 && performance.now() < until) {
frame = requestAnimationFrame(apply);
}
};
apply();
return () => cancelAnimationFrame(frame);
}, [pathname]);
}
/**
* A session that ended without passing through the shell's session-end wipe
* (an expired cookie the edge sent straight to the sign-in page, a sign-in
* page opened directly) left the previous account's offline copies on this
* device. Clear them on every `/auth/*` page once the server has said there is
* no session, before anyone signs in. Once per signed-out stretch, and never
* on an answer that is not a verdict (offline, 5xx).
*/
function useSignInPageOfflineWipe(pathname: string): void {
const { isAuthenticated, isAuthUnknown, isLoading } = useAuth();
const cleared = useRef(false);
useEffect(() => {
if (isAuthenticated) {
cleared.current = false;
} else if (
!isLoading &&
!isAuthUnknown &&
pathname.startsWith("/auth/") &&
!cleared.current
) {
cleared.current = true;
void clearOfflineCachesForSessionEnd();
}
}, [isLoading, isAuthenticated, isAuthUnknown, pathname]);
}
export function AuthShell({
children,
demoMode = false,
@@ -181,63 +273,28 @@ export function AuthShell({
isBodyScrolled ? "body" : rendersAppShell ? "shell" : null,
);
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
// mounted across every route change, so its scroll offset survives
// navigation: scroll down in one section (e.g. Settings → Sources
// Priority), hop to another, and the new view opens already scrolled.
// Reset the scroll container to the top on every forward navigation.
// A back/forward navigation skips the reset so the browser's own scroll
// restoration stands: `popstate` arms a one-shot skip for the single
// pathname change it triggers, and a macrotask clears the flag so it can
// never get stuck on a later forward navigation.
const skipNextScrollReset = useRef(false);
useEffect(() => {
const armSkip = () => {
skipNextScrollReset.current = true;
setTimeout(() => {
skipNextScrollReset.current = false;
}, 0);
};
window.addEventListener("popstate", armSkip);
return () => window.removeEventListener("popstate", armSkip);
}, []);
useEffect(() => {
if (skipNextScrollReset.current) {
skipNextScrollReset.current = false;
return;
}
const main = document.getElementById("main-content");
if (main) {
main.scrollTop = 0;
}
// Fallback for the body-scrolled shells (login / onboarding / standalone
// legal pages) where the document, not `<main>`, owns the scroll.
window.scrollTo(0, 0);
}, [pathname]);
useShellScrollMemory(pathname);
// Session END on a protected page (`/api/auth/me` answered 401/403): wipe
// every client cache, since the QueryClient outlives navigation and health
// data is not user-scoped, then go to the sign-in page. Logout does the same
// from `useLogout`; this covers the expiry path it never runs through. No
// verdict (`isAuthUnknown`: offline, 5xx, no marker) never wipes: a wipe on
// a network failure is what used to destroy the offline caches.
//
// The target is fixed once per signed-out stretch, before the wipe clears
// the marker it reads (the refetch that follows runs this again). A session
// that ended on its own returns here after the next sign-in; a deliberate
// sign-out already cleared the marker, so the next person gets a bare page.
const signInTarget = useRef<string | null>(null);
useEffect(() => {
if (isAuthenticated || isPublicPage) signInTarget.current = null;
if (!isLoading && !isAuthenticated && !isPublicPage) {
// Session END (expired / invalidated cookie → `/api/auth/me` 401/403).
// Wipe the in-memory query cache (the cross-user guard — the
// QueryClient outlives the navigation on this SPA and the health-data
// families are not user-scoped) plus the persisted IndexedDB + SW
// caches, so nothing leaks to the next account on a shared browser.
// Logout does the same from `useLogout`; this covers the expiry path
// logout never runs through. Best-effort; the redirect proceeds
// regardless.
//
// `isAuthUnknown` = the auth probe failed WITHOUT a session verdict
// (offline relaunch, network blip, 5xx) and no prior-session marker
// exists. Nothing to protect and nothing to render — send the visitor
// to the login gate, but never run the wipe off a non-verdict: the
// wipe on a network failure is exactly what used to destroy the
// offline caches. (With the marker present, `isAuthenticated` holds
// the last-known state and this branch never runs.)
if (!isAuthUnknown) {
clearCachesForSessionEnd(queryClient);
}
router.replace("/auth/login");
signInTarget.current ??= readWasAuthenticated()
? signInHref()
: "/auth/login";
if (!isAuthUnknown) clearCachesForSessionEnd(queryClient);
router.replace(signInTarget.current);
}
}, [
isLoading,
@@ -247,6 +304,7 @@ export function AuthShell({
router,
queryClient,
]);
useSignInPageOfflineWipe(pathname);
// Redirect non-admins away from /admin
useEffect(() => {
@@ -284,7 +342,6 @@ export function AuthShell({
return (
<div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top,0px)] pr-[env(safe-area-inset-right,0px)] pb-[env(safe-area-inset-bottom,0px)] pl-[env(safe-area-inset-left,0px)]">
{demoMode ? <DemoBanner /> : null}
<MaintainershipBanner />
<main
id="main-content"
className="flex flex-1 items-center justify-center px-4 py-6"