mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
## Problem
Cookie banner keeps re-prompting GDPR users who denied consent or made a
partial opt-out via Privacy Settings — they can't get rid of it.
Reported by Christian Gedde-Dahl (Front SU-362240, mygame.no) and a
Supabase support engineer independently.
The March fix for FE-2648 handled the accept case — users got their
banner dismissal stomped when GTM's Usercentrics integration migrated
localStorage from `uc_settings` to `ucData`/`ucString`. But that fix
only recognized uniformly-accepted `ucData`, so any other shape
(deny-all, essentials-plus-some-tracking, partial opt-out via the
Privacy Settings modal) fell through and was treated as "no prior
decision." Banner re-prompts on every page load.
Christian's and his colleague's `ucData.consent.services` showed 13
services accepted (essentials + functional) and 4 tracking services
denied — the shape you get from toggling off the Marketing category in
Privacy Settings. Our detection ignored it.
## Changes
- `detectPriorConsent` now returns a discriminated union — `null`, `{
kind: 'uniform-accept' }`, or `{ kind: 'decisions'; decisions }` — so we
restore per-service state faithfully instead of flattening to deny-all.
- Parse `uc_settings` for the fast cross-app nav case. The old fallback
treated `uc_user_interaction === "true"` as uniform-accept, which
silently upgraded deny users (GDPR violation waiting to happen). Now the
flag is just a gate confirming the user actually interacted, and we read
real decisions from `uc_settings.services[]`.
- Extracted the post-init orchestration from `initUserCentrics` into
exported `applyPriorDecisionToSDK(UC, initialUIValues, priorDecision)`
so it's unit-testable without mocking the dynamic SDK import.
- Added a coverage cross-check: if the Usercentrics ruleset has a
non-essential service that isn't in the user's stored decisions, force a
re-prompt rather than silently defaulting the new service. Essentials
are skipped because the SDK forces them on regardless.
- Everything fails closed on partial `ucData` / `uc_settings`
corruption. Cherry-picking the valid subset would bias toward
over-consent, which is the worst-direction bias in this domain.
## Testing
27 unit tests covering `detectPriorConsent` parsing (both `ucData` and
`uc_settings` paths, fail-closed on malformed or partially-corrupt
blobs, combined scenarios) and `applyPriorDecisionToSDK` orchestration
(uniform accept, decisions with full coverage, uncovered-non-essential
compliance guard, essentials-only negative control, null fallthrough,
SDK-already-consented passthrough).
Can't fully repro on staging — CSP blocks GTM on preview, so the
`ucData` migration never fires. Same limitation as the original FE-2648
fix. Will verify in production post-merge by asking Christian to reload
and watching support ticket volume.
Reviewed twice by Codex with a full iteration between passes; final pass
found no functional blockers.
GROWTH-790
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Fail-closed validation for stored consent data: malformed, partial, or
missing entries now yield null and avoid unsafe restoration.
* Improved precedence and fallback so corrupt prior data won’t
incorrectly restore consent.
* **Refactor**
* Consent detection now returns richer prior-decision results (uniform
accept, per-service decisions, or null).
* Applying prior decisions to the SDK uses stricter coverage checks
before restoring per-service consent.
* **Tests**
* Expanded tests covering varied stored-consent shapes, gating rules,
precedence, recovery, and SDK application behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import type Usercentrics from '@usercentrics/cmp-browser-sdk'
|
|
import type { BaseCategory, UserDecision } from '@usercentrics/cmp-browser-sdk'
|
|
import { proxy, snapshot, useSnapshot } from 'valtio'
|
|
|
|
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants'
|
|
|
|
export type PriorConsentDecision =
|
|
| null
|
|
| { kind: 'uniform-accept' }
|
|
| { kind: 'decisions'; decisions: UserDecision[] }
|
|
|
|
type UcDataServiceEntry = [string, { consent: boolean }]
|
|
|
|
const isValidUcDataServiceEntry = (entry: [string, unknown]): entry is UcDataServiceEntry => {
|
|
const value = entry[1]
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
typeof (value as { consent: unknown }).consent === 'boolean'
|
|
)
|
|
}
|
|
|
|
type UcSettingsService = { id: string; status: boolean }
|
|
|
|
const isValidUcSettingsService = (service: unknown): service is UcSettingsService =>
|
|
typeof service === 'object' &&
|
|
service !== null &&
|
|
typeof (service as { id: unknown }).id === 'string' &&
|
|
typeof (service as { status: unknown }).status === 'boolean'
|
|
|
|
/**
|
|
* Check whether the user previously made a consent decision by reading
|
|
* localStorage state that was written before UC.init() overwrites it.
|
|
*
|
|
* Returns enough information for the caller to restore the user's exact
|
|
* prior state via UC.updateServices, or to fast-path via UC.acceptAllServices
|
|
* when we can safely identify a uniform accept. Returns null when nothing
|
|
* trustworthy can be detected — in that case the caller should show the
|
|
* banner rather than fabricate a decision.
|
|
*
|
|
* Handles two scenarios (FE-2648 and GROWTH-790):
|
|
*
|
|
* 1. Slow navigation: GTM's Usercentrics integration replaced uc_settings with
|
|
* compressed ucString/ucData after the user's decision. On the next page
|
|
* load, UC.init() can't read that format and treats the user as new. We
|
|
* read ucData.consent.services directly and return every per-service
|
|
* decision so the caller can restore the user's exact state — including
|
|
* mixed states (essentials + functional accepted, tracking denied) that
|
|
* users produce via the Privacy Settings modal or the Opt out button.
|
|
*
|
|
* 2. Fast navigation: User decided on app A and navigated to app B before GTM
|
|
* finished writing ucData (or before GTM loaded at all, which happens on
|
|
* deny since TelemetryTagManager is gated behind hasAccepted). App B's
|
|
* UC.init() overwrites uc_settings with a fresh controllerId and resets
|
|
* uc_user_interaction to false. We check uc_user_interaction: "true" as
|
|
* a gate confirming the user actually interacted, then parse uc_settings
|
|
* to extract per-service decisions. uc_user_interaction alone is not
|
|
* enough — without uc_settings we cannot tell accept from deny and must
|
|
* not fabricate a direction.
|
|
*
|
|
* Both scenarios fail closed: any schema mismatch or partial corruption
|
|
* returns null rather than proceeding with a subset of valid entries.
|
|
* Over-consenting from malformed storage is the worst-direction bias in
|
|
* this domain.
|
|
*
|
|
* Must be called BEFORE UC.init() since init overwrites these keys.
|
|
*/
|
|
export function detectPriorConsent(): PriorConsentDecision {
|
|
try {
|
|
const ucData = localStorage?.getItem('ucData')
|
|
if (ucData) {
|
|
const data = JSON.parse(ucData)
|
|
const services = data?.consent?.services
|
|
if (services && typeof services === 'object') {
|
|
const rawEntries = Object.entries(services) as Array<[string, unknown]>
|
|
if (rawEntries.length > 0) {
|
|
if (!rawEntries.every(isValidUcDataServiceEntry)) {
|
|
// Partial corruption: don't cherry-pick the valid subset. Fall
|
|
// through to scenario 2 — uc_settings may still be intact.
|
|
} else {
|
|
const entries = rawEntries as UcDataServiceEntry[]
|
|
if (entries.every(([, s]) => s.consent === true)) {
|
|
return { kind: 'uniform-accept' }
|
|
}
|
|
return {
|
|
kind: 'decisions',
|
|
decisions: entries.map(([serviceId, s]) => ({
|
|
serviceId,
|
|
status: s.consent,
|
|
})),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// uc_user_interaction gates trust in uc_settings — the SDK sets it on any
|
|
// user interaction, which confirms uc_settings holds real decisions rather
|
|
// than ruleset defaults.
|
|
if (localStorage?.getItem('uc_user_interaction') === 'true') {
|
|
const ucSettings = localStorage?.getItem('uc_settings')
|
|
if (ucSettings) {
|
|
const parsed = JSON.parse(ucSettings)
|
|
const services = parsed?.services
|
|
if (
|
|
Array.isArray(services) &&
|
|
services.length > 0 &&
|
|
services.every(isValidUcSettingsService)
|
|
) {
|
|
const decisions: UserDecision[] = services.map((s) => ({
|
|
serviceId: s.id,
|
|
status: s.status,
|
|
}))
|
|
if (decisions.every((d) => d.status === true)) {
|
|
return { kind: 'uniform-accept' }
|
|
}
|
|
return { kind: 'decisions', decisions }
|
|
}
|
|
}
|
|
// Flag says interacted but uc_settings is missing or malformed.
|
|
// Don't fabricate direction — show the banner on the next init.
|
|
}
|
|
|
|
return null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export const consentState = proxy({
|
|
UC: null as Usercentrics | null,
|
|
categories: null as BaseCategory[] | null,
|
|
|
|
showConsentToast: false,
|
|
hasConsented: false,
|
|
acceptAll: () => {
|
|
if (!consentState.UC) return
|
|
const previousConsentValue = consentState.hasConsented
|
|
|
|
consentState.hasConsented = true
|
|
consentState.showConsentToast = false
|
|
|
|
consentState.UC.acceptAllServices()
|
|
.then(() => {
|
|
consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
|
|
})
|
|
.catch(() => {
|
|
consentState.hasConsented = previousConsentValue
|
|
consentState.showConsentToast = true
|
|
})
|
|
},
|
|
denyAll: () => {
|
|
if (!consentState.UC) return
|
|
const previousConsentValue = consentState.hasConsented
|
|
|
|
consentState.hasConsented = false
|
|
consentState.showConsentToast = false
|
|
|
|
consentState.UC.denyAllServices()
|
|
.then(() => {
|
|
consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
|
|
})
|
|
.catch(() => {
|
|
consentState.showConsentToast = previousConsentValue
|
|
})
|
|
},
|
|
updateServices: (decisions: UserDecision[]) => {
|
|
if (!consentState.UC) return
|
|
|
|
consentState.showConsentToast = false
|
|
|
|
consentState.UC.updateServices(decisions)
|
|
.then(() => {
|
|
consentState.hasConsented = consentState.UC?.areAllConsentsAccepted() ?? false
|
|
consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
|
|
})
|
|
.catch(() => {
|
|
consentState.showConsentToast = true
|
|
})
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Apply a prior consent decision (or lack of one) to the freshly-initialized
|
|
* Usercentrics SDK and the module's consentState proxy. Extracted from
|
|
* initUserCentrics to make the orchestration unit-testable without mocking
|
|
* the dynamic SDK import. Must be called after UC.init(). Mutates
|
|
* consentState synchronously and may call UC methods asynchronously.
|
|
*/
|
|
export function applyPriorDecisionToSDK(
|
|
UC: Usercentrics,
|
|
initialUIValues: { initialLayer: number },
|
|
priorDecision: PriorConsentDecision
|
|
): void {
|
|
consentState.UC = UC
|
|
const hasConsented = UC.areAllConsentsAccepted()
|
|
|
|
// If the SDK wants to show the banner but the user previously made a
|
|
// decision (detected via ucData or uc_settings before init overwrote
|
|
// them), silently re-apply that decision instead of re-prompting
|
|
// (FE-2648, GROWTH-790).
|
|
if (initialUIValues.initialLayer === 0 && !hasConsented && priorDecision) {
|
|
consentState.categories = UC.getCategoriesBaseInfo()
|
|
consentState.showConsentToast = false
|
|
localStorage?.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
|
|
|
|
if (priorDecision.kind === 'uniform-accept') {
|
|
// Uniform accept covers any currently-active service by definition,
|
|
// including any added to the ruleset since the user's decision was
|
|
// stored — acceptAllServices applies to all current services.
|
|
consentState.hasConsented = true
|
|
UC.acceptAllServices()
|
|
.then(() => {
|
|
consentState.categories = UC.getCategoriesBaseInfo()
|
|
})
|
|
.catch(() => {
|
|
consentState.hasConsented = false
|
|
consentState.showConsentToast = true
|
|
})
|
|
return
|
|
}
|
|
|
|
// priorDecision.kind === 'decisions'. Only suppress the banner if the
|
|
// stored decisions cover every non-essential service the SDK currently
|
|
// knows about. If the ruleset has grown since the user's ucData/
|
|
// uc_settings was written, force a re-prompt rather than silently
|
|
// defaulting the new service. Essentials are skipped because the SDK
|
|
// forces them on regardless of user decision.
|
|
const currentNonEssentialIds = UC.getServicesBaseInfo()
|
|
.filter((s) => !s.isEssential)
|
|
.map((s) => s.id)
|
|
const coveredIds = new Set(priorDecision.decisions.map((d) => d.serviceId))
|
|
const allCovered = currentNonEssentialIds.every((id) => coveredIds.has(id))
|
|
|
|
if (!allCovered) {
|
|
// Fall through to the banner path below. Reset the early writes
|
|
// so the default-branch state assignments take effect correctly.
|
|
consentState.showConsentToast = initialUIValues.initialLayer === 0
|
|
consentState.hasConsented = hasConsented
|
|
return
|
|
}
|
|
|
|
// Restore the user's exact per-service state — handles deny and any
|
|
// partial/category-level decision made via Privacy Settings. hasConsented
|
|
// is computed from SDK state after the restore resolves (will be false
|
|
// unless every service was accepted).
|
|
UC.updateServices(priorDecision.decisions)
|
|
.then(() => {
|
|
consentState.hasConsented = UC.areAllConsentsAccepted()
|
|
consentState.categories = UC.getCategoriesBaseInfo()
|
|
})
|
|
.catch(() => {
|
|
// Falling back to the banner is safer than silently flipping to a
|
|
// uniform state the user didn't choose.
|
|
consentState.showConsentToast = true
|
|
})
|
|
return
|
|
}
|
|
|
|
// 0 = first layer, aka show consent toast
|
|
consentState.showConsentToast = initialUIValues.initialLayer === 0
|
|
consentState.hasConsented = hasConsented
|
|
consentState.categories = UC.getCategoriesBaseInfo()
|
|
|
|
// If the user has previously consented (before usercentrics), accept all services
|
|
if (!hasConsented && localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) === 'true') {
|
|
consentState.acceptAll()
|
|
localStorage.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
|
|
}
|
|
}
|
|
|
|
async function initUserCentrics() {
|
|
if (process.env.NODE_ENV === 'test' || !IS_PLATFORM) return
|
|
|
|
// [Alaister] For local development and staging, we accept all consent by default.
|
|
// If you need to test usercentrics in these environments, comment out this
|
|
// NEXT_PUBLIC_ENVIRONMENT check and add an ngrok domain to usercentrics
|
|
if (
|
|
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
|
|
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
|
|
) {
|
|
consentState.hasConsented = true
|
|
return
|
|
}
|
|
|
|
// Check for prior consent BEFORE UC.init(), which can't read the compressed
|
|
// ucData format written by the GTM/Usercentrics integration (FE-2648).
|
|
const priorDecision = detectPriorConsent()
|
|
|
|
try {
|
|
const { default: Usercentrics } = await import('@usercentrics/cmp-browser-sdk')
|
|
|
|
const UC = new Usercentrics(process.env.NEXT_PUBLIC_USERCENTRICS_RULESET_ID!, {
|
|
rulesetId: process.env.NEXT_PUBLIC_USERCENTRICS_RULESET_ID,
|
|
useRulesetId: true,
|
|
})
|
|
|
|
const initialUIValues = await UC.init()
|
|
applyPriorDecisionToSDK(UC, initialUIValues, priorDecision)
|
|
} catch (error) {
|
|
console.error('Failed to initialize Usercentrics:', error)
|
|
// If SDK fails but user previously accepted uniformly, honor that.
|
|
// For explicit per-service decisions we can't restore without the SDK,
|
|
// and showing the banner when the SDK is broken would fail anyway.
|
|
if (priorDecision?.kind === 'uniform-accept') {
|
|
consentState.hasConsented = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Usercentrics is not available on the server
|
|
if (typeof window !== 'undefined') {
|
|
initUserCentrics()
|
|
}
|
|
|
|
export function hasConsented() {
|
|
return snapshot(consentState).hasConsented
|
|
}
|
|
|
|
export function useConsentState() {
|
|
const snap = useSnapshot(consentState)
|
|
|
|
return {
|
|
hasAccepted: snap.hasConsented,
|
|
categories: snap.categories as BaseCategory[] | null,
|
|
acceptAll: snap.acceptAll,
|
|
denyAll: snap.denyAll,
|
|
updateServices: snap.updateServices,
|
|
}
|
|
}
|