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">