Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/cookie-consent/index.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
// Public helpers shared by sign-in/sign-up flows and analytics gating. This
7
// module deliberately does NOT import the vanilla-cookieconsent CSS — that
8
// happens in ./init, which is the only entry point allowed to do a global CSS
9
// import (Next.js restricts global CSS imports to pages/_app.tsx).
10
11
import { useEffect, useState } from "react";
12
13
import * as CookieConsent from "vanilla-cookieconsent";
14
15
import { COOKIE_CATEGORIES, type CookieCategoryKey } from "./categories";
16
import { BANNER_STATE_EVENT, isBannerActive, isBannerDecided } from "./state";
17
import { revokeYouTubeConsent } from "./youtube";
18
19
export { COOKIE_CATEGORIES };
20
export type { CookieCategoryKey };
21
22
// Bump this if the cookie categories or banner text change in a way that
23
// invalidates prior consent. vanilla-cookieconsent will re-prompt the user.
24
export const COOKIE_CONSENT_REVISION = 2;
25
26
// Snapshot of the user's consent we persist in account.other_settings. Kept
27
// minimal on purpose: the browser cookie is authoritative for the session;
28
// this record is for audit/UI display.
29
//
30
// Per-category booleans are derived from COOKIE_CATEGORIES rather than
31
// hand-listed — adding a new category in categories.ts automatically
32
// extends this type. Stored as individual fields rather than an array
33
// because the immutable.js layer that backs other_settings turns arrays
34
// into objects with numeric keys when round-tripping through JSONB.
35
export type ConsentSnapshot = Record<CookieCategoryKey, boolean> & {
36
timestamp: string; // ISO 8601, last time the user changed their choice
37
revision: number;
38
};
39
40
// True once the user has acted on the banner (accepted necessary or all).
41
// Until then we block sign-up/sign-in. Returns:
42
// * false while customize is still loading (we don't yet know whether the
43
// banner will activate — be conservative so modals don't render on top
44
// of a banner that's about to appear)
45
// * true if the admin has the banner disabled (nothing to acknowledge)
46
// * v3's validConsent() if the banner is active
47
export function hasEssentialConsent(): boolean {
48
if (typeof window === "undefined") return false;
49
if (!isBannerDecided()) return false;
50
if (!isBannerActive()) return true;
51
try {
52
return CookieConsent.validConsent();
53
} catch {
54
return false;
55
}
56
}
57
58
// Generic per-category consent check. Returns false if the v3 runtime
59
// isn't running (banner admin-disabled or not yet initialised) — callers
60
// that want "passthrough when banner is off" should check
61
// `cookie_banner_enabled` from customize separately, mirroring the pattern
62
// in `customize.tsx#init_analytics`.
63
export function hasCategoryConsent(key: CookieCategoryKey): boolean {
64
if (typeof window === "undefined") return false;
65
try {
66
return CookieConsent.acceptedCategory(key);
67
} catch {
68
return false;
69
}
70
}
71
72
// Backwards-compatible alias for the analytics category. Existing callers
73
// (next/components/analytics, frontend/customize, sign-in-hooks) keep their
74
// import unchanged.
75
export function hasTrackingConsent(): boolean {
76
return hasCategoryConsent("analytics");
77
}
78
79
// Open the consent modal for first-time consent (e.g. when user clicks
80
// sign-in without having accepted yet).
81
export function showConsentModal(): void {
82
if (typeof window === "undefined") return;
83
try {
84
CookieConsent.show(true);
85
} catch {
86
// banner not initialised — nothing to show
87
}
88
}
89
90
// Force-consent fallback for the SPA: an SSO callback can drop a logged-in
91
// user on /app without ever passing through the auth-page overlay. Once the
92
// account is loaded and we see the user has no valid consent, apply the same
93
// dimmed-overlay treatment manually (vanilla-cookieconsent's
94
// `disablePageInteraction` is config-time only; we replicate it by toggling
95
// the `disable--interaction` class on <html>, which the v3 stylesheet hooks
96
// into for the backdrop and scroll-lock).
97
//
98
// Returns a cleanup function. The class is also auto-removed when the user
99
// makes a choice (cc:onConsent / cc:onChange), so this is mostly belt &
100
// braces — callers should still invoke the returned cleanup on unmount.
101
export function enableForceConsent(): () => void {
102
if (typeof window === "undefined") return () => {};
103
if (!isBannerActive()) return () => {}; // banner disabled by admin
104
if (hasEssentialConsent()) return () => {}; // nothing to enforce
105
const html = document.documentElement;
106
html.classList.add("disable--interaction");
107
try {
108
CookieConsent.show(true);
109
} catch {
110
/* banner runtime not ready yet — class will still dim the page */
111
}
112
let removed = false;
113
const remove = () => {
114
if (removed) return;
115
removed = true;
116
html.classList.remove("disable--interaction");
117
window.removeEventListener("cc:onConsent", remove);
118
window.removeEventListener("cc:onChange", remove);
119
};
120
window.addEventListener("cc:onConsent", remove);
121
window.addEventListener("cc:onChange", remove);
122
return remove;
123
}
124
125
// Open the preferences modal so a user can change their choice.
126
export function showPreferences(): void {
127
if (typeof window === "undefined") return;
128
try {
129
CookieConsent.showPreferences();
130
} catch {
131
// banner not initialised
132
}
133
}
134
135
// Returns true if the user can proceed (essential consent already given, or
136
// banner not enabled), or false after surfacing the consent modal so the user
137
// can grant it.
138
export function requireEssentialConsent(): boolean {
139
if (hasEssentialConsent()) return true;
140
showConsentModal();
141
return false;
142
}
143
144
// Restore the browser cc_cookie from a previously-saved snapshot (e.g. from
145
// accounts.other_settings.cookie_consent). Used to skip the banner for
146
// signed-in users who have cleared cookies but already gave consent in
147
// their account — the consent record on the server stands as proof of
148
// approval, the browser cookie is just a runtime artifact.
149
//
150
// MUST be called BEFORE initCookieConsent / CookieConsent.run(), since v3
151
// reads the cookie once during run(). Returns true if a cookie was written,
152
// false if anything blocked the restore (no snapshot, revision mismatch,
153
// browser cookie already present, etc.).
154
export function restoreConsentCookieFromSnapshot(
155
snap: ConsentSnapshot | null,
156
): boolean {
157
if (typeof document === "undefined") return false;
158
if (snap == null) return false;
159
// Don't trample an existing cookie — browser is authoritative for the
160
// current session.
161
if (document.cookie.split(";").some((c) => c.trim().startsWith("cc_cookie=")))
162
return false;
163
// Re-prompt if the saved consent is for an older revision (categories or
164
// text changed materially since the user last decided).
165
if (snap.revision !== COOKIE_CONSENT_REVISION) return false;
166
167
const categories: string[] = [];
168
const services: Record<string, string[]> = {};
169
for (const c of COOKIE_CATEGORIES) {
170
services[c.key] = [];
171
if ((snap as Record<string, unknown>)[c.key]) categories.push(c.key);
172
}
173
// Necessary is always-on; ensure it's listed even if the snapshot somehow
174
// omits it.
175
if (!categories.includes("necessary")) categories.push("necessary");
176
177
const timestamp = snap.timestamp || new Date().toISOString();
178
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
179
const value = {
180
categories,
181
revision: snap.revision,
182
data: null,
183
consentTimestamp: timestamp,
184
consentId: cryptoRandomId(),
185
services,
186
languageCode: "en",
187
lastConsentTimestamp: timestamp,
188
expirationTime: Date.now() + oneYearMs,
189
};
190
document.cookie =
191
"cc_cookie=" +
192
encodeURIComponent(JSON.stringify(value)) +
193
`; path=/; max-age=${oneYearMs / 1000}; SameSite=Lax`;
194
return true;
195
}
196
197
function cryptoRandomId(): string {
198
// Best-effort UUID-ish identifier for cc_cookie.consentId. The DB record
199
// is the authoritative consent log; this id is just v3's internal handle.
200
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
201
return crypto.randomUUID();
202
}
203
return Math.random().toString(36).slice(2) + Date.now().toString(36);
204
}
205
206
// Read the current consent state from vanilla-cookieconsent's cookie. Returns
207
// null if the user has not yet acted on the banner.
208
export function getConsentSnapshot(): ConsentSnapshot | null {
209
if (typeof window === "undefined") return null;
210
try {
211
if (!CookieConsent.validConsent()) return null;
212
const cookie = CookieConsent.getCookie();
213
if (cookie == null) return null;
214
const accepted = new Set<string>(cookie.categories ?? []);
215
const snap = {
216
timestamp: cookie.lastConsentTimestamp ?? cookie.consentTimestamp ?? "",
217
revision: cookie.revision ?? 0,
218
} as ConsentSnapshot;
219
for (const c of COOKIE_CATEGORIES) {
220
(snap as Record<string, boolean | string | number>)[c.key] = accepted.has(
221
c.key,
222
);
223
}
224
return snap;
225
} catch {
226
return null;
227
}
228
}
229
230
type Unsubscribe = () => void;
231
232
// Subscribe to consent changes. Fires immediately with the current snapshot
233
// (or null if the user hasn't acted yet) and again on every cc:onConsent /
234
// cc:onChange event. Callers receive null when there's no valid consent —
235
// most callers should ignore those events rather than treat them as "consent
236
// was revoked"; the cc_cookie can expire naturally after a year. Persistence-
237
// style callers should skip null; UI-style callers should render the
238
// absence-of-consent state.
239
//
240
// Note: vanilla-cookieconsent fires cc:onChange synchronously while it's
241
// still flushing the new cookie value, so reading getConsentSnapshot() at
242
// event time can return a stale snapshot (or briefly null). We call the
243
// handler again on the next macrotask so callers always settle on the
244
// post-toggle state.
245
export function onConsentChange(
246
cb: (snap: ConsentSnapshot | null) => void,
247
): Unsubscribe {
248
if (typeof window === "undefined") return () => {};
249
let timer: number | undefined;
250
const handler = () => {
251
cb(getConsentSnapshot());
252
if (timer != null) window.clearTimeout(timer);
253
timer = window.setTimeout(() => cb(getConsentSnapshot()), 0);
254
};
255
window.addEventListener("cc:onConsent", handler);
256
window.addEventListener("cc:onChange", handler);
257
window.addEventListener(BANNER_STATE_EVENT, handler);
258
// Fire once for the current state.
259
handler();
260
return () => {
261
window.removeEventListener("cc:onConsent", handler);
262
window.removeEventListener("cc:onChange", handler);
263
window.removeEventListener(BANNER_STATE_EVENT, handler);
264
if (timer != null) window.clearTimeout(timer);
265
};
266
}
267
268
// React hook: re-renders when essential consent state flips. Used to disable
269
// sign-in / sign-up submit buttons until the user acknowledges the banner.
270
//
271
// First render returns false to avoid a Next.js hydration mismatch (the
272
// server can't read the cc_cookie). The synchronous useEffect-on-mount call
273
// then reconciles to the real value before the next paint, so a returning
274
// user with valid consent never sees the "Acknowledge cookie banner to
275
// continue" label flash. We also subscribe to BANNER_STATE_EVENT so we
276
// re-render when initCookieConsent decides whether the banner activates —
277
// otherwise the hook could be stuck at "consented" while customize is
278
// loading, since the inactive-banner branch returns true.
279
export function useEssentialConsent(): boolean {
280
const [accepted, setAccepted] = useState<boolean>(false);
281
useEffect(() => {
282
if (typeof window === "undefined") return;
283
const update = () => setAccepted(hasEssentialConsent());
284
update(); // sync reconcile post-hydration
285
window.addEventListener("cc:onConsent", update);
286
window.addEventListener("cc:onChange", update);
287
window.addEventListener(BANNER_STATE_EVENT, update);
288
return () => {
289
window.removeEventListener("cc:onConsent", update);
290
window.removeEventListener("cc:onChange", update);
291
window.removeEventListener(BANNER_STATE_EVENT, update);
292
};
293
}, []);
294
return accepted;
295
}
296
297
// Erase every cookie this module is responsible for: the vanilla-cookieconsent
298
// state cookie (cc_cookie), the dedicated YouTube consent cookie, and the
299
// autoClearCookies declared by each category in categories.ts. Used by the
300
// "Clear cookies" affordance in the next.js footer for signed-out visitors
301
// who can't reach the in-app preferences modal. Best-effort: we don't know
302
// the (domain, path) attributes used at write time, so we issue an expiring
303
// write for every plausible variant of the current hostname — browsers
304
// silently reject invalid public-suffix domains, so over-attempting is
305
// harmless.
306
export function clearAllConsentCookies(): void {
307
if (typeof document === "undefined" || typeof window === "undefined") return;
308
309
// Collect names: cc_cookie itself plus everything categories.ts knows about.
310
// RegExp matchers (e.g. /^_ga/) are expanded against the live cookie jar.
311
const names = new Set<string>(["cc_cookie"]);
312
const matchers: Array<string | RegExp> = [];
313
for (const category of COOKIE_CATEGORIES) {
314
// `as const satisfies` narrows each entry to its literal type, so only
315
// entries that declare autoClearCookies expose the property.
316
if (!("autoClearCookies" in category)) continue;
317
for (const item of category.autoClearCookies) {
318
matchers.push(item.name);
319
}
320
}
321
for (const m of matchers) {
322
if (typeof m === "string") {
323
names.add(m);
324
} else {
325
for (const cookie of document.cookie.split(";")) {
326
const n = cookie.trim().split("=")[0];
327
if (n && m.test(n)) names.add(n);
328
}
329
}
330
}
331
332
const domains = parentDomainCandidates(window.location.hostname);
333
for (const name of names) {
334
for (const domain of domains) {
335
document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax${
336
domain == null ? "" : `; domain=${domain}`
337
}`;
338
}
339
}
340
341
// YouTube consent has its own module that owns the cookie name + change
342
// event. Defer to it rather than duplicating the constant here.
343
revokeYouTubeConsent();
344
}
345
346
// Every parent suffix of hostname in both `host` and `.host` form, plus an
347
// `undefined` first entry (= write without a domain attribute, matching
348
// host-only cookies). Robust against multi-level TLDs (.co.uk, .com.au)
349
// without needing the Public Suffix List: invalid candidates (`co.uk`, `com`)
350
// are rejected by the browser.
351
function parentDomainCandidates(hostname: string): Array<string | undefined> {
352
const result: Array<string | undefined> = [undefined];
353
const parts = hostname.split(".");
354
for (let i = 0; i < parts.length; i++) {
355
const suffix = parts.slice(i).join(".");
356
if (!suffix) continue;
357
result.push(suffix, `.${suffix}`);
358
}
359
return result;
360
}
361
362