Path: blob/master/src/packages/frontend/cookie-consent/init.ts
14422 views
/*1* This file is part of CoCalc: Copyright © 2026 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// GDPR cookie consent banner shared between the SPA frontend and the Next.js6// landing pages. We use vanilla-cookieconsent v3 because it is framework7// agnostic — the same configuration object initialises the banner in both the8// SPA and the SSR-rendered Next.js app.9//10// Note: callers must `import "vanilla-cookieconsent/dist/cookieconsent.css"`11// themselves, alongside calling initCookieConsent. Next.js refuses global CSS12// imports from any file other than pages/_app.tsx, even transitively through13// an imported module — so the CSS import has to live directly in the entry.14// Helpers that don't need the CSS (e.g. the requireEssentialConsent gate used15// in sign-in/sign-up) live in ./index.1617import { join } from "path";1819import * as CookieConsent from "vanilla-cookieconsent";2021import { appBasePath } from "@cocalc/frontend/customize/app-base-path";22import { markdown_to_html } from "@cocalc/frontend/markdown";2324import { COOKIE_CATEGORIES, type CookieCategory } from "./categories";25import { COOKIE_CONSENT_REVISION } from "./index";26import { markBannerActive, markBannerDecidedDisabled } from "./state";27import {28YOUTUBE_SECTION_BUTTON_ID,29YOUTUBE_SECTION_CSS,30YOUTUBE_SECTION_STATUS_ID,31buildTranslation,32} from "./translations";33import {34YOUTUBE_CONSENT_EVENT,35grantYouTubeConsent,36hasYouTubeConsent,37revokeYouTubeConsent,38} from "./youtube";3940function buildCategoriesConfig(): Record<string, CookieConsent.Category> {41const out: Record<string, CookieConsent.Category> = {};42for (const raw of COOKIE_CATEGORIES) {43// Widen from `as const satisfies` narrowing so optional fields are visible.44const c: CookieCategory = raw;45const entry: CookieConsent.Category = {46enabled: c.defaultEnabled,47readOnly: c.readOnly,48};49if (c.autoClearCookies && c.autoClearCookies.length > 0) {50entry.autoClear = {51cookies: c.autoClearCookies.map((x) => ({ name: x.name })),52};53}54out[c.key] = entry;55}56return out;57}5859let initialized = false;6061export interface InitOptions {62enabled?: boolean;63// Markdown body shown in the banner & preferences modal.64textMarkdown?: string;65}6667// We never pass disablePageInteraction here. v3 only honours that at init,68// so it would not survive client-side navigation between non-auth and auth69// routes. Force-consent mode is applied separately via enableForceConsent70// from ./index, which toggles the same `disable--interaction` class on71// <html> as v3's built-in option and can be flipped on route changes.72export function initCookieConsent({73enabled,74textMarkdown,75}: InitOptions): void {76if (initialized) return;77if (typeof window === "undefined") return;78if (!enabled) {79// Customize loaded with banner disabled — flip the "decided" flag so80// gate helpers (hasEssentialConsent, useEssentialConsent) stop being81// conservative and pass through.82markBannerDecidedDisabled();83return;84}85initialized = true;86markBannerActive();8788const descHtml = markdown_to_html(textMarkdown?.trim() || "");89const privacyUrl = join(appBasePath, "policies/privacy");90const termsUrl = join(appBasePath, "policies/terms");9192try {93const runResult: any = CookieConsent.run({94revision: COOKIE_CONSENT_REVISION,95guiOptions: {96consentModal: {97layout: "box inline",98position: "bottom right",99equalWeightButtons: true,100flipButtons: false,101},102preferencesModal: {103layout: "bar",104position: "right",105equalWeightButtons: true,106flipButtons: false,107},108},109categories: buildCategoriesConfig(),110language: {111default: "en",112translations: {113en: buildTranslation(descHtml, privacyUrl, termsUrl),114},115},116});117if (runResult && typeof runResult.catch === "function") {118runResult.catch((err: unknown) =>119console.error("cookie-consent: run rejected", err),120);121}122injectYouTubeSectionStyles();123wireYouTubePreferencesSection();124} catch (err) {125console.error("cookie-consent: run threw", err);126}127}128129// Mount the YouTube section stylesheet into <head>. v3 places our section130// description inside a <p>, which can't host a <style> child without the131// HTML parser auto-closing the paragraph. Putting the rules in <head>132// avoids that and applies them to every modal open.133function injectYouTubeSectionStyles(): void {134if (typeof document === "undefined") return;135const id = "cocalc-yt-styles";136if (document.getElementById(id) != null) return;137const style = document.createElement("style");138style.id = id;139style.textContent = YOUTUBE_SECTION_CSS;140document.head.appendChild(style);141}142143// Hook up the "Embedded videos" section the translations file injected into144// the preferences modal. v3 only renders that HTML — it doesn't know about145// our parallel YouTube consent cookie — so we re-populate the status badge146// and bind the toggle checkbox each time the modal opens, and refresh147// state whenever the cookie changes (e.g. from a click-to-load gate on the148// landing page while the modal is already open).149function wireYouTubePreferencesSection(): void {150if (typeof window === "undefined") return;151const refresh = () => {152const status = document.getElementById(YOUTUBE_SECTION_STATUS_ID);153const toggle = document.getElementById(154YOUTUBE_SECTION_BUTTON_ID,155) as HTMLInputElement | null;156if (status == null || toggle == null) return;157const granted = hasYouTubeConsent();158status.textContent = granted ? "Allowed" : "Blocked";159status.classList.toggle("cocalc-yt-status--on", granted);160status.classList.toggle("cocalc-yt-status--off", !granted);161if (toggle.checked !== granted) toggle.checked = granted;162};163// v3 fires cc:onModalShow when either modal opens; we don't try to164// filter to just the preferences modal because the elements are scoped165// by id and absent when the consent modal is open.166window.addEventListener("cc:onModalShow", refresh);167window.addEventListener(YOUTUBE_CONSENT_EVENT, refresh);168// Delegated change handler — the checkbox is re-rendered each time v3169// mounts the preferences modal, so binding directly on the element170// would miss subsequent opens.171document.addEventListener("change", (ev) => {172const target = ev.target;173if (!(target instanceof HTMLInputElement)) return;174if (target.id !== YOUTUBE_SECTION_BUTTON_ID) return;175if (target.checked) {176grantYouTubeConsent();177} else {178revokeYouTubeConsent();179}180});181}182183184185