Merge pull request #251 from Molecule-AI/feat/cookie-consent-banner
feat(canvas): cookie consent banner
This commit is contained in:
commit
59e6665f94
@ -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>
|
||||
);
|
||||
|
||||
144
canvas/src/components/CookieConsent.tsx
Normal file
144
canvas/src/components/CookieConsent.tsx
Normal 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 & 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";
|
||||
}
|
||||
119
canvas/src/components/__tests__/CookieConsent.test.tsx
Normal file
119
canvas/src/components/__tests__/CookieConsent.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user