From 5518219dfda18ba76614adaaf5669925897beec9 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Tue, 8 Sep 2026 16:53:50 +0100 Subject: [PATCH] fix(admin): load the analytics pages as their own document; drop dead saas CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analytics pages carry a wider CSP ('unsafe-eval' for plotly.js) than the rest of the admin. A policy belongs to the document, so a client-side navigation into analytics kept the narrower policy of the page the user started on, and a navigation out carried the wider one along — the same trap that blocked the guide videos (#278), applied to scripts. lib/admin/hard-navigation.ts names the routes that need their own document; AdminSidebar and QuickActionsWidget render links into and out of them as plain anchors. The contract is documented next to the CSP variant picker. Also removes .saas-shell/.saas-card/.saas-pill and their tokens from globals.css: their last consumer (/settings) moved onto tenant tokens in #277. --- nextjs_space/app/globals.css | 42 ++--------------- .../components/admin/AdminSidebar.tsx | 46 ++++++++++++------- .../components/admin/QuickActionsWidget.tsx | 26 ++++++++--- nextjs_space/lib/admin/hard-navigation.ts | 39 ++++++++++++++++ nextjs_space/lib/security/csp.ts | 7 +++ .../tests/unit/hard-navigation.test.ts | 40 ++++++++++++++++ 6 files changed, 139 insertions(+), 61 deletions(-) create mode 100644 nextjs_space/lib/admin/hard-navigation.ts create mode 100644 nextjs_space/tests/unit/hard-navigation.test.ts diff --git a/nextjs_space/app/globals.css b/nextjs_space/app/globals.css index 49ceb892..b72898a5 100644 --- a/nextjs_space/app/globals.css +++ b/nextjs_space/app/globals.css @@ -159,10 +159,6 @@ --tenant-button-font-size: 1rem; /* SaaS light theme accents */ - --saas-surface: 0 0% 100%; - --saas-surface-muted: 210 40% 98%; - --saas-border: 214.3 31.8% 91.4%; - --saas-shadow: 0 20px 40px -30px rgb(15 23 42 / 0.3); /* Sidebar tokens */ --sidebar-background: 0 0% 98%; @@ -258,13 +254,6 @@ /* .admin-bg deprecated/removed in US-009 — was unused. */ - /* Legacy alias used by storefront /store/[slug]/dashboard + /settings */ - .saas-shell { - background-color: #fafaf9; - background-image: radial-gradient(circle, #e7e5e4 1px, transparent 1px); - background-size: 24px 24px; - } - * { @apply border-border; } @@ -455,31 +444,6 @@ transition: all 0.2s ease-out; } - /* Legacy saas-card */ - .saas-card { - background: white; - border-radius: 1rem; - box-shadow: - 0 4px 6px rgba(0, 0, 0, 0.07), - 0 10px 20px rgba(0, 0, 0, 0.12), - 0 30px 60px rgba(0, 0, 0, 0.18); - border: 1px solid rgba(0, 0, 0, 0.08); - } - - .saas-pill { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.35rem 0.85rem; - border-radius: 999px; - border: 1px solid hsl(var(--saas-border)); - background: rgba(255, 255, 255, 0.9); - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); - } /* ========== ICON BADGES ========== */ @@ -1182,8 +1146,10 @@ html { removed in US-009 (Phase 0.4 / Phase 1 exit). Admin pages now use bs-* tokens directly via the @layer components block (below) and the [data-surface="admin"] gate. Storefront homepage classes (.bs-smoky, - .bs-cube-*, .bs-pill-nav, .bs-pill-footer, .bs-drgreen-inline) and - storefront utilities (.saas-shell) intentionally preserved (PRD §4). + .bs-cube-*, .bs-pill-nav, .bs-pill-footer, .bs-drgreen-inline) + intentionally preserved (PRD §4). The saas-shell / saas-card / saas-pill + storefront aliases were removed once /settings moved onto tenant tokens + (PR #277) — they painted BudStacks' own light chrome onto tenant pages. ============================================================ */ /* ============================================================ diff --git a/nextjs_space/components/admin/AdminSidebar.tsx b/nextjs_space/components/admin/AdminSidebar.tsx index b10a82ac..877d65eb 100644 --- a/nextjs_space/components/admin/AdminSidebar.tsx +++ b/nextjs_space/components/admin/AdminSidebar.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import Image from "next/image"; import { cn } from "@/lib/utils"; +import { shouldFullLoad } from "@/lib/admin/hard-navigation"; export interface AdminMenuItem { id: string; @@ -153,22 +154,21 @@ export function AdminSidebar({ {menuItems.map((item) => { const Icon = item.icon; const active = isActive(item.href); - - return ( - setMobileOpen(false)} - className={cn( - "flex items-center gap-3 px-3 py-2.5 rounded-bs-md transition-all group relative", - "before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-[3px] before:rounded-full before:transition-all", - active - ? cn("bg-bs-card text-bs-fg", ACTIVE_RAIL[accent]) - : "before:bg-transparent text-bs-fg-muted hover:text-bs-fg hover:bg-bs-card", - FOCUS_RING[theme] - )} - title={collapsed ? item.label : undefined} - > + const linkProps = { + href: item.href, + onClick: () => setMobileOpen(false), + className: cn( + "flex items-center gap-3 px-3 py-2.5 rounded-bs-md transition-all group relative", + "before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-[3px] before:rounded-full before:transition-all", + active + ? cn("bg-bs-card text-bs-fg", ACTIVE_RAIL[accent]) + : "before:bg-transparent text-bs-fg-muted hover:text-bs-fg hover:bg-bs-card", + FOCUS_RING[theme] + ), + title: collapsed ? item.label : undefined, + }; + const content = ( + <> )} + + ); + + // The analytics pages run under a wider CSP than the rest of the + // admin; a policy belongs to the document, so links into and out + // of them are full loads (lib/admin/hard-navigation.ts). + return shouldFullLoad(pathname, item.href) ? ( + // eslint-disable-next-line @next/next/no-html-link-for-pages -- a full document load is the point + + {content} + + ) : ( + + {content} ); })} diff --git a/nextjs_space/components/admin/QuickActionsWidget.tsx b/nextjs_space/components/admin/QuickActionsWidget.tsx index acbed425..9fa165eb 100644 --- a/nextjs_space/components/admin/QuickActionsWidget.tsx +++ b/nextjs_space/components/admin/QuickActionsWidget.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import Link from "next/link"; +import { needsFullDocument } from "@/lib/admin/hard-navigation"; import { cn } from "@/lib/utils"; import { Package, @@ -87,18 +88,18 @@ export const QuickActionsWidget = React.forwardRef< const Icon = action.icon; const isPrimary = index === 0; - return ( - + ), + }; + const content = ( + <>
{action.label} + + ); + // Analytics needs its own document (wider CSP) — see lib/admin/hard-navigation.ts + return needsFullDocument(action.href) ? ( + // eslint-disable-next-line @next/next/no-html-link-for-pages -- a full document load is the point + + {content} + + ) : ( + + {content} ); })} diff --git a/nextjs_space/lib/admin/hard-navigation.ts b/nextjs_space/lib/admin/hard-navigation.ts new file mode 100644 index 00000000..fb18bb75 --- /dev/null +++ b/nextjs_space/lib/admin/hard-navigation.ts @@ -0,0 +1,39 @@ +/** + * Routes that must be reached by a full document load. + * + * The Content-Security-Policy is built per route in middleware + * (lib/security/csp.ts), and the analytics pages are the one place the admin + * policy is wider: they carry `'unsafe-eval'` for plotly.js. A policy belongs + * to the DOCUMENT, so a client-side `` navigation into analytics keeps + * the policy of the page the user started on — plotly then fails to run — + * and a `` out of analytics carries the widened policy onto pages that + * should not have it. Both directions are avoided by rendering those links + * as plain anchors, which the admin navigation does via these helpers. + * + * Kept free of React/Next imports so it is unit-testable in the node runner. + */ + +/** Pathname prefixes whose CSP variant differs from the rest of the admin. */ +export const FULL_DOCUMENT_ROUTES = [ + "/tenant-admin/analytics", + "/super-admin/analytics", +] as const; + +function matchesRoute(pathname: string, route: string): boolean { + return pathname === route || pathname.startsWith(`${route}/`); +} + +/** True when `href` points at a route that needs its own document. */ +export function needsFullDocument(href: string): boolean { + const path = href.split(/[?#]/)[0]; + return FULL_DOCUMENT_ROUTES.some((route) => matchesRoute(path, route)); +} + +/** + * True when a link from the page at `pathname` to `href` must be a full load: + * either the destination needs its own document, or the current page is one + * whose widened policy must not travel to the destination. + */ +export function shouldFullLoad(pathname: string | null, href: string): boolean { + return needsFullDocument(href) || (pathname !== null && needsFullDocument(pathname)); +} diff --git a/nextjs_space/lib/security/csp.ts b/nextjs_space/lib/security/csp.ts index 50453833..4b16c44a 100644 --- a/nextjs_space/lib/security/csp.ts +++ b/nextjs_space/lib/security/csp.ts @@ -119,6 +119,13 @@ export function buildCsp({ * Pick the CSP variant for the path actually being served (after middleware * rewrites). Analytics pages need 'unsafe-eval' (plotly); store pages need * frame-ancestors 'self' for the editor iframe viewport switcher. + * + * CONTRACT: a policy belongs to the document, not the route. A client-side + * navigation keeps the policy of the page the user loaded first, so any route + * whose variant WIDENS a directive must be entered and left by a full document + * load. The admin navigation does this for the analytics routes via + * lib/admin/hard-navigation.ts; the YouTube frame host is on every variant for + * the same reason (PR #278). Add a route here → add it there. */ export function variantForServedPath(servedPath: string): CspVariant { if (servedPath.startsWith("/store")) return "store"; diff --git a/nextjs_space/tests/unit/hard-navigation.test.ts b/nextjs_space/tests/unit/hard-navigation.test.ts new file mode 100644 index 00000000..f02b55a0 --- /dev/null +++ b/nextjs_space/tests/unit/hard-navigation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { needsFullDocument, shouldFullLoad } from "@/lib/admin/hard-navigation"; + +/** + * The analytics pages carry a wider CSP ('unsafe-eval' for plotly) than the + * rest of the admin. A policy belongs to the document, so links into and out + * of those pages must be full loads — the same trap that blocked the guide + * videos on /documents (PR #278), applied to scripts instead of frames. + */ +describe("needsFullDocument", () => { + it("recognises both analytics routes, with sub-paths and query strings", () => { + expect(needsFullDocument("/tenant-admin/analytics")).toBe(true); + expect(needsFullDocument("/super-admin/analytics")).toBe(true); + expect(needsFullDocument("/tenant-admin/analytics/retention")).toBe(true); + expect(needsFullDocument("/tenant-admin/analytics?period=30d")).toBe(true); + }); + + it("leaves every other admin route on client-side navigation", () => { + expect(needsFullDocument("/tenant-admin")).toBe(false); + expect(needsFullDocument("/tenant-admin/analytics-export")).toBe(false); + expect(needsFullDocument("/tenant-admin/orders")).toBe(false); + expect(needsFullDocument("/super-admin/tenants")).toBe(false); + }); +}); + +describe("shouldFullLoad", () => { + it("forces a full load INTO analytics from an ordinary page", () => { + expect(shouldFullLoad("/tenant-admin/orders", "/tenant-admin/analytics")).toBe(true); + }); + + it("forces a full load OUT of analytics so the wider policy does not travel", () => { + expect(shouldFullLoad("/tenant-admin/analytics", "/tenant-admin/orders")).toBe(true); + }); + + it("keeps ordinary admin links client-side", () => { + expect(shouldFullLoad("/tenant-admin/orders", "/tenant-admin/customers")).toBe(false); + expect(shouldFullLoad(null, "/tenant-admin/customers")).toBe(false); + }); +});