Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions e2e/security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nova-analytics",
"version": "2.2.0",
"version": "1.0.0",
"private": true,
"engines": {
"node": "22.x"
Expand Down
29 changes: 29 additions & 0 deletions src/app/(main)/dashboard/_components/bfcache-guard.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down
9 changes: 4 additions & 5 deletions src/app/(main)/dashboard/_components/sidebar/nav-user.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions src/app/(main)/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -47,6 +48,7 @@ export default async function Layout({ children }: Readonly<{ children: ReactNod
} as React.CSSProperties
}
>
<BfcacheGuard />
<AppSidebar variant={variant} collapsible={collapsible} user={sessionUser} />
<SidebarInset
className={cn(
Expand Down
Loading