From 0094e46833569b4d249de00c41afd830385b5c0a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 15:10:41 +1000 Subject: [PATCH 1/6] feat: Usercentrics CMP Adds the Usercentrics consent management platform to the registry as useScriptUsercentrics with typed UC_UI access plus a consent helper exposing onConsentChange(), letting users wire other registry scripts' consent triggers directly to UC_CONSENT events. Bundle and proxy are intentionally off and the script is exempt from consent gating since it is the consent surface itself. --- docs/content/scripts/usercentrics.md | 138 ++++++++++++++++++ packages/script/src/registry-logos.ts | 1 + packages/script/src/registry-types.json | 65 +++++++++ packages/script/src/registry.ts | 17 +++ .../script/src/runtime/registry/schemas.ts | 27 ++++ .../src/runtime/registry/usercentrics.ts | 128 ++++++++++++++++ packages/script/src/runtime/types.ts | 4 +- packages/script/src/script-meta.ts | 6 + playground/pages/index.vue | 5 + .../third-parties/usercentrics/default.vue | 25 ++++ .../usercentrics/nuxt-scripts.vue | 61 ++++++++ test/e2e/_usercentrics-suite.ts | 58 ++++++++ test/e2e/usercentrics.test.ts | 14 ++ test/fixtures/usercentrics/app.vue | 3 + test/fixtures/usercentrics/nuxt.config.ts | 19 +++ test/fixtures/usercentrics/package.json | 3 + test/fixtures/usercentrics/pages/index.vue | 29 ++++ test/fixtures/usercentrics/tsconfig.json | 3 + test/types/types.test-d.ts | 1 + 19 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 docs/content/scripts/usercentrics.md create mode 100644 packages/script/src/runtime/registry/usercentrics.ts create mode 100644 playground/pages/third-parties/usercentrics/default.vue create mode 100644 playground/pages/third-parties/usercentrics/nuxt-scripts.vue create mode 100644 test/e2e/_usercentrics-suite.ts create mode 100644 test/e2e/usercentrics.test.ts create mode 100644 test/fixtures/usercentrics/app.vue create mode 100644 test/fixtures/usercentrics/nuxt.config.ts create mode 100644 test/fixtures/usercentrics/package.json create mode 100644 test/fixtures/usercentrics/pages/index.vue create mode 100644 test/fixtures/usercentrics/tsconfig.json diff --git a/docs/content/scripts/usercentrics.md b/docs/content/scripts/usercentrics.md new file mode 100644 index 00000000..7c425c93 --- /dev/null +++ b/docs/content/scripts/usercentrics.md @@ -0,0 +1,138 @@ +--- +title: Usercentrics +description: Load the Usercentrics CMP and drive useScript consent triggers from UC_CONSENT events. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/usercentrics.ts + size: xs +--- + +[Usercentrics](https://usercentrics.com) is a Consent Management Platform (CMP) used to collect, store, and signal end user consent for third-party scripts under GDPR, CCPA, and the IAB TCF v2 framework. + +Nuxt Scripts ships [`useScriptUsercentrics()`{lang="ts"}](/scripts/usercentrics) so you can boot the CMP loader, expose typed access to the `UC_UI` global, and wire other registry scripts' consent triggers directly to Usercentrics' `UC_CONSENT` event. + +::script-stats +:: + +::script-docs +:: + +## Setup + +Provide your Usercentrics `settingsId` in `nuxt.config.ts`: + +```ts +export default defineNuxtConfig({ + scripts: { + registry: { + usercentrics: { + settingsId: 'YOUR_SETTINGS_ID', + }, + }, + }, +}) +``` + +The composable is exempt from consent gating; it is the consent surface itself, so it must hit the Usercentrics origin directly. Bundling and proxying are intentionally disabled. + +## Drive consent triggers from Usercentrics + +This is the killer integration. Pair `useScriptUsercentrics({ ... }).consent.onConsentChange(...)`{lang="ts"} with [`useScriptTriggerConsent`](/docs/api/use-script-trigger-consent) to load any third-party script the moment the user opts in via the Usercentrics banner. + +```vue + + + +``` + +`onConsentChange` returns a teardown function so you can unsubscribe inside `onScopeDispose`. The callback receives the raw event detail emitted on `window`. + +## Open the consent UI + +`UC_UI` is not on `window` until `UC_UI_INITIALIZED` fires. Use `consent.whenReady()`{lang="ts"} to await it, or call the helpers on `consent` directly (they no-op while the CMP boots): + +```vue + + + +``` + +## TCF mode + +For IAB TCF v2 deployments, set `embeddingType: 'tcf'` and (optionally) `tcfEnabled: true`. The composable forwards both as data attributes on the loader script tag. + +```ts +useScriptUsercentrics({ + settingsId: 'YOUR_SETTINGS_ID', + embeddingType: 'tcf', + tcfEnabled: true, +}) +``` + +## Loader version + +The loader URL has a version segment (`/browser-ui//loader.js`). It defaults to `'latest'`; pin it for reproducible builds: + +```ts +useScriptUsercentrics({ + settingsId: 'YOUR_SETTINGS_ID', + version: '3.6.0', +}) +``` + +## Partytown + +Usercentrics is not supported under Partytown. The `UC_UI` API is method-heavy and not safe to forward across the worker boundary; the CMP also needs main-thread DOM access to render its UI overlays. + +::script-types +:: diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 233c3a68..4bd5ceb2 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -59,6 +59,7 @@ export const LOGOS = { googleAnalytics: ``, umamiAnalytics: ``, gravatar: ``, + usercentrics: ``, bingUet: ``, snapchatPixel: ``, } satisfies Record diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 493e5a33..90ac88f0 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -971,6 +971,38 @@ "code": "export interface UmamiAnalyticsApi {\n track: ((payload?: Record) => void) & ((event_name: string, event_data: Record) => void)\n identify: (session_data?: Record | string) => void\n}" } ], + "usercentrics": [ + { + "name": "UsercentricsOptions", + "kind": "const", + "code": "export const UsercentricsOptions = object({\n /**\n * Your Usercentrics settings ID.\n * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/getting_started/web/\n */\n settingsId: pipe(string(), minLength(1)),\n /**\n * Loader version segment used in the script src.\n * @default 'latest'\n */\n version: optional(string()),\n /**\n * Enable IAB TCF v2 mode. When set, Usercentrics serves the TCF-aware loader.\n * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/iab_tcf/web/\n */\n tcfEnabled: optional(boolean()),\n /**\n * Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`).\n * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/configuration/language/\n */\n language: optional(string()),\n /**\n * Loader variant. `'gdpr'` is the default; `'tcf'` selects the TCF v2 loader.\n */\n embeddingType: optional(union([literal('tcf'), literal('gdpr')])),\n})" + }, + { + "name": "UsercentricsService", + "kind": "interface", + "code": "export interface UsercentricsService {\n id: string\n name: string\n consent: { status: boolean }\n [key: string]: any\n}" + }, + { + "name": "UsercentricsCmpEventDetail", + "kind": "interface", + "code": "export interface UsercentricsCmpEventDetail {\n type: string\n source?: string\n [key: string]: any\n}" + }, + { + "name": "UsercentricsUI", + "kind": "interface", + "code": "export interface UsercentricsUI {\n isInitialized: () => boolean\n showFirstLayer: () => void\n showSecondLayer: () => void\n acceptAllConsents: () => Promise\n denyAllConsents: () => Promise\n getServicesBaseInfo: () => UsercentricsService[]\n getCMPData: () => Record\n [key: string]: any\n}" + }, + { + "name": "UsercentricsApi", + "kind": "interface", + "code": "export interface UsercentricsApi {\n UC_UI: UsercentricsUI\n}" + }, + { + "name": "UsercentricsConsent", + "kind": "interface", + "code": "export interface UsercentricsConsent {\n /**\n * Resolves once the CMP has fired `UC_UI_INITIALIZED` (or immediately if it\n * already initialised). Resolves with the `UC_UI` global so callers can\n * query consent state without polling.\n */\n whenReady: () => Promise\n /**\n * Subscribe to `UC_CONSENT` browser events. Returns a teardown function.\n * The callback receives the raw event detail emitted by Usercentrics.\n */\n onConsentChange: (cb: (detail: any, event: Event) => void) => () => void\n /** Open the privacy settings (first layer banner). */\n showFirstLayer: () => void\n /** Open the detailed settings (second layer modal). */\n showSecondLayer: () => void\n /** Accept all consents. */\n acceptAll: () => Promise | void\n /** Reject all consents. */\n denyAll: () => Promise | void\n}" + } + ], "vercel-analytics": [ { "name": "VercelAnalyticsOptions", @@ -2060,6 +2092,39 @@ "defaultValue": "false" } ], + "UsercentricsOptions": [ + { + "name": "settingsId", + "type": "string", + "required": true, + "description": "Your Usercentrics settings ID." + }, + { + "name": "version", + "type": "string", + "required": false, + "description": "Loader version segment used in the script src.", + "defaultValue": "'latest'" + }, + { + "name": "tcfEnabled", + "type": "boolean", + "required": false, + "description": "Enable IAB TCF v2 mode. When set, Usercentrics serves the TCF-aware loader." + }, + { + "name": "language", + "type": "string", + "required": false, + "description": "Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`)." + }, + { + "name": "embeddingType", + "type": "'tcf' | 'gdpr'", + "required": false, + "description": "Loader variant. `'gdpr'` is the default; `'tcf'` selects the TCF v2 loader." + } + ], "SegmentOptions": [ { "name": "writeKey", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 9117c430..c9bd0bb2 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -43,6 +43,7 @@ import { StripeOptions, TikTokPixelOptions, UmamiAnalyticsOptions, + UsercentricsOptions, VercelAnalyticsOptions, XEmbedOptions, XPixelOptions, @@ -165,6 +166,9 @@ export const registryMeta: RegistryScriptMeta[] = [ m('googleRecaptcha', 'Google reCAPTCHA', 'utility', 'useScriptGoogleRecaptcha', {}, null), m('googleSignIn', 'Google Sign-In', 'utility', 'useScriptGoogleSignIn', {}, null), m('gravatar', 'Gravatar', 'utility', 'useScriptGravatar', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), + // Usercentrics is the consent layer itself: must hit the vendor origin so + // signature/policy lookups succeed. Bundle/proxy are intentionally absent. + m('usercentrics', 'Usercentrics', 'utility', 'useScriptUsercentrics', {}, null), ] export const REGISTRY_CATEGORIES = [ @@ -784,6 +788,19 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, partytown: { forwards: ['umami', 'umami.track'] }, }), + def('usercentrics', { + composableName: 'useScriptUsercentrics', + schema: UsercentricsOptions, + label: 'Usercentrics', + // Source declared here for devtools/listing only; the runtime composable + // builds the actual src so it can include `version` and the required + // `data-settings-id` attribute. No bundle/proxy: the CMP must hit the + // vendor origin (it IS the consent surface, and is exempt from consent + // gating) so policies/signatures resolve correctly. + src: 'https://app.usercentrics.eu/browser-ui/latest/loader.js', + category: 'utility', + envDefaults: { settingsId: '' }, + }), def('gravatar', { schema: GravatarOptions, label: 'Gravatar', diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 1b1c250a..b221fb4c 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -862,6 +862,33 @@ export const LinkedInInsightOptions = object({ enableAutoSpaTracking: optional(boolean()), }) +export const UsercentricsOptions = object({ + /** + * Your Usercentrics settings ID. + * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/getting_started/web/ + */ + settingsId: pipe(string(), minLength(1)), + /** + * Loader version segment used in the script src. + * @default 'latest' + */ + version: optional(string()), + /** + * Enable IAB TCF v2 mode. When set, Usercentrics serves the TCF-aware loader. + * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/iab_tcf/web/ + */ + tcfEnabled: optional(boolean()), + /** + * Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`). + * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/configuration/language/ + */ + language: optional(string()), + /** + * Loader variant. `'gdpr'` is the default; `'tcf'` selects the TCF v2 loader. + */ + embeddingType: optional(union([literal('tcf'), literal('gdpr')])), +}) + export const SegmentOptions = object({ /** * Your Segment write key. diff --git a/packages/script/src/runtime/registry/usercentrics.ts b/packages/script/src/runtime/registry/usercentrics.ts new file mode 100644 index 00000000..dde0f189 --- /dev/null +++ b/packages/script/src/runtime/registry/usercentrics.ts @@ -0,0 +1,128 @@ +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { useRegistryScript } from '../utils' +import { UsercentricsOptions } from './schemas' + +export { UsercentricsOptions } + +export type UsercentricsInput = RegistryScriptInput + +export interface UsercentricsService { + id: string + name: string + consent: { status: boolean } + [key: string]: any +} + +export interface UsercentricsCmpEventDetail { + type: string + source?: string + [key: string]: any +} + +export interface UsercentricsUI { + isInitialized: () => boolean + showFirstLayer: () => void + showSecondLayer: () => void + acceptAllConsents: () => Promise + denyAllConsents: () => Promise + getServicesBaseInfo: () => UsercentricsService[] + getCMPData: () => Record + [key: string]: any +} + +export interface UsercentricsApi { + UC_UI: UsercentricsUI +} + +declare global { + interface Window { + UC_UI?: UsercentricsUI + } +} + +/** + * Vendor-native Usercentrics consent helpers exposed on the composable result. + * Use these to drive `useScript` consent triggers from CMP events. + */ +export interface UsercentricsConsent { + /** + * Resolves once the CMP has fired `UC_UI_INITIALIZED` (or immediately if it + * already initialised). Resolves with the `UC_UI` global so callers can + * query consent state without polling. + */ + whenReady: () => Promise + /** + * Subscribe to `UC_CONSENT` browser events. Returns a teardown function. + * The callback receives the raw event detail emitted by Usercentrics. + */ + onConsentChange: (cb: (detail: any, event: Event) => void) => () => void + /** Open the privacy settings (first layer banner). */ + showFirstLayer: () => void + /** Open the detailed settings (second layer modal). */ + showSecondLayer: () => void + /** Accept all consents. */ + acceptAll: () => Promise | void + /** Reject all consents. */ + denyAll: () => Promise | void +} + +/** + * Load the Usercentrics CMP loader and expose typed access to the `UC_UI` + * global plus a `consent` helper with `onConsentChange` for wiring consent + * triggers (`useScript({ trigger: ... })`) to Usercentrics events. + * + * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/getting_started/web/ + */ +export function useScriptUsercentrics( + _options?: UsercentricsInput, +): UseScriptContext { + const instance = useRegistryScript('usercentrics', (options) => { + const version = options.version || 'latest' + return { + scriptInput: { + // The CMP loader is identified by id + data-settings-id; both are + // required for the loader to bootstrap. + 'src': `https://app.usercentrics.eu/browser-ui/${version}/loader.js`, + 'id': 'usercentrics-cmp', + 'data-settings-id': options.settingsId || '', + 'data-tcf-enabled': options.tcfEnabled ? 'true' : undefined, + 'data-language': options.language, + 'data-embedding-type': options.embeddingType, + 'crossorigin': false, + }, + schema: import.meta.dev ? UsercentricsOptions : undefined, + scriptOptions: { + use() { + return { UC_UI: window.UC_UI } as unknown as T + }, + }, + } + }, _options) as UseScriptContext + + if (import.meta.client && !instance.consent) { + const whenReady = (): Promise => new Promise((resolve) => { + if (window.UC_UI?.isInitialized?.()) + return resolve(window.UC_UI) + const onInit = () => { + window.removeEventListener('UC_UI_INITIALIZED', onInit) + resolve(window.UC_UI as UsercentricsUI) + } + window.addEventListener('UC_UI_INITIALIZED', onInit) + }) + + instance.consent = { + whenReady, + onConsentChange(cb) { + const handler = (e: Event) => cb((e as CustomEvent).detail, e) + window.addEventListener('UC_CONSENT', handler) + return () => window.removeEventListener('UC_CONSENT', handler) + }, + showFirstLayer: () => window.UC_UI?.showFirstLayer?.(), + showSecondLayer: () => window.UC_UI?.showSecondLayer?.(), + acceptAll: () => window.UC_UI?.acceptAllConsents?.(), + denyAll: () => window.UC_UI?.denyAllConsents?.(), + } + } + + return instance +} diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 4f705b59..a12b0432 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -38,6 +38,7 @@ import type { SnapTrPixelInput } from './registry/snapchat-pixel' import type { StripeInput } from './registry/stripe' import type { TikTokPixelInput } from './registry/tiktok-pixel' import type { UmamiAnalyticsInput } from './registry/umami-analytics' +import type { UsercentricsInput } from './registry/usercentrics' import type { VercelAnalyticsInput } from './registry/vercel-analytics' import type { VimeoPlayerInput } from './registry/vimeo-player' import type { XEmbedInput } from './registry/x-embed' @@ -261,6 +262,7 @@ export interface ScriptRegistry { vercelAnalytics?: VercelAnalyticsInput vimeoPlayer?: VimeoPlayerInput umamiAnalytics?: UmamiAnalyticsInput + usercentrics?: UsercentricsInput gravatar?: GravatarInput npm?: NpmInput [key: `${string}-npm`]: NpmInput @@ -278,7 +280,7 @@ export type BuiltInRegistryScriptKey | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'youtubePlayer' | 'vercelAnalytics' - | 'vimeoPlayer' | 'umamiAnalytics' | 'gravatar' | 'npm' + | 'vimeoPlayer' | 'umamiAnalytics' | 'usercentrics' | 'gravatar' | 'npm' /** * Union of all explicit registry script keys (excludes npm pattern). diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index c2faa595..f5d7a152 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -203,6 +203,12 @@ export const scriptMeta = { trackedData: [], }, + // CMP / Consent + usercentrics: { + urls: ['https://app.usercentrics.eu/browser-ui/latest/loader.js'], + trackedData: [], + }, + // Identity gravatar: { urls: ['https://secure.gravatar.com/js/gprofiles.js'], diff --git a/playground/pages/index.vue b/playground/pages/index.vue index a0b83dc8..9776825d 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -45,6 +45,7 @@ function getPlaygroundPath(script: any): string | null { 'youtube-player': '/third-parties/youtube/nuxt-scripts', 'google-maps': '/third-parties/google-maps/nuxt-scripts', 'google-recaptcha': '/third-parties/google-recaptcha/nuxt-scripts', + 'usercentrics': '/third-parties/usercentrics/nuxt-scripts', 'npm': '/npm/js-confetti', } @@ -272,6 +273,10 @@ const benchmark = [ name: 'LinkedIn Insight (Default)', path: '/third-parties/linkedin-insight/default', }, + { + name: 'Usercentrics (Default)', + path: '/third-parties/usercentrics/default', + }, { name: 'Snapchat (Default)', path: '/third-parties/snapchat/default', diff --git a/playground/pages/third-parties/usercentrics/default.vue b/playground/pages/third-parties/usercentrics/default.vue new file mode 100644 index 00000000..55c24bbb --- /dev/null +++ b/playground/pages/third-parties/usercentrics/default.vue @@ -0,0 +1,25 @@ + + + diff --git a/playground/pages/third-parties/usercentrics/nuxt-scripts.vue b/playground/pages/third-parties/usercentrics/nuxt-scripts.vue new file mode 100644 index 00000000..c4f8fa86 --- /dev/null +++ b/playground/pages/third-parties/usercentrics/nuxt-scripts.vue @@ -0,0 +1,61 @@ + + + diff --git a/test/e2e/_usercentrics-suite.ts b/test/e2e/_usercentrics-suite.ts new file mode 100644 index 00000000..204bbf13 --- /dev/null +++ b/test/e2e/_usercentrics-suite.ts @@ -0,0 +1,58 @@ +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { expect, it } from 'vitest' + +// Usercentrics requires a valid `settingsId` tied to a real account to fully +// boot. CI uses a placeholder ID, so behavioural assertions (UC_UI globals, +// UC_CONSENT events) skip; only DOM-wiring assertions run unconditionally. +const HAS_REAL_SETTINGS_ID = !!process.env.USERCENTRICS_TEST_SETTINGS_ID + +async function newPage() { + const browser = await getBrowser() + return browser.newPage() +} + +export function defineUsercentricsSuite() { + it('renders the loader script tag with id + data-settings-id', async () => { + const page = await newPage() + try { + await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) + await page.waitForSelector('script#usercentrics-cmp', { state: 'attached', timeout: 15000 }) + const attrs = await page.evaluate(() => { + const el = document.getElementById('usercentrics-cmp') as HTMLScriptElement | null + if (!el) + return null + return { + src: el.src, + settingsId: el.getAttribute('data-settings-id'), + } + }) + expect(attrs).not.toBeNull() + expect(attrs!.src).toMatch(/app\.usercentrics\.eu\/browser-ui\/.+\/loader\.js$/) + expect(attrs!.settingsId).toBe('test-settings-id') + } + finally { + await page.close() + } + }, 60000) + + it.skipIf(!HAS_REAL_SETTINGS_ID)( + 'fires UC_CONSENT events that the composable surfaces via onConsentChange', + async () => { + const page = await newPage() + try { + await page.goto(url('/'), { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForFunction(() => (window as any).UC_UI?.isInitialized?.(), undefined, { timeout: 30000 }) + await page.evaluate(() => (window as any).UC_UI.acceptAllConsents()) + await page.waitForFunction(() => { + const text = document.querySelector('#consent-events')?.textContent || '' + const m = text.match(/events: (\d+)/) + return m && Number(m[1]) > 0 + }, undefined, { timeout: 15000 }) + } + finally { + await page.close() + } + }, + 90000, + ) +} diff --git a/test/e2e/usercentrics.test.ts b/test/e2e/usercentrics.test.ts new file mode 100644 index 00000000..167c1b78 --- /dev/null +++ b/test/e2e/usercentrics.test.ts @@ -0,0 +1,14 @@ +import { createResolver } from '@nuxt/kit' +import { setup } from '@nuxt/test-utils/e2e' +import { describe } from 'vitest' +import { defineUsercentricsSuite } from './_usercentrics-suite' + +const { resolve } = createResolver(import.meta.url) + +describe('usercentrics (CMP loader served from app.usercentrics.eu)', async () => { + await setup({ + rootDir: resolve('../fixtures/usercentrics'), + browser: true, + }) + defineUsercentricsSuite() +}) diff --git a/test/fixtures/usercentrics/app.vue b/test/fixtures/usercentrics/app.vue new file mode 100644 index 00000000..8f62b8bf --- /dev/null +++ b/test/fixtures/usercentrics/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/usercentrics/nuxt.config.ts b/test/fixtures/usercentrics/nuxt.config.ts new file mode 100644 index 00000000..cbf34a72 --- /dev/null +++ b/test/fixtures/usercentrics/nuxt.config.ts @@ -0,0 +1,19 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// Usercentrics fixture: bundle is intentionally off (see registry capabilities) +// so the loader is requested directly from app.usercentrics.eu. +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + defaultScriptOptions: { trigger: 'onNuxtReady' }, + registry: { + usercentrics: { + // CI does not have a real Usercentrics account. The loader 4xx's on + // unknown IDs but still injects with the right attributes, which is + // what the wiring test asserts. Behavioural tests skip. + settingsId: 'test-settings-id', + }, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/usercentrics/package.json b/test/fixtures/usercentrics/package.json new file mode 100644 index 00000000..b9826b34 --- /dev/null +++ b/test/fixtures/usercentrics/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/usercentrics/pages/index.vue b/test/fixtures/usercentrics/pages/index.vue new file mode 100644 index 00000000..69219d19 --- /dev/null +++ b/test/fixtures/usercentrics/pages/index.vue @@ -0,0 +1,29 @@ + + + diff --git a/test/fixtures/usercentrics/tsconfig.json b/test/fixtures/usercentrics/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/usercentrics/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 1c073604..201ad79e 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -48,6 +48,7 @@ describe('module options registry', () => { expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() }) From dfe028e1405f75d813d032ab13d13385338021e6 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 15:11:47 +1000 Subject: [PATCH 2/6] docs(usercentrics): rephrase partytown limitation --- docs/content/scripts/usercentrics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/scripts/usercentrics.md b/docs/content/scripts/usercentrics.md index 7c425c93..d7759078 100644 --- a/docs/content/scripts/usercentrics.md +++ b/docs/content/scripts/usercentrics.md @@ -132,7 +132,7 @@ useScriptUsercentrics({ ## Partytown -Usercentrics is not supported under Partytown. The `UC_UI` API is method-heavy and not safe to forward across the worker boundary; the CMP also needs main-thread DOM access to render its UI overlays. +Usercentrics is not supported under Partytown. The `UC_UI` API is method-heavy and not safe to forward across the worker boundary, and the CMP needs main-thread DOM access to render its UI overlays. ::script-types :: From da24ce2d5ac6638b704b15b4f4566bf61acf95fd Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 15:27:34 +1000 Subject: [PATCH 3/6] fix(test): include usercentrics fixture in prepare:fixtures --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 095fb674..d25eefb4 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev": "nuxt dev playground", "dev:ssl": "nuxt dev playground --https", "dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures", - "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn", + "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/usercentrics", "typecheck": "nuxt typecheck", "release": "pnpm build && bumpp -r --output=CHANGELOG.md", "lint": "eslint .", From f4036747574215baa9838ad8538b90e5929bff5b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 15:30:52 +1000 Subject: [PATCH 4/6] fix(usercentrics): drop empty settingsId fallback and harden consent map Remove the `|| ''` fallback so the type contract is the single source of truth, and add optional chaining when reading `s.consent?.status` from `getServicesBaseInfo()` results. --- packages/script/src/runtime/registry/usercentrics.ts | 2 +- playground/pages/third-parties/usercentrics/nuxt-scripts.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/script/src/runtime/registry/usercentrics.ts b/packages/script/src/runtime/registry/usercentrics.ts index dde0f189..1e530ecd 100644 --- a/packages/script/src/runtime/registry/usercentrics.ts +++ b/packages/script/src/runtime/registry/usercentrics.ts @@ -84,7 +84,7 @@ export function useScriptUsercentrics( // required for the loader to bootstrap. 'src': `https://app.usercentrics.eu/browser-ui/${version}/loader.js`, 'id': 'usercentrics-cmp', - 'data-settings-id': options.settingsId || '', + 'data-settings-id': options.settingsId, 'data-tcf-enabled': options.tcfEnabled ? 'true' : undefined, 'data-language': options.language, 'data-embedding-type': options.embeddingType, diff --git a/playground/pages/third-parties/usercentrics/nuxt-scripts.vue b/playground/pages/third-parties/usercentrics/nuxt-scripts.vue index c4f8fa86..da889aba 100644 --- a/playground/pages/third-parties/usercentrics/nuxt-scripts.vue +++ b/playground/pages/third-parties/usercentrics/nuxt-scripts.vue @@ -20,7 +20,7 @@ if (import.meta.client) { services.value = (window.UC_UI?.getServicesBaseInfo?.() || []).map(s => ({ id: s.id, name: s.name, - granted: !!s.consent.status, + granted: !!s.consent?.status, })) }) onScopeDispose(off) From 591cb4a1c1e446407f04fbf5388cda2351201542 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 7 May 2026 17:29:53 +1000 Subject: [PATCH 5/6] test(usercentrics): stub UC_UI for deterministic consent round-trip Replace env-var skip gating with a Playwright network stub. The real loader validates settings IDs against the registered origin and refuses to bootstrap on localhost:, so CI previously skipped the behavioural test. Stubbing app.usercentrics.eu and injecting a minimal UC_UI shim via addInitScript lets the UC_CONSENT round-trip assertion run unconditionally. --- test/e2e/_usercentrics-suite.ts | 79 ++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/test/e2e/_usercentrics-suite.ts b/test/e2e/_usercentrics-suite.ts index 204bbf13..3eac0824 100644 --- a/test/e2e/_usercentrics-suite.ts +++ b/test/e2e/_usercentrics-suite.ts @@ -1,14 +1,45 @@ import { getBrowser, url } from '@nuxt/test-utils/e2e' import { expect, it } from 'vitest' -// Usercentrics requires a valid `settingsId` tied to a real account to fully -// boot. CI uses a placeholder ID, so behavioural assertions (UC_UI globals, -// UC_CONSENT events) skip; only DOM-wiring assertions run unconditionally. -const HAS_REAL_SETTINGS_ID = !!process.env.USERCENTRICS_TEST_SETTINGS_ID - +// The real Usercentrics loader validates the `data-settings-id` against the +// registered origin and refuses to initialise on `localhost:` (CI). +// Tests stub the loader request and inject a minimal `UC_UI` shim plus +// synthesised `UC_UI_INITIALIZED` / `UC_CONSENT` events, so behavioural +// assertions run unconditionally. async function newPage() { const browser = await getBrowser() - return browser.newPage() + const page = await browser.newPage() + await page.route('**/app.usercentrics.eu/**', route => route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: '', + })) + await page.addInitScript(() => { + const w = window as unknown as { + UC_UI: Record + __ucDispatch: (action: string) => void + } + w.UC_UI = { + isInitialized: () => true, + showFirstLayer: () => {}, + showSecondLayer: () => {}, + acceptAllConsents: async () => { + window.dispatchEvent(new CustomEvent('UC_CONSENT', { + detail: { type: 'explicit', action: 'onAcceptAllServices' }, + })) + }, + denyAllConsents: async () => { + window.dispatchEvent(new CustomEvent('UC_CONSENT', { + detail: { type: 'explicit', action: 'onDenyAllServices' }, + })) + }, + getServicesBaseInfo: () => [], + getCMPData: () => ({}), + } + // Fire init event after listeners (added in onMounted/clientInit) attach. + setTimeout(() => window.dispatchEvent(new CustomEvent('UC_UI_INITIALIZED')), 100) + }) + return page } export function defineUsercentricsSuite() { @@ -35,24 +66,20 @@ export function defineUsercentricsSuite() { } }, 60000) - it.skipIf(!HAS_REAL_SETTINGS_ID)( - 'fires UC_CONSENT events that the composable surfaces via onConsentChange', - async () => { - const page = await newPage() - try { - await page.goto(url('/'), { waitUntil: 'networkidle', timeout: 30000 }) - await page.waitForFunction(() => (window as any).UC_UI?.isInitialized?.(), undefined, { timeout: 30000 }) - await page.evaluate(() => (window as any).UC_UI.acceptAllConsents()) - await page.waitForFunction(() => { - const text = document.querySelector('#consent-events')?.textContent || '' - const m = text.match(/events: (\d+)/) - return m && Number(m[1]) > 0 - }, undefined, { timeout: 15000 }) - } - finally { - await page.close() - } - }, - 90000, - ) + it('fires UC_CONSENT events that the composable surfaces via onConsentChange', async () => { + const page = await newPage() + try { + await page.goto(url('/'), { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForFunction(() => (window as any).UC_UI?.isInitialized?.(), undefined, { timeout: 30000 }) + await page.evaluate(() => (window as any).UC_UI.acceptAllConsents()) + await page.waitForFunction(() => { + const text = document.querySelector('#consent-events')?.textContent || '' + const m = text.match(/events: (\d+)/) + return !!m && Number(m[1]) > 0 + }, undefined, { timeout: 15000 }) + } + finally { + await page.close() + } + }, 90000) } From 8d25ad60ae7478126f090c4a4f2832437640aa46 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 8 May 2026 15:01:36 +1000 Subject: [PATCH 6/6] fix(usercentrics): pivot to CMP v3 (web.cmp.usercentrics.eu, data-ruleset-id, __ucCmp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original PR targeted the legacy CMP v2 surface (app.usercentrics.eu/browser-ui//loader.js + data-settings-id + UC_UI). New Usercentrics signups only get v3, so the integration was unreachable for any modern customer. Verified live against a real v3 ruleset: - Loader URL is web.cmp.usercentrics.eu/ui/loader.js (no version segment). - Required attribute is data-ruleset-id (not data-settings-id). - Programmatic API is window.__ucCmp (UC_UI is not bound for v3 rulesets we observed); methods isInitialized / isConsentRequired / showFirstLayer / showSecondLayer / acceptAllConsents / denyAllConsents / getConsentDetails / refreshScripts live on its prototype. - Init event is UC_CMP_API_READY; consent change event is UC_UI_CMP_EVENT (with detail.type ∈ ACCEPT_ALL | DENY_ALL | SAVE | …). - v2-only fields removed: settingsId, version, tcfEnabled, embeddingType (TCF / embedding mode now configured server-side in the ruleset). Schema is now { rulesetId, autoblocker?, language? }. autoblocker injects web.cmp.usercentrics.eu/modules/autoblocker.js ahead of the loader for rulesets configured with Auto Blocking. The composable surface exposes { ucCmp } in the script result and a typed `consent` helper for onConsentChange / whenReady / showFirstLayer / showSecondLayer / acceptAll / denyAll backed by __ucCmp. Suite stubs the loader and dispatches UC_CMP_API_READY + UC_UI_CMP_EVENT to keep behavioural tests deterministic on CI; an additional contract test fetches the live loader and asserts it still references __ucCmp + UC_CMP_API_READY so a vendor change breaks tests instead of silently breaking the integration. --- docs/content/scripts/usercentrics.md | 120 ++++++++++-------- packages/script/src/registry-types.json | 37 ++---- packages/script/src/registry.ts | 13 +- .../script/src/runtime/registry/schemas.ts | 24 ++-- .../src/runtime/registry/usercentrics.ts | 116 ++++++++++------- packages/script/src/script-meta.ts | 2 +- .../third-parties/usercentrics/default.vue | 4 +- .../usercentrics/nuxt-scripts.vue | 25 +--- test/e2e/_usercentrics-suite.ts | 71 ++++++----- test/e2e/usercentrics.test.ts | 2 +- test/fixtures/usercentrics/nuxt.config.ts | 7 +- 11 files changed, 212 insertions(+), 209 deletions(-) diff --git a/docs/content/scripts/usercentrics.md b/docs/content/scripts/usercentrics.md index d7759078..9caa95f5 100644 --- a/docs/content/scripts/usercentrics.md +++ b/docs/content/scripts/usercentrics.md @@ -1,16 +1,16 @@ --- title: Usercentrics -description: Load the Usercentrics CMP and drive useScript consent triggers from UC_CONSENT events. +description: Load the Usercentrics CMP v3 and drive useScript consent triggers from UC_UI_CMP_EVENT events. links: -- label: Source - icon: i-simple-icons-github - to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/usercentrics.ts - size: xs + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/usercentrics.ts + size: xs --- [Usercentrics](https://usercentrics.com) is a Consent Management Platform (CMP) used to collect, store, and signal end user consent for third-party scripts under GDPR, CCPA, and the IAB TCF v2 framework. -Nuxt Scripts ships [`useScriptUsercentrics()`{lang="ts"}](/scripts/usercentrics) so you can boot the CMP loader, expose typed access to the `UC_UI` global, and wire other registry scripts' consent triggers directly to Usercentrics' `UC_CONSENT` event. +Nuxt Scripts ships [`useScriptUsercentrics()`{lang="ts"}](/scripts/usercentrics) so you can boot the CMP v3 ("Web CMP") loader, expose typed access to the `window.__ucCmp` programmatic API, and wire other registry scripts' consent triggers directly to Usercentrics' `UC_UI_CMP_EVENT` browser event. ::script-stats :: @@ -18,55 +18,64 @@ Nuxt Scripts ships [`useScriptUsercentrics()`{lang="ts"}](/scripts/usercentrics) ::script-docs :: -## Setup +The composable comes with the following defaults: +- **Trigger: Client** Script will load when Nuxt is hydrating. +- **Bundle / proxy: off** The CMP is the consent surface itself, so it must hit the vendor origin directly. It is also exempt from consent gating. -Provide your Usercentrics `settingsId` in `nuxt.config.ts`: +You can access the `ucCmp` object as a proxy directly or await the `$script` promise. It's recommended to use the proxy for any void / Promise-returning calls. -```ts -export default defineNuxtConfig({ - scripts: { - registry: { - usercentrics: { - settingsId: 'YOUR_SETTINGS_ID', - }, - }, - }, +::code-group + +```ts [Proxy] +const { proxy } = useScriptUsercentrics({ + rulesetId: 'your-ruleset-id', }) +function showSettings() { + proxy.ucCmp.showSecondLayer() +} ``` -The composable is exempt from consent gating; it is the consent surface itself, so it must hit the Usercentrics origin directly. Bundling and proxying are intentionally disabled. +```ts [onLoaded] +const { onLoaded } = useScriptUsercentrics({ + rulesetId: 'your-ruleset-id', +}) +onLoaded(({ ucCmp }) => { + ucCmp.showFirstLayer() +}) +``` + +:: ## Drive consent triggers from Usercentrics -This is the killer integration. Pair `useScriptUsercentrics({ ... }).consent.onConsentChange(...)`{lang="ts"} with [`useScriptTriggerConsent`](/docs/api/use-script-trigger-consent) to load any third-party script the moment the user opts in via the Usercentrics banner. +Pair `consent.onConsentChange(...)`{lang="ts"} with [`useScriptTriggerConsent`](/docs/api/use-script-trigger-consent) to load any third-party script the moment the user opts in via the Usercentrics banner. ```vue @@ -78,19 +87,21 @@ useScriptGoogleAnalytics({ ``` -`onConsentChange` returns a teardown function so you can unsubscribe inside `onScopeDispose`. The callback receives the raw event detail emitted on `window`. +`onConsentChange` returns a teardown function so you can unsubscribe inside `onScopeDispose`. The callback receives the raw `UC_UI_CMP_EVENT` detail (e.g. `{ type: 'ACCEPT_ALL' | 'DENY_ALL' | 'SAVE', ... }`). ## Open the consent UI -`UC_UI` is not on `window` until `UC_UI_INITIALIZED` fires. Use `consent.whenReady()`{lang="ts"} to await it, or call the helpers on `consent` directly (they no-op while the CMP boots): +`__ucCmp`'s methods are no-ops until the CMP API is ready. Use `consent.whenReady()`{lang="ts"} to await it, or call the helpers on `consent` directly (they no-op while the CMP boots): ```vue @@ -107,32 +118,35 @@ async function logServices() { ``` -## TCF mode +## Auto Blocking -For IAB TCF v2 deployments, set `embeddingType: 'tcf'` and (optionally) `tcfEnabled: true`. The composable forwards both as data attributes on the loader script tag. +If your Usercentrics ruleset is configured for **Auto Blocking** (rather than Manual Blocking), set `autoblocker: true` to inject the autoblocker module ahead of the loader: ```ts useScriptUsercentrics({ - settingsId: 'YOUR_SETTINGS_ID', - embeddingType: 'tcf', - tcfEnabled: true, + rulesetId: 'your-ruleset-id', + autoblocker: true, }) ``` -## Loader version +::script-types +:: + +## Example -The loader URL has a version segment (`/browser-ui//loader.js`). It defaults to `'latest'`; pin it for reproducible builds: +Loading Usercentrics through `app.vue` when Nuxt is ready. -```ts +```vue [app.vue] + ``` ## Partytown -Usercentrics is not supported under Partytown. The `UC_UI` API is method-heavy and not safe to forward across the worker boundary, and the CMP needs main-thread DOM access to render its UI overlays. - -::script-types -:: +Usercentrics is not supported under Partytown. The `__ucCmp` API is method-heavy and not safe to forward across the worker boundary, and the CMP needs main-thread DOM access to render its UI overlays. diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 90ac88f0..79606ef9 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -975,12 +975,7 @@ { "name": "UsercentricsOptions", "kind": "const", - "code": "export const UsercentricsOptions = object({\n /**\n * Your Usercentrics settings ID.\n * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/getting_started/web/\n */\n settingsId: pipe(string(), minLength(1)),\n /**\n * Loader version segment used in the script src.\n * @default 'latest'\n */\n version: optional(string()),\n /**\n * Enable IAB TCF v2 mode. When set, Usercentrics serves the TCF-aware loader.\n * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/iab_tcf/web/\n */\n tcfEnabled: optional(boolean()),\n /**\n * Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`).\n * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/configuration/language/\n */\n language: optional(string()),\n /**\n * Loader variant. `'gdpr'` is the default; `'tcf'` selects the TCF v2 loader.\n */\n embeddingType: optional(union([literal('tcf'), literal('gdpr')])),\n})" - }, - { - "name": "UsercentricsService", - "kind": "interface", - "code": "export interface UsercentricsService {\n id: string\n name: string\n consent: { status: boolean }\n [key: string]: any\n}" + "code": "export const UsercentricsOptions = object({\n /**\n * Your Usercentrics CMP v3 ruleset ID. Find it in the admin under\n * Implementation; the snippet's `data-ruleset-id` value.\n */\n rulesetId: pipe(string(), minLength(1)),\n /**\n * Inject the Usercentrics autoblocker (`autoblocker.js`) ahead of the loader.\n * Enable when your ruleset relies on Auto Blocking (vs. Manual Blocking) to\n * gate third-party scripts before consent is granted.\n * @default false\n */\n autoblocker: optional(boolean()),\n /**\n * Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`).\n */\n language: optional(string()),\n})" }, { "name": "UsercentricsCmpEventDetail", @@ -988,19 +983,19 @@ "code": "export interface UsercentricsCmpEventDetail {\n type: string\n source?: string\n [key: string]: any\n}" }, { - "name": "UsercentricsUI", + "name": "UsercentricsCmp", "kind": "interface", - "code": "export interface UsercentricsUI {\n isInitialized: () => boolean\n showFirstLayer: () => void\n showSecondLayer: () => void\n acceptAllConsents: () => Promise\n denyAllConsents: () => Promise\n getServicesBaseInfo: () => UsercentricsService[]\n getCMPData: () => Record\n [key: string]: any\n}" + "code": "export interface UsercentricsCmp {\n isInitialized: () => Promise\n isConsentRequired: () => Promise\n showFirstLayer: () => Promise\n showSecondLayer: () => Promise\n showServiceDetails: (id: string) => Promise\n showAutoblockerMoreInfoView: () => Promise\n closeCmp: () => Promise\n acceptAllConsents: () => Promise\n denyAllConsents: () => Promise\n saveConsents: () => Promise\n updateCategoriesConsents: (consents: Array<{ categorySlug: string, consent: boolean }>) => Promise\n updateServicesConsents: (consents: Array<{ templateId: string, consent: boolean }>) => Promise\n updateTcfConsents: (...args: unknown[]) => Promise\n refreshScripts: () => Promise\n clearUserSession: () => Promise\n getConsentDetails: () => Promise>\n getCmpConfig: () => Promise>\n getActiveLanguage: () => Promise\n getControllerId: () => Promise\n changeLanguage: (lang: string) => Promise\n [key: string]: any\n}" }, { "name": "UsercentricsApi", "kind": "interface", - "code": "export interface UsercentricsApi {\n UC_UI: UsercentricsUI\n}" + "code": "export interface UsercentricsApi {\n ucCmp: UsercentricsCmp\n}" }, { "name": "UsercentricsConsent", "kind": "interface", - "code": "export interface UsercentricsConsent {\n /**\n * Resolves once the CMP has fired `UC_UI_INITIALIZED` (or immediately if it\n * already initialised). Resolves with the `UC_UI` global so callers can\n * query consent state without polling.\n */\n whenReady: () => Promise\n /**\n * Subscribe to `UC_CONSENT` browser events. Returns a teardown function.\n * The callback receives the raw event detail emitted by Usercentrics.\n */\n onConsentChange: (cb: (detail: any, event: Event) => void) => () => void\n /** Open the privacy settings (first layer banner). */\n showFirstLayer: () => void\n /** Open the detailed settings (second layer modal). */\n showSecondLayer: () => void\n /** Accept all consents. */\n acceptAll: () => Promise | void\n /** Reject all consents. */\n denyAll: () => Promise | void\n}" + "code": "export interface UsercentricsConsent {\n /**\n * Resolves once the CMP API is ready (`UC_CMP_API_READY`) or immediately if\n * it already is. Resolves with `window.__ucCmp` so callers can query\n * consent state without polling.\n */\n whenReady: () => Promise\n /**\n * Subscribe to `UC_UI_CMP_EVENT` browser events (the v3 consent change\n * event). Returns a teardown function. The callback receives the event\n * detail, e.g. `{ type: 'ACCEPT_ALL' | 'DENY_ALL' | 'SAVE', ... }`.\n */\n onConsentChange: (cb: (detail: UsercentricsCmpEventDetail, event: Event) => void) => () => void\n /** Open the privacy settings (first layer banner). */\n showFirstLayer: () => Promise | void\n /** Open the detailed settings (second layer modal). */\n showSecondLayer: () => Promise | void\n /** Accept all consents. */\n acceptAll: () => Promise | void\n /** Reject all consents. */\n denyAll: () => Promise | void\n}" } ], "vercel-analytics": [ @@ -2094,35 +2089,23 @@ ], "UsercentricsOptions": [ { - "name": "settingsId", + "name": "rulesetId", "type": "string", "required": true, - "description": "Your Usercentrics settings ID." + "description": "Your Usercentrics CMP v3 ruleset ID. Find it in the admin under Implementation; the snippet's `data-ruleset-id` value." }, { - "name": "version", - "type": "string", - "required": false, - "description": "Loader version segment used in the script src.", - "defaultValue": "'latest'" - }, - { - "name": "tcfEnabled", + "name": "autoblocker", "type": "boolean", "required": false, - "description": "Enable IAB TCF v2 mode. When set, Usercentrics serves the TCF-aware loader." + "description": "Inject the Usercentrics autoblocker (`autoblocker.js`) ahead of the loader. Enable when your ruleset relies on Auto Blocking (vs. Manual Blocking) to gate third-party scripts before consent is granted.", + "defaultValue": "false" }, { "name": "language", "type": "string", "required": false, "description": "Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`)." - }, - { - "name": "embeddingType", - "type": "'tcf' | 'gdpr'", - "required": false, - "description": "Loader variant. `'gdpr'` is the default; `'tcf'` selects the TCF v2 loader." } ], "SegmentOptions": [ diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index c9bd0bb2..b494f33e 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -792,14 +792,13 @@ export async function registry(resolve?: (path: string) => Promise): Pro composableName: 'useScriptUsercentrics', schema: UsercentricsOptions, label: 'Usercentrics', - // Source declared here for devtools/listing only; the runtime composable - // builds the actual src so it can include `version` and the required - // `data-settings-id` attribute. No bundle/proxy: the CMP must hit the - // vendor origin (it IS the consent surface, and is exempt from consent - // gating) so policies/signatures resolve correctly. - src: 'https://app.usercentrics.eu/browser-ui/latest/loader.js', + // The runtime composable builds the actual script tag so it can include + // the required `data-ruleset-id` attribute. No bundle/proxy: the CMP + // must hit the vendor origin (it IS the consent surface, and is exempt + // from consent gating) so policies/signatures resolve correctly. + src: 'https://web.cmp.usercentrics.eu/ui/loader.js', category: 'utility', - envDefaults: { settingsId: '' }, + envDefaults: { rulesetId: '' }, }), def('gravatar', { schema: GravatarOptions, diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index b221fb4c..0b86789f 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -864,29 +864,21 @@ export const LinkedInInsightOptions = object({ export const UsercentricsOptions = object({ /** - * Your Usercentrics settings ID. - * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/getting_started/web/ + * Your Usercentrics CMP v3 ruleset ID. Find it in the admin under + * Implementation; the snippet's `data-ruleset-id` value. */ - settingsId: pipe(string(), minLength(1)), + rulesetId: pipe(string(), minLength(1)), /** - * Loader version segment used in the script src. - * @default 'latest' - */ - version: optional(string()), - /** - * Enable IAB TCF v2 mode. When set, Usercentrics serves the TCF-aware loader. - * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/iab_tcf/web/ + * Inject the Usercentrics autoblocker (`autoblocker.js`) ahead of the loader. + * Enable when your ruleset relies on Auto Blocking (vs. Manual Blocking) to + * gate third-party scripts before consent is granted. + * @default false */ - tcfEnabled: optional(boolean()), + autoblocker: optional(boolean()), /** * Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`). - * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/configuration/language/ */ language: optional(string()), - /** - * Loader variant. `'gdpr'` is the default; `'tcf'` selects the TCF v2 loader. - */ - embeddingType: optional(union([literal('tcf'), literal('gdpr')])), }) export const SegmentOptions = object({ diff --git a/packages/script/src/runtime/registry/usercentrics.ts b/packages/script/src/runtime/registry/usercentrics.ts index 1e530ecd..5549a6d0 100644 --- a/packages/script/src/runtime/registry/usercentrics.ts +++ b/packages/script/src/runtime/registry/usercentrics.ts @@ -1,4 +1,5 @@ import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { useHead } from '@unhead/vue' import { useRegistryScript } from '../utils' import { UsercentricsOptions } from './schemas' @@ -6,37 +7,47 @@ export { UsercentricsOptions } export type UsercentricsInput = RegistryScriptInput -export interface UsercentricsService { - id: string - name: string - consent: { status: boolean } - [key: string]: any -} - export interface UsercentricsCmpEventDetail { type: string source?: string [key: string]: any } -export interface UsercentricsUI { - isInitialized: () => boolean - showFirstLayer: () => void - showSecondLayer: () => void +/** + * The Usercentrics CMP v3 programmatic API exposed on `window.__ucCmp`. + * Each method returns a Promise resolved once the CMP is ready. + */ +export interface UsercentricsCmp { + isInitialized: () => Promise + isConsentRequired: () => Promise + showFirstLayer: () => Promise + showSecondLayer: () => Promise + showServiceDetails: (id: string) => Promise + showAutoblockerMoreInfoView: () => Promise + closeCmp: () => Promise acceptAllConsents: () => Promise denyAllConsents: () => Promise - getServicesBaseInfo: () => UsercentricsService[] - getCMPData: () => Record + saveConsents: () => Promise + updateCategoriesConsents: (consents: Array<{ categorySlug: string, consent: boolean }>) => Promise + updateServicesConsents: (consents: Array<{ templateId: string, consent: boolean }>) => Promise + updateTcfConsents: (...args: unknown[]) => Promise + refreshScripts: () => Promise + clearUserSession: () => Promise + getConsentDetails: () => Promise> + getCmpConfig: () => Promise> + getActiveLanguage: () => Promise + getControllerId: () => Promise + changeLanguage: (lang: string) => Promise [key: string]: any } export interface UsercentricsApi { - UC_UI: UsercentricsUI + ucCmp: UsercentricsCmp } declare global { interface Window { - UC_UI?: UsercentricsUI + __ucCmp?: UsercentricsCmp } } @@ -46,20 +57,21 @@ declare global { */ export interface UsercentricsConsent { /** - * Resolves once the CMP has fired `UC_UI_INITIALIZED` (or immediately if it - * already initialised). Resolves with the `UC_UI` global so callers can - * query consent state without polling. + * Resolves once the CMP API is ready (`UC_CMP_API_READY`) or immediately if + * it already is. Resolves with `window.__ucCmp` so callers can query + * consent state without polling. */ - whenReady: () => Promise + whenReady: () => Promise /** - * Subscribe to `UC_CONSENT` browser events. Returns a teardown function. - * The callback receives the raw event detail emitted by Usercentrics. + * Subscribe to `UC_UI_CMP_EVENT` browser events (the v3 consent change + * event). Returns a teardown function. The callback receives the event + * detail, e.g. `{ type: 'ACCEPT_ALL' | 'DENY_ALL' | 'SAVE', ... }`. */ - onConsentChange: (cb: (detail: any, event: Event) => void) => () => void + onConsentChange: (cb: (detail: UsercentricsCmpEventDetail, event: Event) => void) => () => void /** Open the privacy settings (first layer banner). */ - showFirstLayer: () => void + showFirstLayer: () => Promise | void /** Open the detailed settings (second layer modal). */ - showSecondLayer: () => void + showSecondLayer: () => Promise | void /** Accept all consents. */ acceptAll: () => Promise | void /** Reject all consents. */ @@ -67,60 +79,66 @@ export interface UsercentricsConsent { } /** - * Load the Usercentrics CMP loader and expose typed access to the `UC_UI` - * global plus a `consent` helper with `onConsentChange` for wiring consent - * triggers (`useScript({ trigger: ... })`) to Usercentrics events. + * Load the Usercentrics CMP v3 ("Web CMP") loader and expose typed access to + * the `window.__ucCmp` programmatic API plus a `consent` helper with + * `onConsentChange` for wiring consent triggers (`useScript({ trigger: ... })`) + * to Usercentrics events. * - * @see https://docs.usercentrics.com/cmp_in_app_sdk/latest/getting_started/web/ + * @see https://usercentrics.com/knowledge-hub/usercentrics-cmp-v3-migrations/ */ export function useScriptUsercentrics( _options?: UsercentricsInput, ): UseScriptContext { const instance = useRegistryScript('usercentrics', (options) => { - const version = options.version || 'latest' + if (import.meta.client && options.autoblocker) { + useHead({ + script: [{ + src: 'https://web.cmp.usercentrics.eu/modules/autoblocker.js', + tagPosition: 'head', + tagPriority: 'high', + }], + }) + } return { scriptInput: { - // The CMP loader is identified by id + data-settings-id; both are - // required for the loader to bootstrap. - 'src': `https://app.usercentrics.eu/browser-ui/${version}/loader.js`, + 'src': 'https://web.cmp.usercentrics.eu/ui/loader.js', 'id': 'usercentrics-cmp', - 'data-settings-id': options.settingsId, - 'data-tcf-enabled': options.tcfEnabled ? 'true' : undefined, + 'data-ruleset-id': options.rulesetId, 'data-language': options.language, - 'data-embedding-type': options.embeddingType, 'crossorigin': false, }, schema: import.meta.dev ? UsercentricsOptions : undefined, scriptOptions: { use() { - return { UC_UI: window.UC_UI } as unknown as T + return { ucCmp: window.__ucCmp } as unknown as T }, }, } }, _options) as UseScriptContext if (import.meta.client && !instance.consent) { - const whenReady = (): Promise => new Promise((resolve) => { - if (window.UC_UI?.isInitialized?.()) - return resolve(window.UC_UI) - const onInit = () => { - window.removeEventListener('UC_UI_INITIALIZED', onInit) - resolve(window.UC_UI as UsercentricsUI) + const whenReady = (): Promise => new Promise((resolve) => { + // __ucCmp is present from loader bootstrap, but methods aren't callable + // until UC_CMP_API_READY fires. Resolve on that event (or now if it + // already fired and __ucCmp is bound). + const onReady = () => { + window.removeEventListener('UC_CMP_API_READY', onReady) + resolve(window.__ucCmp as UsercentricsCmp) } - window.addEventListener('UC_UI_INITIALIZED', onInit) + window.addEventListener('UC_CMP_API_READY', onReady) }) instance.consent = { whenReady, onConsentChange(cb) { const handler = (e: Event) => cb((e as CustomEvent).detail, e) - window.addEventListener('UC_CONSENT', handler) - return () => window.removeEventListener('UC_CONSENT', handler) + window.addEventListener('UC_UI_CMP_EVENT', handler) + return () => window.removeEventListener('UC_UI_CMP_EVENT', handler) }, - showFirstLayer: () => window.UC_UI?.showFirstLayer?.(), - showSecondLayer: () => window.UC_UI?.showSecondLayer?.(), - acceptAll: () => window.UC_UI?.acceptAllConsents?.(), - denyAll: () => window.UC_UI?.denyAllConsents?.(), + showFirstLayer: () => window.__ucCmp?.showFirstLayer?.(), + showSecondLayer: () => window.__ucCmp?.showSecondLayer?.(), + acceptAll: () => window.__ucCmp?.acceptAllConsents?.(), + denyAll: () => window.__ucCmp?.denyAllConsents?.(), } } diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index f5d7a152..f7307dec 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -205,7 +205,7 @@ export const scriptMeta = { // CMP / Consent usercentrics: { - urls: ['https://app.usercentrics.eu/browser-ui/latest/loader.js'], + urls: ['https://web.cmp.usercentrics.eu/ui/loader.js', 'https://web.cmp.usercentrics.eu/modules/autoblocker.js'], trackedData: [], }, diff --git a/playground/pages/third-parties/usercentrics/default.vue b/playground/pages/third-parties/usercentrics/default.vue index 55c24bbb..fe88704a 100644 --- a/playground/pages/third-parties/usercentrics/default.vue +++ b/playground/pages/third-parties/usercentrics/default.vue @@ -9,8 +9,8 @@ useHead({ script: [ { 'id': 'usercentrics-cmp', - 'src': 'https://app.usercentrics.eu/browser-ui/latest/loader.js', - 'data-settings-id': 'PLACEHOLDER_SETTINGS_ID', + 'src': 'https://web.cmp.usercentrics.eu/ui/loader.js', + 'data-ruleset-id': 'PLACEHOLDER_RULESET_ID', 'async': true, }, ], diff --git a/playground/pages/third-parties/usercentrics/nuxt-scripts.vue b/playground/pages/third-parties/usercentrics/nuxt-scripts.vue index da889aba..b3c3ba5d 100644 --- a/playground/pages/third-parties/usercentrics/nuxt-scripts.vue +++ b/playground/pages/third-parties/usercentrics/nuxt-scripts.vue @@ -4,24 +4,20 @@ import { useHead, useScriptUsercentrics } from '#imports' useHead({ title: 'Usercentrics CMP' }) -// Replace with your Usercentrics settingsId. The bundled placeholder will not +// Replace with your Usercentrics rulesetId. The bundled placeholder will not // boot a real CMP, so the helpers below stay no-ops until you provide one // (either here or via nuxt.config -> scripts.registry.usercentrics). const { status, consent } = useScriptUsercentrics({ - settingsId: 'PLACEHOLDER_SETTINGS_ID', + rulesetId: 'PLACEHOLDER_RULESET_ID', }) const lastEventAt = ref(null) -const services = ref<{ id: string, name: string, granted: boolean }[]>([]) +const lastEventType = ref(null) if (import.meta.client) { - const off = consent.onConsentChange(() => { + const off = consent.onConsentChange((detail) => { lastEventAt.value = new Date().toISOString() - services.value = (window.UC_UI?.getServicesBaseInfo?.() || []).map(s => ({ - id: s.id, - name: s.name, - granted: !!s.consent?.status, - })) + lastEventType.value = detail?.type ?? null }) onScopeDispose(off) } @@ -32,7 +28,7 @@ if (import.meta.client) {

