consent-state.ts 12 KB

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