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
42 changes: 4 additions & 38 deletions nextjs_space/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 ========== */

Expand Down Expand Up @@ -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.
============================================================ */

/* ============================================================
Expand Down
46 changes: 30 additions & 16 deletions nextjs_space/components/admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -153,22 +154,21 @@ export function AdminSidebar({
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);

return (
<Link
key={item.id}
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 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 = (
<>
<Icon
className={cn(
"h-5 w-5 transition-colors flex-shrink-0",
Expand Down Expand Up @@ -197,6 +197,20 @@ export function AdminSidebar({
{item.label}
</div>
)}
</>
);

// 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
<a key={item.id} {...linkProps}>
{content}
</a>
) : (
<Link key={item.id} {...linkProps}>
{content}
</Link>
);
})}
Expand Down
26 changes: 19 additions & 7 deletions nextjs_space/components/admin/QuickActionsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,18 +88,18 @@ export const QuickActionsWidget = React.forwardRef<
const Icon = action.icon;
const isPrimary = index === 0;

return (
<Link
key={action.href}
href={action.href}
className={cn(
const linkProps = {
href: action.href,
className: cn(
"group relative flex flex-col items-center gap-3 p-5",
"rounded-xl border border-bs-border-100 bg-bs-card-2",
"transition-colors duration-200",
"hover:border-bs-border hover:bg-bs-card-3",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bs-green/40",
)}
>
),
};
const content = (
<>
<div
className={cn(
"flex items-center justify-center w-12 h-12 rounded-xl",
Expand All @@ -119,6 +120,17 @@ export const QuickActionsWidget = React.forwardRef<
<span className="text-sm font-medium text-bs-fg text-center">
{action.label}
</span>
</>
);
// 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
<a key={action.href} {...linkProps}>
{content}
</a>
) : (
<Link key={action.href} {...linkProps}>
{content}
</Link>
);
})}
Expand Down
39 changes: 39 additions & 0 deletions nextjs_space/lib/admin/hard-navigation.ts
Original file line number Diff line number Diff line change
@@ -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 `<Link>` navigation into analytics keeps
* the policy of the page the user started on — plotly then fails to run —
* and a `<Link>` 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));
}
7 changes: 7 additions & 0 deletions nextjs_space/lib/security/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
40 changes: 40 additions & 0 deletions nextjs_space/tests/unit/hard-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading