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
}
>
+