mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
This PR is partly driven by changes in https://github.com/supabase/supabase/pull/48102. Claude identified code smells of Valtio state which are not bugs at the moment, but we should address in case their usage changes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved AI assistant message updates to prevent unexpected state changes. * Fixed table editing behavior to preserve shared data and prevent accidental in-place mutations. * Improved consent handling by preventing SDK internals from being altered by state management. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import type Usercentrics from '@usercentrics/cmp-browser-sdk'
|
|
import type { BaseCategory, UserDecision } from '@usercentrics/cmp-browser-sdk'
|
|
import { proxy, ref, 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 {
|
|
// ref() prevents valtio from recursively proxying the SDK instance's internals in place,
|
|
// which would corrupt them the same way it once corrupted the AI Assistant's message array
|
|
consentState.UC = ref(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,
|
|
}
|
|
}
|