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