diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e75eefd..a489c8f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { cookies } from "next/headers"; import { Inter, La_Belle_Aurore } from "next/font/google"; import { RootShell } from "@/components/layout/root-shell"; import { FeaturedPostProvider } from "@/components/layout/featured-post"; @@ -9,7 +10,7 @@ import { PageTracker } from "@/components/analytics/page-tracker"; // Imported from the plain module, never from the "use client" provider — a // server importer of a client export gets a throwing proxy, not the string. import { THEME_INIT_SCRIPT } from "@/components/theme/theme-script"; -import { AUTH_INIT_SCRIPT } from "@/lib/auth-script"; +import { AUTH_INIT_SCRIPT, SESSION_COOKIE, AUTH_ATTRIBUTE } from "@/lib/auth-script"; import { SITE_NAME, SITE_URL } from "@/lib/constants"; import { getFeaturedPost } from "@/lib/ghost"; import { PUBLISHER } from "@/lib/blog-schema"; @@ -127,6 +128,9 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const cookieStore = await cookies(); + const isAuthed = !!cookieStore.get(SESSION_COOKIE)?.value; + // Read here rather than in the nav so the strip is server-rendered with the // page instead of popping in. Ghost's fetches are revalidate-cached and deduped // per request, so this costs the layout nothing after the first hit. @@ -136,10 +140,12 @@ export default async function RootLayout({ before - // hydration, so the server markup (no attributes) and client differ by - // design. + // data-theme is set by the pre-paint script before hydration; data-authed + // is set here server-side (the session cookie is HttpOnly). The demo + // override script may change data-authed client-side, so both attributes + // can differ from the server markup by design. suppressHydrationWarning + {...{ [`data-${AUTH_ATTRIBUTE}`]: isAuthed ? "1" : "0" }} > diff --git a/src/lib/auth-script.ts b/src/lib/auth-script.ts index c016a25..38ee900 100644 --- a/src/lib/auth-script.ts +++ b/src/lib/auth-script.ts @@ -4,12 +4,9 @@ // error rather than the script. /** - * Whether the visitor is signed in to Assembly. The dashboard sets a cookie on - * the shared `.assembly.com` parent domain, which any page under that domain - * can read client-side. - * - * A Vercel preview URL is not under `.assembly.com`, so the cookie is never - * visible there — use the demo override below to see both states. + * The dashboard's session cookie. HttpOnly, so it can only be read server-side + * — the root layout reads it with `cookies()` and stamps `data-authed` on + * `` at render time. */ export const SESSION_COOKIE = "current-portal-session"; @@ -23,16 +20,10 @@ export const DEMO_KEY = "studio:demo-authed"; export const AUTH_ATTRIBUTE = "authed"; /** - * Runs before paint so the right variant is on before React hydrates. - * - * This is what makes the auth-aware UI flash-free. The markup is prerendered - * without knowing who is visiting, so every auth-dependent surface ships BOTH - * variants and the stylesheet picks one off this attribute (see `.auth-only` / - * `.unauth-only` in globals.css). Deciding in an effect instead meant a - * signed-in visitor watched the nav, the pricing grid and the comparison table - * render signed-out and then rearrange. - * - * Failing closed to "0" matters: signed-out is the state the markup is written - * for, so a thrown script leaves a correct page rather than a blank one. + * Client-side script that handles only the demo override. The real auth state + * is set server-side on `` by the root layout (which can + * read the HttpOnly session cookie). This script checks for `?authed=1|0` in + * the URL or a persisted override in localStorage and, if found, overwrites + * the server-set value. If no override is active it leaves the attribute alone. */ -export const AUTH_INIT_SCRIPT = `try{var a=null;var q=new URLSearchParams(window.location.search).get('authed');if(q==='clear'){localStorage.removeItem('${DEMO_KEY}');}else if(q==='1'||q==='0'){localStorage.setItem('${DEMO_KEY}',q);a=q==='1';}if(a===null){var s=localStorage.getItem('${DEMO_KEY}');if(s==='1'||s==='0')a=s==='1';}if(a===null)a=document.cookie.split('; ').some(function(c){return c.indexOf('${SESSION_COOKIE}=')===0&&c.length>${SESSION_COOKIE.length + 1};});document.documentElement.dataset.${AUTH_ATTRIBUTE}=a?'1':'0';}catch(e){document.documentElement.dataset.${AUTH_ATTRIBUTE}='0';}`; +export const AUTH_INIT_SCRIPT = `try{var a=null;var q=new URLSearchParams(window.location.search).get('authed');if(q==='clear'){localStorage.removeItem('${DEMO_KEY}');}else if(q==='1'||q==='0'){localStorage.setItem('${DEMO_KEY}',q);a=q==='1';}if(a===null){var s=localStorage.getItem('${DEMO_KEY}');if(s==='1'||s==='0')a=s==='1';}if(a!==null)document.documentElement.dataset.${AUTH_ATTRIBUTE}=a?'1':'0';}catch(e){}`;