fix(auth): bring a deep link back to its page after signing in

A home-screen shortcut, a notification tap or a bookmark opened without
a session landed on the sign-in page with a bare URL, and after signing
in the person was on the dashboard instead of the page they asked for.
The sign-in page already honoured a sanitised `next`; neither redirect
set one.

The proxy now passes the request's own path and query as `next` (the
bare start page goes without one). The shell does the same when the
account answer ends a session on a protected page, except after a
deliberate sign-out: the marker a confirmed session leaves is gone by
then, so the next person on the device starts from a bare sign-in page
rather than the page the previous one left.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:14:04 +01:00
1 parent 75b879279f
commit 6c5a6eacbb
7 files changed
+152 -37

No files matched your search

@@ -5,8 +5,15 @@
* 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";
@@ -42,6 +49,8 @@ vi.mock("@/components/command-palette/command-palette.lazy", () => ({
}));
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
import { clearCachesForSessionEnd } from "@/hooks/use-auth";
import { AuthShell } from "../auth-shell";
const ME = "/api/auth/me";
@@ -50,6 +59,19 @@ 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>
@@ -125,3 +147,55 @@ describe("AuthShell on a sign-in page", () => {
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("");
});
});
+24 -20
View File
@@ -10,9 +10,14 @@ 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";
@@ -243,27 +248,26 @@ export function AuthShell({
window.scrollTo(0, 0);
}, [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 it
// 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,