-
Notifications
You must be signed in to change notification settings - Fork 85
feat: unified consent management #712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
harlan-zw
wants to merge
15
commits into
main
Choose a base branch
from
worktree-consent-mode-unified
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
c60a00d
feat(registry): add defaultConsent + consentAdapter to consent-aware …
harlan-zw 5000b98
feat(consent): unified consentAdapter + defaultConsent for GTM / Mato…
harlan-zw fe1548b
feat(script): unified useScriptConsent composable
harlan-zw 787752a
merge: Scope A consent adapters (TikTok/Meta/GA/Bing/Clarity)
harlan-zw 392e173
merge: Scope A+ consent adapters (GTM/Matomo/Mixpanel/PostHog)
harlan-zw 6b15d18
merge: Scope B unified useScriptConsent composable
harlan-zw a36398c
fix: dedupe consent types after Scope B merge
harlan-zw bbb68d5
style: fix max-statements-per-line in consent-default test
harlan-zw 8ae9ff1
fix(consent): extract adapters to decoupled module so registry.ts sta…
harlan-zw 429a2fc
test(consent): remove unit tests needing nuxt runtime; hoist posthog …
harlan-zw 58303b8
fix(consent): address CodeRabbit review findings
harlan-zw 3b68b6c
fix(consent): tiktok hold defaultConsent + mixpanel stub opt-in/out
harlan-zw be0ad69
refactor(consent): vendor-native per-script consent API
harlan-zw c6161b1
fix(consent): resolve CI typecheck + lint errors
harlan-zw 8608c04
docs(tiktok): clarify consent method stubs + loader filename
harlan-zw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,108 +1,232 @@ | ||
| --- | ||
| title: Consent Management | ||
| description: Learn how to get user consent before loading scripts. | ||
| description: Gate scripts behind user consent and drive vendor-native consent APIs through a typed per-script `consent` object. | ||
| --- | ||
|
|
||
| ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"} | ||
| Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent) or [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent) on [StackBlitz](https://stackblitz.com). | ||
| :: | ||
|
|
||
| ## Background | ||
| ## Two complementary primitives | ||
|
|
||
| Many third-party scripts include tracking cookies that require user consent under privacy laws. Nuxt Scripts simplifies this process with the [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable, allowing scripts to load only after you receive user consent. | ||
| Nuxt Scripts ships two consent primitives that work together: | ||
|
|
||
| ## Usage | ||
| 1. **[`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent)**: a binary load gate. The script only starts loading after consent is granted. | ||
| 2. **Per-script `consent` object** returned from every consent-aware `useScriptX()`{lang="ts"}. A vendor-native, typed API for granting, revoking, or updating consent categories at runtime. Paired with each script's `defaultConsent` option for the initial policy applied *before* the vendor's init call. | ||
|
|
||
| The [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable offers flexible interaction options suitable for various scenarios. | ||
| Each vendor exposes its own consent dialect (Google Consent Mode v2 for GA/GTM/Bing, binary grant/revoke for Meta, three-state for TikTok, `setConsentGiven`/`forgetConsentGiven` for Matomo, `opt_in`/`opt_out` for Mixpanel/PostHog, cookie toggle for Clarity). You wire each explicitly. | ||
|
|
||
| See the [API](/docs/api/use-script-trigger-consent) docs for full details on the available options. | ||
| ## Binary load gate | ||
|
|
||
| ### Accepting as a Function | ||
|
|
||
| The easiest way to make use of [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} is by invoking the `accept` method when user consent is granted. | ||
|
|
||
| For an example of how you might lay out your code to handle this, see the following: | ||
| The simplest usage matches the classic cookie-banner flow: load the script only after the user clicks accept. | ||
|
|
||
| ::code-group | ||
|
|
||
| ```ts [utils/cookie.ts] | ||
| export const agreedToCookiesScriptConsent = useScriptTriggerConsent() | ||
| export const scriptsConsent = useScriptTriggerConsent() | ||
| ``` | ||
|
|
||
| ```vue [app.vue] | ||
| <script setup lang="ts"> | ||
| import { agreedToCookiesScriptConsent } from '#imports' | ||
| import { scriptsConsent } from '#imports' | ||
|
|
||
| useScript('https://www.google-analytics.com/analytics.js', { | ||
| trigger: agreedToCookiesScriptConsent | ||
| trigger: scriptsConsent, | ||
| }) | ||
| </script> | ||
| ``` | ||
|
|
||
| ```vue [components/cookie-banner.vue] | ||
| <script setup lang="ts"> | ||
| import { agreedToCookiesScriptConsent } from '#imports' | ||
| import { scriptsConsent } from '#imports' | ||
| </script> | ||
|
|
||
| <template> | ||
| <button @click="agreedToCookiesScriptConsent.accept()"> | ||
| <button @click="scriptsConsent.accept()"> | ||
| Accept Cookies | ||
| </button> | ||
| </template> | ||
| ``` | ||
|
|
||
| :: | ||
|
|
||
| ### Accepting as a resolvable boolean | ||
| ### Reactive source | ||
|
|
||
| Alternatively, you can pass a reactive reference to the consent state to the [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable. This will automatically load the script when the consent state is `true`. | ||
| Pass a `Ref<boolean>`{lang="html"} if an external store owns the state. | ||
|
|
||
| ```ts | ||
| const agreedToCookies = ref(false) | ||
| useScript('https://www.google-analytics.com/analytics.js', { | ||
| trigger: useScriptTriggerConsent({ | ||
| consent: agreedToCookies | ||
| }) | ||
| }) | ||
| const consent = useScriptTriggerConsent({ consent: agreedToCookies }) | ||
| ``` | ||
|
|
||
| ### Revoking Consent | ||
| ### Revoking | ||
|
|
||
| You can revoke consent after it has been granted using the `revoke` method. Use the reactive `consented` ref to track the current consent state. | ||
|
|
||
| ```vue [components/cookie-banner.vue] | ||
| <script setup lang="ts"> | ||
| import { agreedToCookiesScriptConsent } from '#imports' | ||
| </script> | ||
| Consent revocation flips the reactive `consented` ref. Once the load-gate promise has resolved the script has loaded; watch `consented` if you need to tear down on revoke. | ||
|
|
||
| ```vue | ||
| <template> | ||
| <div v-if="agreedToCookiesScriptConsent.consented.value"> | ||
| <p>Cookies accepted</p> | ||
| <button @click="agreedToCookiesScriptConsent.revoke()"> | ||
| <div v-if="scriptsConsent.consented.value"> | ||
| <button @click="scriptsConsent.revoke()"> | ||
| Revoke Consent | ||
| </button> | ||
| </div> | ||
| <button v-else @click="agreedToCookiesScriptConsent.accept()"> | ||
| <button v-else @click="scriptsConsent.accept()"> | ||
| Accept Cookies | ||
| </button> | ||
| </template> | ||
| ``` | ||
|
|
||
| ### Delaying script load after consent | ||
| ### Delaying the load after consent | ||
|
|
||
| ```ts | ||
| const consent = useScriptTriggerConsent({ | ||
| consent: agreedToCookies, | ||
| postConsentTrigger: () => new Promise<void>(resolve => | ||
| setTimeout(resolve, 3000), | ||
| ), | ||
| }) | ||
| ``` | ||
|
|
||
| There may be instances where you want to trigger the script load after a certain event, only if the user has consented. | ||
| ## Per-script consent API | ||
|
|
||
| For this you can use the `postConsentTrigger`, which shares the same API as `trigger` from the [`useScript()`{lang="ts"}](/docs/api/use-script){lang="ts"} composable. | ||
| Every consent-aware `useScriptX()`{lang="ts"} returns a `consent` object typed to the vendor's native API. Combine it with `defaultConsent` for the initial policy (applied in `clientInit` before the vendor fires its first call) and call `consent.*` from your cookie banner to update. | ||
|
|
||
| ```ts | ||
| const agreedToCookies = ref(false) | ||
| useScript('https://www.google-analytics.com/analytics.js', { | ||
| trigger: useScriptTriggerConsent({ | ||
| consent: agreedToCookies, | ||
| // load 3 seconds after consent is granted | ||
| postConsentTrigger: () => new Promise<void>(resolve => | ||
| setTimeout(resolve, 3000), | ||
| ), | ||
| const { consent } = useScriptGoogleAnalytics({ | ||
| id: 'G-XXXXXXXX', | ||
| defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' }, | ||
| }) | ||
|
|
||
| function onAcceptAll() { | ||
| consent.update({ | ||
| ad_storage: 'granted', | ||
| ad_user_data: 'granted', | ||
| ad_personalization: 'granted', | ||
| analytics_storage: 'granted', | ||
| }) | ||
| } | ||
| ``` | ||
|
|
||
| ### Per-vendor surface | ||
|
|
||
| | Script | `defaultConsent` | Runtime `consent.*` | | ||
| |---|---|---| | ||
| | Google Analytics | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} | | ||
| | Google Tag Manager | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} | | ||
| | Bing UET | `{ ad_storage }` | `consent.update({ ad_storage })`{lang="ts"} | | ||
| | Meta Pixel | `'granted' \| 'denied'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} | | ||
| | TikTok Pixel | `'granted' \| 'denied' \| 'hold'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} / `consent.hold()`{lang="ts"} | | ||
| | Matomo | `'required' \| 'given' \| 'not-required'` | `consent.give()`{lang="ts"} / `consent.forget()`{lang="ts"} *(requires `defaultConsent: 'required'` or `'given'`)* | | ||
| | Mixpanel | `'opt-in' \| 'opt-out'` | `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} | | ||
| | PostHog | `'opt-in' \| 'opt-out'` | `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} | | ||
| | Clarity | `boolean \| Record<string, string>`{lang="html"} | `consent.set(value)`{lang="ts"} | | ||
|
|
||
| See each script's registry page for notes on lossy projections and vendor caveats. | ||
|
|
||
| ### Fanning out to multiple scripts | ||
|
|
||
| When one cookie banner drives several vendors, wire them explicitly in your accept handler. No magic, fully typed, no lossy remapping: | ||
|
|
||
| ```ts | ||
| const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) | ||
| const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) | ||
| const matomo = useScriptMatomoAnalytics({ cloudId: 'foo.matomo.cloud', defaultConsent: 'required' }) | ||
|
|
||
| function onAcceptAll() { | ||
| ga.consent.update({ | ||
| ad_storage: 'granted', | ||
| ad_user_data: 'granted', | ||
| ad_personalization: 'granted', | ||
| analytics_storage: 'granted', | ||
| }) | ||
| meta.consent.grant() | ||
| matomo.consent.give() | ||
| } | ||
|
|
||
| function onDeclineAll() { | ||
| meta.consent.revoke() | ||
| matomo.consent.forget() | ||
| } | ||
| ``` | ||
|
|
||
| ### Granular categories | ||
|
|
||
| If users can toggle categories individually (analytics, marketing, functional), the same pattern applies; each script gets only the categories it understands: | ||
|
|
||
| ```ts | ||
| function savePreferences(choices: { analytics: boolean, marketing: boolean }) { | ||
| ga.consent.update({ | ||
| analytics_storage: choices.analytics ? 'granted' : 'denied', | ||
| ad_storage: choices.marketing ? 'granted' : 'denied', | ||
| ad_user_data: choices.marketing ? 'granted' : 'denied', | ||
| ad_personalization: choices.marketing ? 'granted' : 'denied', | ||
| }) | ||
| if (choices.marketing) | ||
| meta.consent.grant() | ||
| else meta.consent.revoke() | ||
| if (choices.analytics) | ||
| matomo.consent.give() | ||
| else matomo.consent.forget() | ||
| } | ||
| ``` | ||
|
|
||
| ## Third-party CMP recipes | ||
|
|
||
| When a dedicated Consent Management Platform owns the UI, bridge its events into each script's `consent` API. | ||
|
|
||
| ### OneTrust | ||
|
|
||
| ```ts | ||
| const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) | ||
| const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) | ||
|
|
||
| onNuxtReady(() => { | ||
| function apply() { | ||
| const groups = (window as any).OnetrustActiveGroups as string | undefined | ||
| if (!groups) | ||
| return | ||
| const analytics = groups.includes('C0002') | ||
| const marketing = groups.includes('C0004') | ||
| ga.consent.update({ | ||
| analytics_storage: analytics ? 'granted' : 'denied', | ||
| ad_storage: marketing ? 'granted' : 'denied', | ||
| ad_user_data: marketing ? 'granted' : 'denied', | ||
| ad_personalization: marketing ? 'granted' : 'denied', | ||
| }) | ||
| if (marketing) | ||
| meta.consent.grant() | ||
| else meta.consent.revoke() | ||
| } | ||
|
|
||
| apply() | ||
| window.addEventListener('OneTrustGroupsUpdated', apply) | ||
| }) | ||
| ``` | ||
|
|
||
| ### Cookiebot | ||
|
|
||
| ```ts | ||
| const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) | ||
| const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) | ||
|
|
||
| onNuxtReady(() => { | ||
| function apply() { | ||
| const cb = (window as any).Cookiebot | ||
| if (!cb?.consent) | ||
| return | ||
| ga.consent.update({ | ||
| analytics_storage: cb.consent.statistics ? 'granted' : 'denied', | ||
| ad_storage: cb.consent.marketing ? 'granted' : 'denied', | ||
| ad_user_data: cb.consent.marketing ? 'granted' : 'denied', | ||
| ad_personalization: cb.consent.marketing ? 'granted' : 'denied', | ||
| }) | ||
| if (cb.consent.marketing) | ||
| meta.consent.grant() | ||
| else meta.consent.revoke() | ||
| } | ||
|
|
||
| apply() | ||
| window.addEventListener('CookiebotOnAccept', apply) | ||
| window.addEventListener('CookiebotOnDecline', apply) | ||
| }) | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
OneTrust OnetrustActiveGroups OneTrustGroupsUpdated JavaScript API 2024💡 Result:
OnetrustActiveGroups and OneTrustGroupsUpdated are key elements of the OneTrust Web CMP JavaScript API for managing user consent groups. OnetrustActiveGroups is a global data layer variable (window.OnetrustActiveGroups) that contains a comma-separated string of IDs for active consent groups (those with user consent or always active). It is populated after script initialization and updated on consent changes. Example usage for Google Consent Mode: window.OnetrustActiveGroups.includes(',C0002,') ? 'granted' : 'denied' OneTrustGroupsUpdated is a window event triggered on script load/refresh and when user consent preferences update. It provides an array of active group IDs via event.detail. window.addEventListener("OneTrustGroupsUpdated", event => { console.log(Active groups: ${event.detail.join(", ")}); }); These are used together for real-time consent-aware logic, such as enabling/disabling trackers. Official docs confirm this as of 2024 references in related pages. No specific "OneTrust OnetrustActiveGroups OneTrustGroupsUpdated JavaScript API 2024" endpoint; these are client-side CMP features.
Citations:
🌐 Web query:
Cookiebot consent object JavaScript API 2024 CookiebotOnAccept CookiebotOnDecline💡 Result:
The Cookiebot CMP (Consent Management Platform) provides a JavaScript API via the global 'Cookiebot' object, loaded by including the script https://consent.cookiebot.com/uc.js?cbid=YOUR_CBID (replace YOUR_CBID with your domain group ID from your Cookiebot account). Key elements: Consent Object (Cookiebot.consent): Read-only properties indicating user consent for cookie categories: - consent.necessary (bool, default true): Necessary cookies accepted. - consent.preferences (bool, default false): Preferences cookies accepted. - consent.statistics (bool, default false): Statistics cookies accepted. - consent.marketing (bool, default false): Marketing cookies accepted. - consented (bool, default false): Any cookies accepted. - declined (bool, default false): Cookies declined. - hasResponse (bool): User has responded. - method (string): 'implied' or 'explicit' consent method. Methods: - Cookiebot.show: Show consent dialog. - Cookiebot.renew: Renew/change consent (resets and shows dialog). - Cookiebot.runScripts: Execute blocked scripts based on consent (for dynamic content). - Cookiebot.getScript(url, [async], [callback]): Load external script respecting consent. Events (use window.addEventListener): - CookiebotOnAccept: Fires when user accepts cookies (also on page load if previously accepted). - CookiebotOnDecline: Fires when user declines cookies (also on page load if previously declined). - CookiebotOnLoad: When consent loaded (not script load). - CookiebotOnConsentReady: Consent state ready. - CookiebotOnDialogInit: Dialog initialized. Callbacks (define global functions before script loads): - CookiebotCallback_OnAccept: Called on accept. - CookiebotCallback_OnDecline: Called on decline. - CookiebotCallback_OnLoad: Called when consent loaded. Example usage for OnAccept (load GA if statistics consented): window.addEventListener('CookiebotOnAccept', function { if (Cookiebot.consent.statistics) { // Load Google Analytics } }); function CookiebotCallback_OnDecline { // Handle decline } This API remains consistent as of 2025-2026 sources from official Cookiebot developer pages and support docs. Always check consent before loading trackers: if (Cookiebot.consent.statistics) { ... }. For server-side, parse CookieConsent cookie.
Citations:
Fix OneTrust group ID matching pattern.
The Cookiebot implementation correctly uses the
Cookiebot.consentobject structure (statistics, marketing properties) and listens to the proper events (CookiebotOnAccept/CookiebotOnDecline).However, the OneTrust code uses an unsafe string matching pattern. Since
OnetrustActiveGroupsis a comma-separated string of group IDs,groups.includes('C0002')can produce false positives if another group ID contains 'C0002' as a substring (e.g., 'C00020'). Use comma delimiters instead:Recommended OneTrust pattern
🤖 Prompt for AI Agents