consent-state.ts331 lines · main
| 1 | // @ts-nocheck |
| 2 | import type Usercentrics from '@usercentrics/cmp-browser-sdk' |
| 3 | import type { BaseCategory, UserDecision } from '@usercentrics/cmp-browser-sdk' |
| 4 | import { proxy, snapshot, useSnapshot } from 'valtio' |
| 5 | |
| 6 | import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants' |
| 7 | |
| 8 | export type PriorConsentDecision = |
| 9 | | null |
| 10 | | { kind: 'uniform-accept' } |
| 11 | | { kind: 'decisions'; decisions: UserDecision[] } |
| 12 | |
| 13 | type UcDataServiceEntry = [string, { consent: boolean }] |
| 14 | |
| 15 | const 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 | |
| 24 | type UcSettingsService = { id: string; status: boolean } |
| 25 | |
| 26 | const 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 | */ |
| 69 | export 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 | |
| 131 | export 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 | */ |
| 191 | export 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 | |
| 273 | async 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 |
| 313 | if (typeof window !== 'undefined') { |
| 314 | initUserCentrics() |
| 315 | } |
| 316 | |
| 317 | export function hasConsented() { |
| 318 | return snapshot(consentState).hasConsented |
| 319 | } |
| 320 | |
| 321 | export 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 | } |