Merge pull request #251 from Molecule-AI/feat/cookie-consent-banner

feat(canvas): cookie consent banner
This commit is contained in:
Hongming Wang 2026-04-15 13:49:53 -07:00 committed by GitHub
commit 59e6665f94
3 changed files with 265 additions and 0 deletions

View File

@ -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. */}
<AuthGate>{children}</AuthGate>
<CookieConsent />
</body>
</html>
);

View File

@ -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 (
<div
role="dialog"
aria-labelledby="cookie-consent-title"
aria-describedby="cookie-consent-body"
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-zinc-800 bg-zinc-950/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
>
<div className="mx-auto flex max-w-5xl flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-300">
<p id="cookie-consent-title" className="font-medium text-zinc-100">
Cookies &amp; your privacy
</p>
<p id="cookie-consent-body" className="mt-1 text-zinc-400">
We use strictly-necessary cookies for authentication and session
continuity. Accept to also allow optional functional cookies that
improve your canvas experience (layout preferences, recent
workspaces). See our{" "}
<a
href="https://moleculesai.app/legal/privacy"
className="text-blue-400 underline hover:text-blue-300"
target="_blank"
rel="noreferrer"
>
privacy policy
</a>{" "}
for details.
</p>
</div>
<div className="flex gap-2 md:shrink-0">
<button
type="button"
onClick={() => decide("rejected")}
className="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-800"
>
Necessary only
</button>
<button
type="button"
onClick={() => decide("accepted")}
className="rounded border border-blue-600 bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500"
>
Accept all
</button>
</div>
</div>
</div>
);
}
// 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";
}

View File

@ -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(<CookieConsent />);
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(<CookieConsent />);
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(<CookieConsent />);
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(<CookieConsent />);
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(<CookieConsent />);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("re-prompts when localStorage contains invalid JSON", () => {
window.localStorage.setItem(STORAGE_KEY, "{not json");
render(<CookieConsent />);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("exposes a privacy-policy link with target='_blank'", () => {
render(<CookieConsent />);
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(<CookieConsent />);
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);
});
});