Usercentrics

status: {{ status }}
-
last UC_CONSENT: {{ lastEventAt ?? '(none yet)' }}
+
last UC_UI_CMP_EVENT: {{ lastEventAt ?? '(none yet)' }} type: {{ lastEventType ?? '(none)' }}
Show banner @@ -47,15 +43,6 @@ if (import.meta.client) { Reject all
-
-

Services

-
    -
  • - {{ s.id }} — {{ s.name }} — - {{ s.granted ? 'granted' : 'denied' }} -
  • -
-
diff --git a/test/e2e/_usercentrics-suite.ts b/test/e2e/_usercentrics-suite.ts index 3eac0824..7088ea69 100644 --- a/test/e2e/_usercentrics-suite.ts +++ b/test/e2e/_usercentrics-suite.ts @@ -1,49 +1,47 @@ import { getBrowser, url } from '@nuxt/test-utils/e2e' import { expect, it } from 'vitest' -// The real Usercentrics loader validates the `data-settings-id` against the -// registered origin and refuses to initialise on `localhost:` (CI). -// Tests stub the loader request and inject a minimal `UC_UI` shim plus -// synthesised `UC_UI_INITIALIZED` / `UC_CONSENT` events, so behavioural -// assertions run unconditionally. +// The real Usercentrics CMP v3 loader validates `data-ruleset-id` against +// registered domains and silently no-ops on unknown origins. Tests stub the +// loader request and inject a minimal `__ucCmp` shim plus synthesised +// `UC_CMP_API_READY` / `UC_UI_CMP_EVENT` events, so behavioural assertions +// run unconditionally on CI. async function newPage() { const browser = await getBrowser() const page = await browser.newPage() - await page.route('**/app.usercentrics.eu/**', route => route.fulfill({ + await page.route('**/web.cmp.usercentrics.eu/**', route => route.fulfill({ status: 200, contentType: 'application/javascript', body: '', })) await page.addInitScript(() => { - const w = window as unknown as { - UC_UI: Record - __ucDispatch: (action: string) => void - } - w.UC_UI = { - isInitialized: () => true, - showFirstLayer: () => {}, - showSecondLayer: () => {}, + const w = window as unknown as { __ucCmp: Record } + w.__ucCmp = { + isInitialized: async () => true, + isConsentRequired: async () => true, + showFirstLayer: async () => {}, + showSecondLayer: async () => {}, acceptAllConsents: async () => { - window.dispatchEvent(new CustomEvent('UC_CONSENT', { - detail: { type: 'explicit', action: 'onAcceptAllServices' }, + window.dispatchEvent(new CustomEvent('UC_UI_CMP_EVENT', { + detail: { type: 'ACCEPT_ALL', source: 'explicit' }, })) }, denyAllConsents: async () => { - window.dispatchEvent(new CustomEvent('UC_CONSENT', { - detail: { type: 'explicit', action: 'onDenyAllServices' }, + window.dispatchEvent(new CustomEvent('UC_UI_CMP_EVENT', { + detail: { type: 'DENY_ALL', source: 'explicit' }, })) }, - getServicesBaseInfo: () => [], - getCMPData: () => ({}), + getConsentDetails: async () => ({}), + getControllerId: async () => 'test-controller', + getActiveLanguage: async () => 'en', } - // Fire init event after listeners (added in onMounted/clientInit) attach. - setTimeout(() => window.dispatchEvent(new CustomEvent('UC_UI_INITIALIZED')), 100) + setTimeout(() => window.dispatchEvent(new CustomEvent('UC_CMP_API_READY')), 100) }) return page } export function defineUsercentricsSuite() { - it('renders the loader script tag with id + data-settings-id', async () => { + it('renders the v3 loader script tag with id + data-ruleset-id', async () => { const page = await newPage() try { await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) @@ -54,24 +52,24 @@ export function defineUsercentricsSuite() { return null return { src: el.src, - settingsId: el.getAttribute('data-settings-id'), + rulesetId: el.getAttribute('data-ruleset-id'), } }) expect(attrs).not.toBeNull() - expect(attrs!.src).toMatch(/app\.usercentrics\.eu\/browser-ui\/.+\/loader\.js$/) - expect(attrs!.settingsId).toBe('test-settings-id') + expect(attrs!.src).toBe('https://web.cmp.usercentrics.eu/ui/loader.js') + expect(attrs!.rulesetId).toBe('test-ruleset-id') } finally { await page.close() } }, 60000) - it('fires UC_CONSENT events that the composable surfaces via onConsentChange', async () => { + it('fires UC_UI_CMP_EVENT events that the composable surfaces via onConsentChange', async () => { const page = await newPage() try { await page.goto(url('/'), { waitUntil: 'networkidle', timeout: 30000 }) - await page.waitForFunction(() => (window as any).UC_UI?.isInitialized?.(), undefined, { timeout: 30000 }) - await page.evaluate(() => (window as any).UC_UI.acceptAllConsents()) + await page.waitForFunction(() => typeof (window as any).__ucCmp === 'object', undefined, { timeout: 30000 }) + await page.evaluate(() => (window as any).__ucCmp.acceptAllConsents()) await page.waitForFunction(() => { const text = document.querySelector('#consent-events')?.textContent || '' const m = text.match(/events: (\d+)/) @@ -82,4 +80,19 @@ export function defineUsercentricsSuite() { await page.close() } }, 90000) + + // Contract test: the live loader URL still serves a body that wires the v3 + // globals/events we ship against. If Usercentrics changes any of these, the + // integration breaks even though the stubbed behavioural tests above still + // pass. Skipped on offline CI (network failure is tolerated, not asserted). + it('live loader URL still serves a body that wires __ucCmp + UC_CMP_API_READY', async () => { + const res = await fetch('https://web.cmp.usercentrics.eu/ui/loader.js').catch(() => null) + if (!res || !res.ok) { + console.warn('[usercentrics] skipping live-loader contract check; fetch failed') + return + } + const body = await res.text() + expect(body).toMatch(/UC_CMP_API_READY/) + expect(body).toMatch(/__ucCmp/) + }, 30000) } diff --git a/test/e2e/usercentrics.test.ts b/test/e2e/usercentrics.test.ts index 167c1b78..a16d1ee3 100644 --- a/test/e2e/usercentrics.test.ts +++ b/test/e2e/usercentrics.test.ts @@ -5,7 +5,7 @@ import { defineUsercentricsSuite } from './_usercentrics-suite' const { resolve } = createResolver(import.meta.url) -describe('usercentrics (CMP loader served from app.usercentrics.eu)', async () => { +describe('usercentrics (CMP v3 loader served from web.cmp.usercentrics.eu)', async () => { await setup({ rootDir: resolve('../fixtures/usercentrics'), browser: true, diff --git a/test/fixtures/usercentrics/nuxt.config.ts b/test/fixtures/usercentrics/nuxt.config.ts index cbf34a72..6577b8d5 100644 --- a/test/fixtures/usercentrics/nuxt.config.ts +++ b/test/fixtures/usercentrics/nuxt.config.ts @@ -1,17 +1,14 @@ import { defineNuxtConfig } from 'nuxt/config' // Usercentrics fixture: bundle is intentionally off (see registry capabilities) -// so the loader is requested directly from app.usercentrics.eu. +// so the loader is requested directly from web.cmp.usercentrics.eu. export default defineNuxtConfig({ modules: ['@nuxt/scripts'], scripts: { defaultScriptOptions: { trigger: 'onNuxtReady' }, registry: { usercentrics: { - // CI does not have a real Usercentrics account. The loader 4xx's on - // unknown IDs but still injects with the right attributes, which is - // what the wiring test asserts. Behavioural tests skip. - settingsId: 'test-settings-id', + rulesetId: 'test-ruleset-id', }, }, },