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/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/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" 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/_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 ( 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 } > +