From 7b043250b35abd51fb7e378861549d153e6347dc Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 8 Jul 2026 13:03:12 -0500 Subject: [PATCH 1/3] fix(dashboard): reload bfcache-restored pages so logout+Back can't show a stale dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After logout, pressing Back restored the authenticated dashboard from the browser's back/forward cache (bfcache). The session is already invalidated server-side (any interaction 401s -> redirect), so this is a defense-in-depth + UX gap, not an auth bypass. Root cause: dashboard pages are dynamically rendered (cookies/headers) and Next owns their Cache-Control — it serves `no-cache, must-revalidate`, not `no-store`. Only `no-store` disables bfcache in Chromium/Firefox, and neither next.config headers() nor the edge proxy can override a dynamic route's Cache-Control (verified empirically — both are dropped/replaced). Fix: a client BfcacheGuard in the dashboard layout reloads any page restored from bfcache (`pageshow` with `persisted`); the reload re-hits the auth check and a logged-out visitor is redirected to /login (MDN's documented pattern for this exact problem). Scoped to the dashboard; the static marketing/landing stays cacheable (Phase 3 LCP work untouched). e2e: pins that a logged-out reload of the dashboard lands on /login (real bfcache Back is verified manually — headless bfcache is unreliable to trigger and window.location.reload can't be spied in Chromium). Verified: biome, next build (31 routes), full e2e 12/12. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PA9EzergAZ12Dy1QBqx5kp --- e2e/security.spec.ts | 25 ++++++++++++++++ .../dashboard/_components/bfcache-guard.tsx | 29 +++++++++++++++++++ src/app/(main)/dashboard/layout.tsx | 2 ++ 3 files changed, 56 insertions(+) create mode 100644 src/app/(main)/dashboard/_components/bfcache-guard.tsx diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts index cdb85dae2..ee2f8ac41 100644 --- a/e2e/security.spec.ts +++ b/e2e/security.spec.ts @@ -22,6 +22,31 @@ test("baseline security headers are present on responses", async ({ page }) => { expect(headers["permissions-policy"]).toContain("camera=()"); }); +test("after logout, reloading the dashboard lands on /login (never re-shows the authenticated view)", async ({ + page, + context, +}) => { + // This is the redirect the client BfcacheGuard triggers when a page is restored + // from the browser's back/forward cache. Dashboard pages are dynamically rendered + // and Next owns their Cache-Control (it serves `no-cache`, not `no-store`), so the + // guard reloads any bfcache-restored page (`pageshow` + `persisted`) — and, as + // asserted here, a logged-out reload redirects to /login rather than re-showing the + // stale dashboard. (Real-bfcache Back is verified manually; headless bfcache is + // unreliable to trigger, and window.location.reload can't be spied in Chromium.) + const email = `sec-bfcache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@novaanalytics.io`; + const signup = await page.request.post("/api/auth/sign-up/email", { + data: { name: "Bfcache Probe", email, password: "SecPass123!" }, + }); + expect(signup.ok()).toBeTruthy(); + + await page.goto("/dashboard/default"); + await expect(page).toHaveURL(/dashboard/); + + await context.clearCookies(); // log out + await page.reload(); + await expect(page).toHaveURL(/login/); +}); + test("anonymous request to a protected page is redirected to /login at the edge", async ({ request }) => { const res = await request.get("/dashboard/default", { maxRedirects: 0 }); expect(res.status()).toBe(307); diff --git a/src/app/(main)/dashboard/_components/bfcache-guard.tsx b/src/app/(main)/dashboard/_components/bfcache-guard.tsx new file mode 100644 index 000000000..896974667 --- /dev/null +++ b/src/app/(main)/dashboard/_components/bfcache-guard.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Reloads the page when it is restored from the browser's back/forward cache + * (bfcache). Dashboard pages are dynamically rendered, and Next controls the + * Cache-Control of dynamic routes (it serves `no-cache`, not `no-store`), so + * neither `next.config` headers nor the edge proxy can reliably mark them + * `no-store` — the only directive that disables bfcache. Without this, after + * logout the Back button restores a stale authenticated view from bfcache. + * + * On a bfcache restore (`pageshow` with `persisted === true`), the reload + * re-hits the server auth check, which redirects a logged-out visitor to /login. + */ +export function BfcacheGuard() { + useEffect(() => { + const handlePageShow = (event: PageTransitionEvent) => { + if (event.persisted) { + window.location.reload(); + } + }; + + window.addEventListener("pageshow", handlePageShow); + return () => window.removeEventListener("pageshow", handlePageShow); + }, []); + + return null; +} diff --git a/src/app/(main)/dashboard/layout.tsx b/src/app/(main)/dashboard/layout.tsx index 9b78341c8..fc550cc1c 100644 --- a/src/app/(main)/dashboard/layout.tsx +++ b/src/app/(main)/dashboard/layout.tsx @@ -10,6 +10,7 @@ import { auth } from "@/lib/auth"; import { cn } from "@/lib/utils"; import { getPreference } from "@/server/server-actions"; +import { BfcacheGuard } from "./_components/bfcache-guard"; import { AccountSwitcher } from "./_components/sidebar/account-switcher"; import { LayoutControls } from "./_components/sidebar/layout-controls"; import { SearchDialog } from "./_components/sidebar/search-dialog"; @@ -47,6 +48,7 @@ export default async function Layout({ children }: Readonly<{ children: ReactNod } as React.CSSProperties } > + Date: Wed, 8 Jul 2026 15:34:43 -0500 Subject: [PATCH 2/3] fix(auth): hard-navigate on logout so Back can't browse the cached dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the BfcacheGuard: that alone was insufficient. The logout handlers did `await signOut(); router.push("/login")` — a SOFT (client-side) navigation. A soft nav leaves the dashboard SPA and Next's client Router Cache alive, so after logout, Back restored the still-mounted dashboard and its prefetched authenticated RSC — the logged-out user could not just VIEW it but navigate and interact via cached soft navigations (no server round-trip, so the dead session was never re-checked). Fix: `window.location.href = "/login"` — a hard navigation that tears down the SPA and discards the Router Cache. Combined with the BfcacheGuard (browser bfcache restore → reload) and the edge proxy (no cookie → /login), Back after logout now lands on /login through every path. Dropped the now-unused useRouter. e2e: the auth flow test now presses Back after logout and asserts /login (it reproduced the bug on the soft-nav version). Full suite 12/12; build green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PA9EzergAZ12Dy1QBqx5kp --- e2e/auth.spec.ts | 7 +++++++ .../dashboard/_components/sidebar/account-switcher.tsx | 8 ++++---- .../(main)/dashboard/_components/sidebar/nav-user.tsx | 9 ++++----- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 3e3b43fd7..4b6f198d0 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -19,6 +19,13 @@ test("signup → dashboard → logout → login", async ({ page }) => { await page.getByRole("menuitem", { name: /log ?out/i }).click(); await expect(page).toHaveURL(/login/, { timeout: 15_000 }); + // Regression: after logout, pressing Back must not restore the dashboard. A + // soft-nav (router.push) logout left the SPA + client Router Cache alive, so a + // logged-out user could view AND interact with cached pages; a hard-nav logout + // + the BfcacheGuard send Back to /login instead. + await page.goBack(); + await expect(page, "Back after logout must not restore the dashboard").toHaveURL(/login/, { timeout: 15_000 }); + await page.getByLabel(/email address/i).fill(email); await page.getByLabel(/^password$/i).fill(password); await page.getByRole("button", { name: /^login$/i }).click(); diff --git a/src/app/(main)/dashboard/_components/sidebar/account-switcher.tsx b/src/app/(main)/dashboard/_components/sidebar/account-switcher.tsx index 95d0f37b4..d2def6941 100644 --- a/src/app/(main)/dashboard/_components/sidebar/account-switcher.tsx +++ b/src/app/(main)/dashboard/_components/sidebar/account-switcher.tsx @@ -2,8 +2,6 @@ import { useState } from "react"; -import { useRouter } from "next/navigation"; - import { BadgeCheck, Bell, Check, CreditCard, LogOut } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -30,11 +28,13 @@ export function AccountSwitcher({ }>; }) { const [activeUser, setActiveUser] = useState(users[0]); - const router = useRouter(); async function handleLogout() { await signOut(); - router.push("/login"); + // Hard navigation (not router.push): a soft nav leaves the dashboard SPA and + // its client Router Cache alive, so Back could restore prefetched authenticated + // RSC and let a logged-out user browse/interact. A full load tears that down. + window.location.href = "/login"; } if (!activeUser) { diff --git a/src/app/(main)/dashboard/_components/sidebar/nav-user.tsx b/src/app/(main)/dashboard/_components/sidebar/nav-user.tsx index 0f4b8a6e3..6d57e063b 100644 --- a/src/app/(main)/dashboard/_components/sidebar/nav-user.tsx +++ b/src/app/(main)/dashboard/_components/sidebar/nav-user.tsx @@ -1,7 +1,5 @@ "use client"; -import { useRouter } from "next/navigation"; - import { CircleUser, CreditCard, EllipsisVertical, LogOut, MessageSquareDot } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -28,11 +26,12 @@ export function NavUser({ }; }) { const { isMobile } = useSidebar(); - const router = useRouter(); - async function handleLogout() { await signOut(); - router.push("/login"); + // Hard navigation (not router.push): a soft nav leaves the dashboard SPA and + // its client Router Cache alive, so Back could restore prefetched authenticated + // RSC and let a logged-out user browse/interact. A full load tears that down. + window.location.href = "/login"; } return ( From f4758a03dcc7c4eb79bf4c1696d7fc101e15eae0 Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 8 Jul 2026 15:38:43 -0500 Subject: [PATCH 3/3] chore(release): bump version to 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nova Analytics v1.0.0. Aligns the package version (inherited 2.2.0 from the arhamkhnz template) with the v1.0.0 release tag. Version field only — no dependency or lockfile-format changes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PA9EzergAZ12Dy1QBqx5kp --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index afe8b50ca..bbfe3ce2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nova-analytics", - "version": "2.2.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nova-analytics", - "version": "2.2.0", + "version": "1.0.0", "dependencies": { "@base-ui/react": "^1.6.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index df35e230c..6bb534c9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nova-analytics", - "version": "2.2.0", + "version": "1.0.0", "private": true, "engines": { "node": "22.x"