From 601d5aaa6b02ad5de1aa11eda6946eefa742f001 Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Wed, 5 Aug 2026 01:50:50 +0100 Subject: [PATCH] fix(web): gate the dashboard on the server, repair two nav links Three issues, one of them structural. 1. The dashboard had no server-side authentication. (dashboard)/layout.tsx was a client component calling useSession() and redirecting from a useEffect. That is not protection. The check runs only after the server has sent the HTML and the browser has downloaded, parsed and hydrated the whole dashboard bundle, so an unauthenticated visitor received the entire screen, saw a spinner, and was then bounced. The route existence, layout, navigation and feature set all leaked, and a user with an expired session got a flash of the UI first. It is now a server component: resolve the session, redirect if absent. An unauthenticated request never receives a dashboard response. Proof in the build output -- every /dashboard route moved from static to dynamic, which only happens because the layout now reads request headers. Worth stating plainly: customer DATA was never exposed. Every /api/dashboard route proxies through forwardToCP, which returns 401 when the vls_session cookie is missing and otherwise hands it to the control plane to validate. The leak was the shell, not the contents -- a real defect, not an incident. This is a coarse gate. Per-page checks remain the standard, but every page below is still a client component and cannot do it yet; converting them is tracked separately. 2. Docs pointed at /dashboard. Anyone looking for documentation was sent into the product, and once the gate above landed they would have been bounced to /login. Now points at docs.vectorless.store, overridable via NEXT_PUBLIC_DOCS_URL so preview deployments can target a staging docs site. 3. Pricing, FAQ and How-it-works were dead on /whitepaper. The sections do exist and carry the right ids -- but only on the landing page, and Nav renders on both. A bare "#pricing" resolves against the current document, so on /whitepaper it did nothing. Changed to "/#pricing" so the links work from either page. --- apps/web/app/(dashboard)/layout.tsx | 63 +++++++++++++++-------------- apps/web/components/Nav.tsx | 27 +++++++++---- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 6484011..23d6775 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -1,39 +1,42 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { useSession } from "@/lib/auth-client"; +import { redirect } from "next/navigation"; +import { getServerSession } from "@/lib/server-auth"; import DashboardShell from "@/components/dashboard/DashboardShell"; -import { Loader2 } from "lucide-react"; -export default function DashboardLayout({ +/** + * Server-side gate for every /dashboard route. + * + * This was previously a client component that called `useSession()` and + * redirected from a `useEffect`. That is not protection. A client-side + * check runs only after the server has already sent the HTML and the + * browser has downloaded, parsed and hydrated the whole dashboard bundle + * — so an unauthenticated visitor received the entire screen, saw a + * spinner, and was then bounced. The route's existence, its layout, its + * navigation and its feature set all leaked, and a user whose session had + * expired got a flash of the UI before the redirect. + * + * Resolving the session here means an unauthenticated request never gets + * a dashboard response at all — it gets a redirect, before any of this + * subtree renders. + * + * Customer data was never exposed by the old version: every /api/dashboard + * route proxies through `forwardToCP`, which returns 401 when the + * `vls_session` cookie is absent and otherwise hands the cookie to the + * control plane to validate. The leak was the shell, not the contents. + * That is why this is a real defect and not an incident. + * + * This is a coarse gate. Per-page checks are still the standard — a + * route's protection should be readable in the route's own file — and + * every page under here is currently a client component, so they cannot + * do it yet. Converting them is tracked separately; this closes the hole + * in the meantime. + */ +export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { - const { data: session, isPending } = useSession(); - const router = useRouter(); - - useEffect(() => { - if (!isPending && !session) { - router.push("/login"); - } - }, [isPending, session, router]); - - if (isPending) { - return ( -
-
- -

Loading...

-
-
- ); - } - - if (!session) { - return null; - } + const session = await getServerSession(); + if (!session) redirect("/login"); return {children}; } diff --git a/apps/web/components/Nav.tsx b/apps/web/components/Nav.tsx index d93001c..4eac726 100644 --- a/apps/web/components/Nav.tsx +++ b/apps/web/components/Nav.tsx @@ -5,6 +5,17 @@ import Link from 'next/link'; import { Menu, X } from 'lucide-react'; import { VectorlessDot } from './VectorlessIcon'; +/** + * Docs live on their own Fumadocs deployment, not in this app. The link + * used to point at /dashboard, which sent anyone looking for + * documentation into the product — and, before the layout was gated on + * the server, into a login redirect. + * + * Overridable so a preview deployment can point at a staging docs site + * without a code change. + */ +const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL ?? 'https://docs.vectorless.store'; + export default function Nav() { const [isOpen, setIsOpen] = useState(false); const [scrolled, setScrolled] = useState(false); @@ -40,11 +51,11 @@ export default function Nav() {
- How it works - Docs + How it works + Docs Whitepaper - Pricing - FAQ + Pricing + FAQ
Login @@ -64,11 +75,11 @@ export default function Nav() { {/* Mobile menu — floating glass sheet below the pill */} {isOpen && (
- setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">How it works - setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">Docs + setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">How it works + setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">Docs setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">Whitepaper - setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">Pricing - setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">FAQ + setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">Pricing + setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">FAQ
setIsOpen(false)} className="text-[15px] font-medium text-text-dark p-2 rounded-lg hover:bg-black/5">Login setIsOpen(false)} className="bg-bg-dark text-white px-4 py-3 rounded-full text-[14px] font-medium hover:bg-black transition-colors flex items-center justify-center mt-1">