Path: blob/master/src/packages/frontend/cookie-consent/index.ts
14422 views
/*1* This file is part of CoCalc: Copyright © 2026 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// Public helpers shared by sign-in/sign-up flows and analytics gating. This6// module deliberately does NOT import the vanilla-cookieconsent CSS — that7// happens in ./init, which is the only entry point allowed to do a global CSS8// import (Next.js restricts global CSS imports to pages/_app.tsx).910import { useEffect, useState } from "react";1112import * as CookieConsent from "vanilla-cookieconsent";1314import { COOKIE_CATEGORIES, type CookieCategoryKey } from "./categories";15import { BANNER_STATE_EVENT, isBannerActive, isBannerDecided } from "./state";16import { revokeYouTubeConsent } from "./youtube";1718export { COOKIE_CATEGORIES };19export type { CookieCategoryKey };2021// Bump this if the cookie categories or banner text change in a way that22// invalidates prior consent. vanilla-cookieconsent will re-prompt the user.23export const COOKIE_CONSENT_REVISION = 2;2425// Snapshot of the user's consent we persist in account.other_settings. Kept26// minimal on purpose: the browser cookie is authoritative for the session;27// this record is for audit/UI display.28//29// Per-category booleans are derived from COOKIE_CATEGORIES rather than30// hand-listed — adding a new category in categories.ts automatically31// extends this type. Stored as individual fields rather than an array32// because the immutable.js layer that backs other_settings turns arrays33// into objects with numeric keys when round-tripping through JSONB.34export type ConsentSnapshot = Record<CookieCategoryKey, boolean> & {35timestamp: string; // ISO 8601, last time the user changed their choice36revision: number;37};3839// True once the user has acted on the banner (accepted necessary or all).40// Until then we block sign-up/sign-in. Returns:41// * false while customize is still loading (we don't yet know whether the42// banner will activate — be conservative so modals don't render on top43// of a banner that's about to appear)44// * true if the admin has the banner disabled (nothing to acknowledge)45// * v3's validConsent() if the banner is active46export function hasEssentialConsent(): boolean {47if (typeof window === "undefined") return false;48if (!isBannerDecided()) return false;49if (!isBannerActive()) return true;50try {51return CookieConsent.validConsent();52} catch {53return false;54}55}5657// Generic per-category consent check. Returns false if the v3 runtime58// isn't running (banner admin-disabled or not yet initialised) — callers59// that want "passthrough when banner is off" should check60// `cookie_banner_enabled` from customize separately, mirroring the pattern61// in `customize.tsx#init_analytics`.62export function hasCategoryConsent(key: CookieCategoryKey): boolean {63if (typeof window === "undefined") return false;64try {65return CookieConsent.acceptedCategory(key);66} catch {67return false;68}69}7071// Backwards-compatible alias for the analytics category. Existing callers72// (next/components/analytics, frontend/customize, sign-in-hooks) keep their73// import unchanged.74export function hasTrackingConsent(): boolean {75return hasCategoryConsent("analytics");76}7778// Open the consent modal for first-time consent (e.g. when user clicks79// sign-in without having accepted yet).80export function showConsentModal(): void {81if (typeof window === "undefined") return;82try {83CookieConsent.show(true);84} catch {85// banner not initialised — nothing to show86}87}8889// Force-consent fallback for the SPA: an SSO callback can drop a logged-in90// user on /app without ever passing through the auth-page overlay. Once the91// account is loaded and we see the user has no valid consent, apply the same92// dimmed-overlay treatment manually (vanilla-cookieconsent's93// `disablePageInteraction` is config-time only; we replicate it by toggling94// the `disable--interaction` class on <html>, which the v3 stylesheet hooks95// into for the backdrop and scroll-lock).96//97// Returns a cleanup function. The class is also auto-removed when the user98// makes a choice (cc:onConsent / cc:onChange), so this is mostly belt &99// braces — callers should still invoke the returned cleanup on unmount.100export function enableForceConsent(): () => void {101if (typeof window === "undefined") return () => {};102if (!isBannerActive()) return () => {}; // banner disabled by admin103if (hasEssentialConsent()) return () => {}; // nothing to enforce104const html = document.documentElement;105html.classList.add("disable--interaction");106try {107CookieConsent.show(true);108} catch {109/* banner runtime not ready yet — class will still dim the page */110}111let removed = false;112const remove = () => {113if (removed) return;114removed = true;115html.classList.remove("disable--interaction");116window.removeEventListener("cc:onConsent", remove);117window.removeEventListener("cc:onChange", remove);118};119window.addEventListener("cc:onConsent", remove);120window.addEventListener("cc:onChange", remove);121return remove;122}123124// Open the preferences modal so a user can change their choice.125export function showPreferences(): void {126if (typeof window === "undefined") return;127try {128CookieConsent.showPreferences();129} catch {130// banner not initialised131}132}133134// Returns true if the user can proceed (essential consent already given, or135// banner not enabled), or false after surfacing the consent modal so the user136// can grant it.137export function requireEssentialConsent(): boolean {138if (hasEssentialConsent()) return true;139showConsentModal();140return false;141}142143// Restore the browser cc_cookie from a previously-saved snapshot (e.g. from144// accounts.other_settings.cookie_consent). Used to skip the banner for145// signed-in users who have cleared cookies but already gave consent in146// their account — the consent record on the server stands as proof of147// approval, the browser cookie is just a runtime artifact.148//149// MUST be called BEFORE initCookieConsent / CookieConsent.run(), since v3150// reads the cookie once during run(). Returns true if a cookie was written,151// false if anything blocked the restore (no snapshot, revision mismatch,152// browser cookie already present, etc.).153export function restoreConsentCookieFromSnapshot(154snap: ConsentSnapshot | null,155): boolean {156if (typeof document === "undefined") return false;157if (snap == null) return false;158// Don't trample an existing cookie — browser is authoritative for the159// current session.160if (document.cookie.split(";").some((c) => c.trim().startsWith("cc_cookie=")))161return false;162// Re-prompt if the saved consent is for an older revision (categories or163// text changed materially since the user last decided).164if (snap.revision !== COOKIE_CONSENT_REVISION) return false;165166const categories: string[] = [];167const services: Record<string, string[]> = {};168for (const c of COOKIE_CATEGORIES) {169services[c.key] = [];170if ((snap as Record<string, unknown>)[c.key]) categories.push(c.key);171}172// Necessary is always-on; ensure it's listed even if the snapshot somehow173// omits it.174if (!categories.includes("necessary")) categories.push("necessary");175176const timestamp = snap.timestamp || new Date().toISOString();177const oneYearMs = 365 * 24 * 60 * 60 * 1000;178const value = {179categories,180revision: snap.revision,181data: null,182consentTimestamp: timestamp,183consentId: cryptoRandomId(),184services,185languageCode: "en",186lastConsentTimestamp: timestamp,187expirationTime: Date.now() + oneYearMs,188};189document.cookie =190"cc_cookie=" +191encodeURIComponent(JSON.stringify(value)) +192`; path=/; max-age=${oneYearMs / 1000}; SameSite=Lax`;193return true;194}195196function cryptoRandomId(): string {197// Best-effort UUID-ish identifier for cc_cookie.consentId. The DB record198// is the authoritative consent log; this id is just v3's internal handle.199if (typeof crypto !== "undefined" && "randomUUID" in crypto) {200return crypto.randomUUID();201}202return Math.random().toString(36).slice(2) + Date.now().toString(36);203}204205// Read the current consent state from vanilla-cookieconsent's cookie. Returns206// null if the user has not yet acted on the banner.207export function getConsentSnapshot(): ConsentSnapshot | null {208if (typeof window === "undefined") return null;209try {210if (!CookieConsent.validConsent()) return null;211const cookie = CookieConsent.getCookie();212if (cookie == null) return null;213const accepted = new Set<string>(cookie.categories ?? []);214const snap = {215timestamp: cookie.lastConsentTimestamp ?? cookie.consentTimestamp ?? "",216revision: cookie.revision ?? 0,217} as ConsentSnapshot;218for (const c of COOKIE_CATEGORIES) {219(snap as Record<string, boolean | string | number>)[c.key] = accepted.has(220c.key,221);222}223return snap;224} catch {225return null;226}227}228229type Unsubscribe = () => void;230231// Subscribe to consent changes. Fires immediately with the current snapshot232// (or null if the user hasn't acted yet) and again on every cc:onConsent /233// cc:onChange event. Callers receive null when there's no valid consent —234// most callers should ignore those events rather than treat them as "consent235// was revoked"; the cc_cookie can expire naturally after a year. Persistence-236// style callers should skip null; UI-style callers should render the237// absence-of-consent state.238//239// Note: vanilla-cookieconsent fires cc:onChange synchronously while it's240// still flushing the new cookie value, so reading getConsentSnapshot() at241// event time can return a stale snapshot (or briefly null). We call the242// handler again on the next macrotask so callers always settle on the243// post-toggle state.244export function onConsentChange(245cb: (snap: ConsentSnapshot | null) => void,246): Unsubscribe {247if (typeof window === "undefined") return () => {};248let timer: number | undefined;249const handler = () => {250cb(getConsentSnapshot());251if (timer != null) window.clearTimeout(timer);252timer = window.setTimeout(() => cb(getConsentSnapshot()), 0);253};254window.addEventListener("cc:onConsent", handler);255window.addEventListener("cc:onChange", handler);256window.addEventListener(BANNER_STATE_EVENT, handler);257// Fire once for the current state.258handler();259return () => {260window.removeEventListener("cc:onConsent", handler);261window.removeEventListener("cc:onChange", handler);262window.removeEventListener(BANNER_STATE_EVENT, handler);263if (timer != null) window.clearTimeout(timer);264};265}266267// React hook: re-renders when essential consent state flips. Used to disable268// sign-in / sign-up submit buttons until the user acknowledges the banner.269//270// First render returns false to avoid a Next.js hydration mismatch (the271// server can't read the cc_cookie). The synchronous useEffect-on-mount call272// then reconciles to the real value before the next paint, so a returning273// user with valid consent never sees the "Acknowledge cookie banner to274// continue" label flash. We also subscribe to BANNER_STATE_EVENT so we275// re-render when initCookieConsent decides whether the banner activates —276// otherwise the hook could be stuck at "consented" while customize is277// loading, since the inactive-banner branch returns true.278export function useEssentialConsent(): boolean {279const [accepted, setAccepted] = useState<boolean>(false);280useEffect(() => {281if (typeof window === "undefined") return;282const update = () => setAccepted(hasEssentialConsent());283update(); // sync reconcile post-hydration284window.addEventListener("cc:onConsent", update);285window.addEventListener("cc:onChange", update);286window.addEventListener(BANNER_STATE_EVENT, update);287return () => {288window.removeEventListener("cc:onConsent", update);289window.removeEventListener("cc:onChange", update);290window.removeEventListener(BANNER_STATE_EVENT, update);291};292}, []);293return accepted;294}295296// Erase every cookie this module is responsible for: the vanilla-cookieconsent297// state cookie (cc_cookie), the dedicated YouTube consent cookie, and the298// autoClearCookies declared by each category in categories.ts. Used by the299// "Clear cookies" affordance in the next.js footer for signed-out visitors300// who can't reach the in-app preferences modal. Best-effort: we don't know301// the (domain, path) attributes used at write time, so we issue an expiring302// write for every plausible variant of the current hostname — browsers303// silently reject invalid public-suffix domains, so over-attempting is304// harmless.305export function clearAllConsentCookies(): void {306if (typeof document === "undefined" || typeof window === "undefined") return;307308// Collect names: cc_cookie itself plus everything categories.ts knows about.309// RegExp matchers (e.g. /^_ga/) are expanded against the live cookie jar.310const names = new Set<string>(["cc_cookie"]);311const matchers: Array<string | RegExp> = [];312for (const category of COOKIE_CATEGORIES) {313// `as const satisfies` narrows each entry to its literal type, so only314// entries that declare autoClearCookies expose the property.315if (!("autoClearCookies" in category)) continue;316for (const item of category.autoClearCookies) {317matchers.push(item.name);318}319}320for (const m of matchers) {321if (typeof m === "string") {322names.add(m);323} else {324for (const cookie of document.cookie.split(";")) {325const n = cookie.trim().split("=")[0];326if (n && m.test(n)) names.add(n);327}328}329}330331const domains = parentDomainCandidates(window.location.hostname);332for (const name of names) {333for (const domain of domains) {334document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax${335domain == null ? "" : `; domain=${domain}`336}`;337}338}339340// YouTube consent has its own module that owns the cookie name + change341// event. Defer to it rather than duplicating the constant here.342revokeYouTubeConsent();343}344345// Every parent suffix of hostname in both `host` and `.host` form, plus an346// `undefined` first entry (= write without a domain attribute, matching347// host-only cookies). Robust against multi-level TLDs (.co.uk, .com.au)348// without needing the Public Suffix List: invalid candidates (`co.uk`, `com`)349// are rejected by the browser.350function parentDomainCandidates(hostname: string): Array<string | undefined> {351const result: Array<string | undefined> = [undefined];352const parts = hostname.split(".");353for (let i = 0; i < parts.length; i++) {354const suffix = parts.slice(i).join(".");355if (!suffix) continue;356result.push(suffix, `.${suffix}`);357}358return result;359}360361362