consent-state.ts331 lines · main
1// @ts-nocheck
2import type Usercentrics from '@usercentrics/cmp-browser-sdk'
3import type { BaseCategory, UserDecision } from '@usercentrics/cmp-browser-sdk'
4import { proxy, snapshot, useSnapshot } from 'valtio'
5
6import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants'
7
8export type PriorConsentDecision =
9 | null
10 | { kind: 'uniform-accept' }
11 | { kind: 'decisions'; decisions: UserDecision[] }
12
13type UcDataServiceEntry = [string, { consent: boolean }]
14
15const isValidUcDataServiceEntry = (entry: [string, unknown]): entry is UcDataServiceEntry => {
16 const value = entry[1]
17 return (
18 typeof value === 'object' &&
19 value !== null &&
20 typeof (value as { consent: unknown }).consent === 'boolean'
21 )
22}
23
24type UcSettingsService = { id: string; status: boolean }
25
26const isValidUcSettingsService = (service: unknown): service is UcSettingsService =>
27 typeof service === 'object' &&
28 service !== null &&
29 typeof (service as { id: unknown }).id === 'string' &&
30 typeof (service as { status: unknown }).status === 'boolean'
31
32/**
33 * Check whether the user previously made a consent decision by reading
34 * localStorage state that was written before UC.init() overwrites it.
35 *
36 * Returns enough information for the caller to restore the user's exact
37 * prior state via UC.updateServices, or to fast-path via UC.acceptAllServices
38 * when we can safely identify a uniform accept. Returns null when nothing
39 * trustworthy can be detected — in that case the caller should show the
40 * banner rather than fabricate a decision.
41 *
42 * Handles two scenarios (FE-2648 and GROWTH-790):
43 *
44 * 1. Slow navigation: GTM's Usercentrics integration replaced uc_settings with
45 * compressed ucString/ucData after the user's decision. On the next page
46 * load, UC.init() can't read that format and treats the user as new. We
47 * read ucData.consent.services directly and return every per-service
48 * decision so the caller can restore the user's exact state — including
49 * mixed states (essentials + functional accepted, tracking denied) that
50 * users produce via the Privacy Settings modal or the Opt out button.
51 *
52 * 2. Fast navigation: User decided on app A and navigated to app B before GTM
53 * finished writing ucData (or before GTM loaded at all, which happens on
54 * deny since TelemetryTagManager is gated behind hasAccepted). App B's
55 * UC.init() overwrites uc_settings with a fresh controllerId and resets
56 * uc_user_interaction to false. We check uc_user_interaction: "true" as
57 * a gate confirming the user actually interacted, then parse uc_settings
58 * to extract per-service decisions. uc_user_interaction alone is not
59 * enough — without uc_settings we cannot tell accept from deny and must
60 * not fabricate a direction.
61 *
62 * Both scenarios fail closed: any schema mismatch or partial corruption
63 * returns null rather than proceeding with a subset of valid entries.
64 * Over-consenting from malformed storage is the worst-direction bias in
65 * this domain.
66 *
67 * Must be called BEFORE UC.init() since init overwrites these keys.
68 */
69export function detectPriorConsent(): PriorConsentDecision {
70 try {
71 const ucData = localStorage?.getItem('ucData')
72 if (ucData) {
73 const data = JSON.parse(ucData)
74 const services = data?.consent?.services
75 if (services && typeof services === 'object') {
76 const rawEntries = Object.entries(services) as Array<[string, unknown]>
77 if (rawEntries.length > 0) {
78 if (!rawEntries.every(isValidUcDataServiceEntry)) {
79 // Partial corruption: don't cherry-pick the valid subset. Fall
80 // through to scenario 2 — uc_settings may still be intact.
81 } else {
82 const entries = rawEntries as UcDataServiceEntry[]
83 if (entries.every(([, s]) => s.consent === true)) {
84 return { kind: 'uniform-accept' }
85 }
86 return {
87 kind: 'decisions',
88 decisions: entries.map(([serviceId, s]) => ({
89 serviceId,
90 status: s.consent,
91 })),
92 }
93 }
94 }
95 }
96 }
97
98 // uc_user_interaction gates trust in uc_settings — the SDK sets it on any
99 // user interaction, which confirms uc_settings holds real decisions rather
100 // than ruleset defaults.
101 if (localStorage?.getItem('uc_user_interaction') === 'true') {
102 const ucSettings = localStorage?.getItem('uc_settings')
103 if (ucSettings) {
104 const parsed = JSON.parse(ucSettings)
105 const services = parsed?.services
106 if (
107 Array.isArray(services) &&
108 services.length > 0 &&
109 services.every(isValidUcSettingsService)
110 ) {
111 const decisions: UserDecision[] = services.map((s) => ({
112 serviceId: s.id,
113 status: s.status,
114 }))
115 if (decisions.every((d) => d.status === true)) {
116 return { kind: 'uniform-accept' }
117 }
118 return { kind: 'decisions', decisions }
119 }
120 }
121 // Flag says interacted but uc_settings is missing or malformed.
122 // Don't fabricate direction — show the banner on the next init.
123 }
124
125 return null
126 } catch {
127 return null
128 }
129}
130
131export const consentState = proxy({
132 UC: null as Usercentrics | null,
133 categories: null as BaseCategory[] | null,
134
135 showConsentToast: false,
136 hasConsented: false,
137 acceptAll: () => {
138 if (!consentState.UC) return
139 const previousConsentValue = consentState.hasConsented
140
141 consentState.hasConsented = true
142 consentState.showConsentToast = false
143
144 consentState.UC.acceptAllServices()
145 .then(() => {
146 consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
147 })
148 .catch(() => {
149 consentState.hasConsented = previousConsentValue
150 consentState.showConsentToast = true
151 })
152 },
153 denyAll: () => {
154 if (!consentState.UC) return
155 const previousConsentValue = consentState.hasConsented
156
157 consentState.hasConsented = false
158 consentState.showConsentToast = false
159
160 consentState.UC.denyAllServices()
161 .then(() => {
162 consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
163 })
164 .catch(() => {
165 consentState.showConsentToast = previousConsentValue
166 })
167 },
168 updateServices: (decisions: UserDecision[]) => {
169 if (!consentState.UC) return
170
171 consentState.showConsentToast = false
172
173 consentState.UC.updateServices(decisions)
174 .then(() => {
175 consentState.hasConsented = consentState.UC?.areAllConsentsAccepted() ?? false
176 consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
177 })
178 .catch(() => {
179 consentState.showConsentToast = true
180 })
181 },
182})
183
184/**
185 * Apply a prior consent decision (or lack of one) to the freshly-initialized
186 * Usercentrics SDK and the module's consentState proxy. Extracted from
187 * initUserCentrics to make the orchestration unit-testable without mocking
188 * the dynamic SDK import. Must be called after UC.init(). Mutates
189 * consentState synchronously and may call UC methods asynchronously.
190 */
191export function applyPriorDecisionToSDK(
192 UC: Usercentrics,
193 initialUIValues: { initialLayer: number },
194 priorDecision: PriorConsentDecision
195): void {
196 consentState.UC = UC
197 const hasConsented = UC.areAllConsentsAccepted()
198
199 // If the SDK wants to show the banner but the user previously made a
200 // decision (detected via ucData or uc_settings before init overwrote
201 // them), silently re-apply that decision instead of re-prompting
202 // (FE-2648, GROWTH-790).
203 if (initialUIValues.initialLayer === 0 && !hasConsented && priorDecision) {
204 consentState.categories = UC.getCategoriesBaseInfo()
205 consentState.showConsentToast = false
206 localStorage?.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
207
208 if (priorDecision.kind === 'uniform-accept') {
209 // Uniform accept covers any currently-active service by definition,
210 // including any added to the ruleset since the user's decision was
211 // stored — acceptAllServices applies to all current services.
212 consentState.hasConsented = true
213 UC.acceptAllServices()
214 .then(() => {
215 consentState.categories = UC.getCategoriesBaseInfo()
216 })
217 .catch(() => {
218 consentState.hasConsented = false
219 consentState.showConsentToast = true
220 })
221 return
222 }
223
224 // priorDecision.kind === 'decisions'. Only suppress the banner if the
225 // stored decisions cover every non-essential service the SDK currently
226 // knows about. If the ruleset has grown since the user's ucData/
227 // uc_settings was written, force a re-prompt rather than silently
228 // defaulting the new service. Essentials are skipped because the SDK
229 // forces them on regardless of user decision.
230 const currentNonEssentialIds = UC.getServicesBaseInfo()
231 .filter((s) => !s.isEssential)
232 .map((s) => s.id)
233 const coveredIds = new Set(priorDecision.decisions.map((d) => d.serviceId))
234 const allCovered = currentNonEssentialIds.every((id) => coveredIds.has(id))
235
236 if (!allCovered) {
237 // Fall through to the banner path below. Reset the early writes
238 // so the default-branch state assignments take effect correctly.
239 consentState.showConsentToast = initialUIValues.initialLayer === 0
240 consentState.hasConsented = hasConsented
241 return
242 }
243
244 // Restore the user's exact per-service state — handles deny and any
245 // partial/category-level decision made via Privacy Settings. hasConsented
246 // is computed from SDK state after the restore resolves (will be false
247 // unless every service was accepted).
248 UC.updateServices(priorDecision.decisions)
249 .then(() => {
250 consentState.hasConsented = UC.areAllConsentsAccepted()
251 consentState.categories = UC.getCategoriesBaseInfo()
252 })
253 .catch(() => {
254 // Falling back to the banner is safer than silently flipping to a
255 // uniform state the user didn't choose.
256 consentState.showConsentToast = true
257 })
258 return
259 }
260
261 // 0 = first layer, aka show consent toast
262 consentState.showConsentToast = initialUIValues.initialLayer === 0
263 consentState.hasConsented = hasConsented
264 consentState.categories = UC.getCategoriesBaseInfo()
265
266 // If the user has previously consented (before usercentrics), accept all services
267 if (!hasConsented && localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) === 'true') {
268 consentState.acceptAll()
269 localStorage.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
270 }
271}
272
273async function initUserCentrics() {
274 if (process.env.NODE_ENV === 'test' || !IS_PLATFORM) return
275
276 // [Alaister] For local development and staging, we accept all consent by default.
277 // If you need to test usercentrics in these environments, comment out this
278 // NEXT_PUBLIC_ENVIRONMENT check and add an ngrok domain to usercentrics
279 if (
280 process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
281 process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
282 ) {
283 consentState.hasConsented = true
284 return
285 }
286
287 // Check for prior consent BEFORE UC.init(), which can't read the compressed
288 // ucData format written by the GTM/Usercentrics integration (FE-2648).
289 const priorDecision = detectPriorConsent()
290
291 try {
292 const { default: Usercentrics } = await import('@usercentrics/cmp-browser-sdk')
293
294 const UC = new Usercentrics(process.env.NEXT_PUBLIC_USERCENTRICS_RULESET_ID!, {
295 rulesetId: process.env.NEXT_PUBLIC_USERCENTRICS_RULESET_ID,
296 useRulesetId: true,
297 })
298
299 const initialUIValues = await UC.init()
300 applyPriorDecisionToSDK(UC, initialUIValues, priorDecision)
301 } catch (error) {
302 console.error('Failed to initialize Usercentrics:', error)
303 // If SDK fails but user previously accepted uniformly, honor that.
304 // For explicit per-service decisions we can't restore without the SDK,
305 // and showing the banner when the SDK is broken would fail anyway.
306 if (priorDecision?.kind === 'uniform-accept') {
307 consentState.hasConsented = true
308 }
309 }
310}
311
312// Usercentrics is not available on the server
313if (typeof window !== 'undefined') {
314 initUserCentrics()
315}
316
317export function hasConsented() {
318 return snapshot(consentState).hasConsented
319}
320
321export function useConsentState() {
322 const snap = useSnapshot(consentState)
323
324 return {
325 hasAccepted: snap.hasConsented,
326 categories: snap.categories as BaseCategory[] | null,
327 acceptAll: snap.acceptAll,
328 denyAll: snap.denyAll,
329 updateServices: snap.updateServices,
330 }
331}