From 0dd4f25952def14dff942f66be0341c631981385 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 15 Apr 2026 13:01:48 -0700 Subject: [PATCH] feat(canvas): cookie consent banner with privacy-preserving default Adds a GDPR/ePrivacy-compliant cookie banner to the canvas root layout. Privacy-preserving default: no optional cookies are considered accepted until the user clicks "Accept all". Clicking "Necessary only" or dismissing records "rejected" and the banner does not re-appear until the cookie-policy version bumps. - New CookieConsent component wired into src/app/layout.tsx so it renders on every canvas route - Persists decision to localStorage as {decision, decidedAt, version} - Versioned schema: bumping CURRENT_VERSION re-prompts every user - Exports hasConsent() helper for feature code that needs to gate analytics / functional cookies on user choice - ARIA: role=dialog + aria-labelledby/aria-describedby so screen readers announce it as a dialog - Same storage key + schema as the control-plane legal-page banner (see molecule-controlplane PR #XX) so a user who accepts on one surface does not re-see the banner on the other Tests: 12 Vitest cases covering first-visit render, accept/reject persistence, version re-prompt, invalid-JSON recovery, privacy link attrs, ARIA markup, and the hasConsent helper under every state. Co-Authored-By: Claude Opus 4.6 (1M context) --- canvas/src/app/layout.tsx | 2 + canvas/src/components/CookieConsent.tsx | 144 ++++++++++++++++++ .../__tests__/CookieConsent.test.tsx | 119 +++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 canvas/src/components/CookieConsent.tsx create mode 100644 canvas/src/components/__tests__/CookieConsent.test.tsx diff --git a/canvas/src/app/layout.tsx b/canvas/src/app/layout.tsx index 15cd5646..a2d192b4 100644 --- a/canvas/src/app/layout.tsx +++ b/canvas/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { AuthGate } from "@/components/AuthGate"; +import { CookieConsent } from "@/components/CookieConsent"; export const metadata: Metadata = { title: "Molecule AI", @@ -20,6 +21,7 @@ export default function RootLayout({ when running on a tenant subdomain. Non-SaaS hosts (localhost, vercel preview URL, apex) pass through unchanged. */} {children} + ); diff --git a/canvas/src/components/CookieConsent.tsx b/canvas/src/components/CookieConsent.tsx new file mode 100644 index 00000000..82316b16 --- /dev/null +++ b/canvas/src/components/CookieConsent.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "molecule_cookie_consent"; + +// Three states, not two: "necessary-only" is distinct from "rejected" +// under GDPR/ePrivacy because the banner is supposed to let the user +// accept *some* cookies (functional, analytics) while still rejecting +// others. We keep the schema simple and offer just "accepted" (all) +// vs "rejected" (necessary only) for now — a future version can add +// per-category toggles if we ever ship analytics tracking. +export type ConsentDecision = "accepted" | "rejected"; + +interface StoredConsent { + decision: ConsentDecision; + decidedAt: string; // ISO-8601 UTC — makes audit logs unambiguous + version: number; // bump when the cookie policy changes materially +} + +// Current cookie-policy version. Bump this when we add a new cookie +// category or change data-sharing scope; the banner will re-prompt +// every user whose stored decision is on an older version. +const CURRENT_VERSION = 1; + +// getStoredConsent reads localStorage and returns null when either no +// decision exists OR the stored version is older than the current +// policy. Safe to call during render — guarded for SSR where window is +// undefined. +function getStoredConsent(): StoredConsent | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as StoredConsent; + if (parsed.version !== CURRENT_VERSION) return null; + return parsed; + } catch { + // Malformed JSON or localStorage blocked — treat as "no decision" + // so the banner re-prompts. Better than swallowing the error and + // leaving the user unable to recover. + return null; + } +} + +// storeConsent persists a decision plus the current policy version so +// we know when to re-prompt. Failures are swallowed — if localStorage +// is blocked (private mode, quota) the banner will re-appear on next +// visit, which is the safer fallback than a runtime error. +function storeConsent(decision: ConsentDecision): void { + try { + const record: StoredConsent = { + decision, + decidedAt: new Date().toISOString(), + version: CURRENT_VERSION, + }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record)); + } catch { + // intentional no-op + } +} + +// CookieConsent renders a dismissible footer banner that blocks nothing +// but visually prompts for a decision. Returns null after a decision is +// recorded so it doesn't waste vertical space for returning users. +// +// Privacy-preserving default: no cookies beyond strictly-necessary ones +// (session auth) are set until the user clicks Accept. Reject + dismiss +// both record "rejected" so we don't re-prompt until the next policy +// version bump. +export function CookieConsent() { + const [visible, setVisible] = useState(false); + + // Read persisted decision on mount. useState's initialState can't run + // on first render because localStorage is SSR-unsafe — defer to + // useEffect so the initial HTML is identical to the server snapshot. + useEffect(() => { + setVisible(getStoredConsent() === null); + }, []); + + if (!visible) return null; + + const decide = (decision: ConsentDecision) => { + storeConsent(decision); + setVisible(false); + }; + + return ( +
+
+
+ + +
+
+ + +
+
+
+ ); +} + +// hasConsent is a helper for feature code that needs to check whether +// optional cookies are allowed. Returns false under SSR or when no +// decision is on file, which matches the banner's privacy-preserving +// default ("assume no consent until proven otherwise"). +export function hasConsent(): boolean { + const stored = getStoredConsent(); + return stored?.decision === "accepted"; +} diff --git a/canvas/src/components/__tests__/CookieConsent.test.tsx b/canvas/src/components/__tests__/CookieConsent.test.tsx new file mode 100644 index 00000000..36314858 --- /dev/null +++ b/canvas/src/components/__tests__/CookieConsent.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { CookieConsent, hasConsent } from "../CookieConsent"; + +const STORAGE_KEY = "molecule_cookie_consent"; + +// These tests lock the privacy-preserving default: the banner appears on +// first visit, clicking either button records a decision, and subsequent +// renders skip the banner until the policy version changes. + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + window.localStorage.clear(); +}); + +describe("CookieConsent", () => { + it("renders the banner when no decision is stored", () => { + render(); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Accept all" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Necessary only" })).toBeTruthy(); + }); + + it("stores 'accepted' and hides the banner when user clicks Accept all", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Accept all" })); + expect(screen.queryByRole("dialog")).toBeNull(); + + const raw = window.localStorage.getItem(STORAGE_KEY); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.decision).toBe("accepted"); + expect(parsed.version).toBe(1); + expect(typeof parsed.decidedAt).toBe("string"); + }); + + it("stores 'rejected' and hides the banner when user clicks Necessary only", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Necessary only" })); + expect(screen.queryByRole("dialog")).toBeNull(); + + const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!); + expect(parsed.decision).toBe("rejected"); + }); + + it("does NOT render the banner when a current-version decision is already stored", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 1 }), + ); + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("re-prompts when the stored decision is on an older policy version", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 0 }), + ); + render(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("re-prompts when localStorage contains invalid JSON", () => { + window.localStorage.setItem(STORAGE_KEY, "{not json"); + render(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("exposes a privacy-policy link with target='_blank'", () => { + render(); + const link = screen.getByRole("link", { name: /privacy policy/i }); + expect(link).toBeTruthy(); + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toContain("noreferrer"); + }); + + it("uses role=dialog with aria-labelledby and aria-describedby for screen readers", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("aria-labelledby")).toBe("cookie-consent-title"); + expect(dialog.getAttribute("aria-describedby")).toBe("cookie-consent-body"); + }); +}); + +describe("hasConsent", () => { + it("returns false when no decision is stored (privacy-preserving default)", () => { + expect(hasConsent()).toBe(false); + }); + + it("returns true only when the stored decision is 'accepted'", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 1 }), + ); + expect(hasConsent()).toBe(true); + }); + + it("returns false when stored decision is 'rejected'", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ decision: "rejected", decidedAt: new Date().toISOString(), version: 1 }), + ); + expect(hasConsent()).toBe(false); + }); + + it("returns false when stored decision is from an older policy version", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 0 }), + ); + expect(hasConsent()).toBe(false); + }); +});