@consenti/ui 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -98
- package/dist/angular.d.cts +335 -43
- package/dist/angular.d.ts +335 -43
- package/dist/angular.js +1 -1
- package/dist/angular.js.map +1 -1
- package/dist/angular.mjs +1 -1
- package/dist/angular.mjs.map +1 -1
- package/dist/chunk-RDE7CM6T.mjs +2 -0
- package/dist/chunk-RDE7CM6T.mjs.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +386 -42
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/profiles-5ZLWWN2L.mjs +2 -0
- package/dist/react.d.cts +335 -43
- package/dist/react.d.ts +335 -43
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/testing.mjs.map +1 -1
- package/dist/vue.d.cts +335 -43
- package/dist/vue.d.ts +335 -43
- package/dist/vue.js +1 -1
- package/dist/vue.js.map +1 -1
- package/dist/vue.mjs +1 -1
- package/dist/vue.mjs.map +1 -1
- package/package.json +12 -8
- package/dist/chunk-J4BKXUD6.mjs +0 -2
- package/dist/chunk-J4BKXUD6.mjs.map +0 -1
- package/dist/profiles-OEZAKEWL.mjs +0 -2
- /package/dist/{profiles-OEZAKEWL.mjs.map → profiles-5ZLWWN2L.mjs.map} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @consenti/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
GDPR-style cookie consent widget for any web framework — zero required runtime dependencies.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@consenti/ui)
|
|
6
6
|
[](../../LICENSE)
|
|
@@ -12,12 +12,15 @@ Zero-dependency, GDPR-compliant cookie consent widget for any web framework.
|
|
|
12
12
|
|
|
13
13
|
- **Zero runtime dependencies** — browser built-ins only (`fetch`, `crypto.subtle`, `BroadcastChannel`, `CustomEvent`, `document.cookie`, `localStorage`)
|
|
14
14
|
- **Fully standalone or API-connected** — works offline with pre-built profiles, or connects to `@consenti/api` for dashboard-managed profiles
|
|
15
|
-
- **Multi-regulation** — GDPR, CCPA, CPRA, LGPD
|
|
15
|
+
- **Multi-regulation** — Maintained: GDPR, CCPA, CPRA, LGPD. Partial: TCF v2.3 (real binary encoding requires the `@consenti/api` backend) — only relevant if you monetize through programmatic/RTB ad exchanges. In development: DPDPA. More via 8 built-in compliance groups — see [ECOSYSTEM.md](../../ECOSYSTEM.md) for per-regulation status
|
|
16
|
+
|
|
17
|
+
> **Registration is only needed for programmatic ad monetization.** For first-party analytics/marketing consent, Consenti is fully compliant with zero external registration. Consenti implements the IAB TCF/GPP technical specs but is **not** itself a registered CMP; if you do need TCF/GPP, you must register your own `cmpId` with IAB Europe/MSPA independently before going live — see the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration).
|
|
16
18
|
- **Automatic geo-resolution** — detects visitor jurisdiction client-side (timezone + language) or server-side (`/resolve-profile`)
|
|
17
19
|
- **GPC aware** — Global Privacy Control signal detection with `'honor'` / `'strict'` / `'ignore'` modes per profile
|
|
18
20
|
- **GTM / Google Consent Mode v2** — built-in `dataLayer` integration
|
|
19
21
|
- **SSR-safe** — all browser API access is guarded; `new ConsentiSetup()` during SSR is a silent no-op
|
|
20
22
|
- **Fully typed** — ships TypeScript definitions; no `@types` needed
|
|
23
|
+
- **Companion CLI**: [`@consenti/scanner`](../scanner) crawls a site under none/reject-all/accept-all consent states and reports undeclared third-party trackers
|
|
21
24
|
|
|
22
25
|
---
|
|
23
26
|
|
|
@@ -35,17 +38,15 @@ ES2020+ · Chrome 80+ · Firefox 74+ · Safari 13.1+
|
|
|
35
38
|
npm install @consenti/ui
|
|
36
39
|
```
|
|
37
40
|
|
|
38
|
-
Then import
|
|
41
|
+
Then import the class:
|
|
39
42
|
|
|
40
43
|
```ts
|
|
41
44
|
import { ConsentiSetup } from '@consenti/ui'
|
|
42
|
-
import '@consenti/ui/dist/index.css'
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
### CDN / UMD (no build step)
|
|
46
48
|
|
|
47
49
|
```html
|
|
48
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.css" />
|
|
49
50
|
<script src="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.umd.js"></script>
|
|
50
51
|
<script>
|
|
51
52
|
const { ConsentiSetup } = ConsentiUI
|
|
@@ -63,12 +64,15 @@ import '@consenti/ui/dist/index.css'
|
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
### CSS options
|
|
67
|
+
Choose any approach to load stylesheet, by default it will be auto injected
|
|
66
68
|
|
|
67
69
|
| Approach | How |
|
|
68
70
|
|-----------------------------------|-----------------------------------------------------------------------|
|
|
69
|
-
|
|
|
71
|
+
| Auto Injection (recommended) | by default consenti will inject css |
|
|
72
|
+
| Manual injection (avoids FOUC) | `import '@consenti/ui/dist/index.css'` in your bundler entry or code and set `disableCssTemplate: true;` |
|
|
70
73
|
| Skip all CSS (bring your own) | Set `core.disableCssTemplate: true` — no styles injected |
|
|
71
74
|
| CSS custom properties only | Import CSS, then override `--consenti-*` variables in your stylesheet |
|
|
75
|
+
| core.theme config in ConsentiSetup| Provide css variables for your stylesheet in the setup as js object |
|
|
72
76
|
|
|
73
77
|
---
|
|
74
78
|
|
|
@@ -107,21 +111,30 @@ A consent banner appears on first visit. Everything below is optional.
|
|
|
107
111
|
|
|
108
112
|
```ts
|
|
109
113
|
import { ConsentiSetup } from '@consenti/ui'
|
|
110
|
-
import '@consenti/ui/dist/index.css'
|
|
111
114
|
|
|
112
115
|
const widget = new ConsentiSetup({
|
|
113
116
|
// ── Core (optional) ──────────────────────────────────────────────────────────
|
|
114
117
|
core: {
|
|
115
118
|
tenantId: 'acme', // identifies your tenant's profiles; default: 'default'
|
|
116
119
|
locale: 'en', // BCP 47; 'auto' = navigator.language; default: 'auto'
|
|
117
|
-
dir: 'auto', // 'ltr' | 'rtl' | 'auto'; 'auto' derives from locale
|
|
118
|
-
// (
|
|
120
|
+
dir: 'auto', // 'ltr' | 'rtl' | 'auto'; 'auto' derives from locale via
|
|
121
|
+
// Intl.Locale(...).getTextInfo().direction (falls back to a
|
|
122
|
+
// hand-maintained ar/he/fa/ur/ps/sd/ug/yi → rtl list on
|
|
123
|
+
// engines without getTextInfo() support); default: 'auto'
|
|
119
124
|
storage: 'cookie', // 'cookie' | 'localStorage'; default: 'cookie'
|
|
125
|
+
cookieName: 'consenti_data', // cookie/localStorage key name; default: 'consenti_data'
|
|
126
|
+
// not switched automatically by detected region — set
|
|
127
|
+
// explicitly if you want a different name (e.g. 'euconsent-v2',
|
|
128
|
+
// only meaningful if you're using the spec-correct binary TCF
|
|
129
|
+
// encoder — @consenti/api + @iabtechlabtcf/core — since that
|
|
130
|
+
// name implies IAB's binary format, not Consenti's own encoding)
|
|
120
131
|
cookieDomains: '.example.com', // comma-separated; first entry used as Domain attribute
|
|
121
132
|
cookieSigningKey: 'min-32-char-secret', // HMAC-SHA256 signing; implicit from presence
|
|
122
133
|
allowReceipt: true, // allow consent receipt download; default: false
|
|
123
134
|
disableCssTemplate: false, // skip all style injection; default: false
|
|
124
135
|
userId: 'server-assigned-uuid', // authenticated users — enables cross-device sync
|
|
136
|
+
// prefer widget.getUserId()/setUserId() (or the
|
|
137
|
+
// consenti:listener:identify event) to change it after init
|
|
125
138
|
|
|
126
139
|
// Pre-built profile fallback
|
|
127
140
|
usePrebuiltProfiles: 'all', // 'all' (default) | ['opt-in', 'opt-out', ...] (non-empty)
|
|
@@ -131,24 +144,31 @@ const widget = new ConsentiSetup({
|
|
|
131
144
|
console: ['error'], // log levels emitted: 'info' | 'log' | 'warning' | 'error'
|
|
132
145
|
// default: ['error'] only — no noise in production
|
|
133
146
|
|
|
147
|
+
// Field names mirror their --consenti-* CSS variable literally (--consenti-color-primary
|
|
148
|
+
// → colorPrimary). Every field is optional and maps 1:1 to one CSS custom property — see
|
|
149
|
+
// apps/ui/src/styles/_variables.scss for the full set of ~33 variables. Common ones:
|
|
134
150
|
theme: {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
colorBg: '#ffffff',
|
|
152
|
+
colorText: '#1a1a1a',
|
|
153
|
+
colorTextMuted: '#949dab',
|
|
154
|
+
colorPrimary: '#1565c0',
|
|
155
|
+
colorPrimaryText: '#ffffff',
|
|
156
|
+
colorSecondary: '#f0f4f8',
|
|
157
|
+
colorSecondaryText: '#1a3460',
|
|
158
|
+
colorBorder: '#e2e8f0',
|
|
159
|
+
colorAccent: '#d32f2f',
|
|
160
|
+
colorAccentText: '#ffffff',
|
|
144
161
|
fontFamily: 'system-ui, sans-serif',
|
|
145
162
|
fontSizeBase: '14px',
|
|
146
163
|
fontSizeHeading: '18px',
|
|
147
164
|
fontSizeMultiplier: '1',
|
|
148
165
|
borderRadius: '8px',
|
|
149
|
-
|
|
166
|
+
borderRadiusBtn: '4px',
|
|
150
167
|
toggleBgOn: '#1565c0',
|
|
151
168
|
toggleBgOff: '#cccccc',
|
|
169
|
+
// ...plus colorSecondaryBorder, colorOverlay, fontFamilyMono, fontWeightHeading,
|
|
170
|
+
// lineHeight, spacingXs/Sm/Md/Lg, shadow, toggleBgPartial, toggleKnob,
|
|
171
|
+
// toggleWidth/Height, zBanner/Overlay/Modal
|
|
152
172
|
},
|
|
153
173
|
},
|
|
154
174
|
|
|
@@ -166,12 +186,12 @@ const widget = new ConsentiSetup({
|
|
|
166
186
|
// WidgetCountryResolverFn → custom async function: () => Promise<{ country, region, confidence }>
|
|
167
187
|
geoDataProvider: 'default',
|
|
168
188
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
// Only meaningful when api.enabled = false (ignored, with a warning, otherwise — the server
|
|
190
|
+
// resolves the group in that mode). Only overrides country→group mapping; country/region
|
|
191
|
+
// detection above always uses the embedded geo data.
|
|
192
|
+
// 'default' → embedded map (default) | a URL string (fetched; the browser's own HTTP cache
|
|
193
|
+
// honors whatever Cache-Control/ETag the response sends) | an inline ComplianceMapData object
|
|
194
|
+
complianceMap: 'default',
|
|
175
195
|
|
|
176
196
|
// TCF (IAB Transparency & Consent Framework) client stub (optional) — see "TCF" section below
|
|
177
197
|
tcf: {
|
|
@@ -179,6 +199,16 @@ const widget = new ConsentiSetup({
|
|
|
179
199
|
cmpId: 0, // must match the cmpId configured on the backend (TcfConfig)
|
|
180
200
|
cmpVersion: 1,
|
|
181
201
|
},
|
|
202
|
+
|
|
203
|
+
// GPP (IAB Global Privacy Platform, US National section) client stub (optional) — see "GPP" section below
|
|
204
|
+
gpp: {
|
|
205
|
+
enabled: false,
|
|
206
|
+
cmpId: 0, // must match the cmpId configured on the backend (GppConfig)
|
|
207
|
+
cmpVersion: 1,
|
|
208
|
+
mspaCoveredTransaction: false, // no honest default — required
|
|
209
|
+
mspaOptOutOptionMode: 0, // 0 = not applicable | 1 = yes | 2 = no
|
|
210
|
+
mspaServiceProviderMode: 0,
|
|
211
|
+
},
|
|
182
212
|
},
|
|
183
213
|
|
|
184
214
|
// ── Mount point (optional) ────────────────────────────────────────────────
|
|
@@ -242,6 +272,47 @@ profileOverride: {
|
|
|
242
272
|
no-op (same as omitting the key), the latter merges an empty object onto the existing category,
|
|
243
273
|
changing nothing. Only an explicit `null` removes it.
|
|
244
274
|
|
|
275
|
+
#### Replacing a whole map: `'*': null`
|
|
276
|
+
|
|
277
|
+
Removing entries one by one doesn't scale when you want to *replace* a map: every built-in
|
|
278
|
+
profile uses different button ids (`reject-optional`, `save-choices`, `manage-preferences`, …),
|
|
279
|
+
and there are three `buttons` maps (`mainBanner`, `gpcBanner`, `preferenceModal`). Use the
|
|
280
|
+
wildcard key `'*': null` to delete **every key of the base map that your override doesn't name**:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
profileOverride: {
|
|
284
|
+
mainBanner: {
|
|
285
|
+
buttons: {
|
|
286
|
+
'*': null, // drop all other built-in buttons
|
|
287
|
+
'accept-all': { text: 'Allow all' }, // kept — only its text changes
|
|
288
|
+
'my-button': { text: 'Custom', style: 'secondary', action: 'custom', cookies: '!' },
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
preferenceModal: {
|
|
292
|
+
buttons: { '*': null, 'my-save': { text: 'Save', style: 'primary', action: 'submit' } },
|
|
293
|
+
categories: { '*': null, necessary: {}, analytics: {} }, // keep only these two categories
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- Keys you **name** are kept and merged onto their base value (so `'accept-all': { text }` keeps
|
|
299
|
+
its `style`/`action`/`cookies`); keys you **don't name** are deleted; new keys are added.
|
|
300
|
+
- It works per map, so you choose which surfaces to replace — leave `gpcBanner` alone and it keeps
|
|
301
|
+
its built-in buttons.
|
|
302
|
+
- `'*': null` alone empties the map. It works on any keyed map (`cookies` too), and under
|
|
303
|
+
`complianceGroupsOverride` and `widget.setProfile()`.
|
|
304
|
+
- `'*'` is reserved in these maps and can't be used as a button/category id.
|
|
305
|
+
- `widget.setProfile()` patches are replayed in order on the resolved profile (rather than being
|
|
306
|
+
folded together first), so `null` and `'*': null` deletes in a later `setProfile()` call are
|
|
307
|
+
honoured even after an earlier override.
|
|
308
|
+
|
|
309
|
+
> **Compliance:** refusing must be as easy as accepting (GDPR Art. 7(3); CNIL/EDPB cookie-banner
|
|
310
|
+
> guidance). If, after overrides, a `mainBanner`/`gpcBanner` has an "accept all" button
|
|
311
|
+
> (`cookies: '*'`) but no reject (`cookies: '!'`) or `manage` button, the widget logs a
|
|
312
|
+
> `console.warn`. Warnings are hidden by default — enable them with `verbose: true` or
|
|
313
|
+
> `core: { console: ['warning'] }`. Keep a reject or manage path when
|
|
314
|
+
> you replace the built-in buttons.
|
|
315
|
+
|
|
245
316
|
---
|
|
246
317
|
|
|
247
318
|
## Compliance Groups
|
|
@@ -309,30 +380,67 @@ Set `api.trustDomain: true` to bypass this check (useful for localhost / dev env
|
|
|
309
380
|
|
|
310
381
|
GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config profileOverrides overrides the profile value when explicitly set.
|
|
311
382
|
|
|
383
|
+
`gpcMode` only applies once the widget itself has loaded and initialized. If a tag-loading script
|
|
384
|
+
sits earlier in `<head>` (GTM/gtag.js, or another script), it can still fire before that. Use
|
|
385
|
+
`buildSyncGpcSnippet()` to close that gap — it returns a tiny, dependency-free `<script>` string to
|
|
386
|
+
place first in `<head>`, ahead of everything else, that checks `navigator.globalPrivacyControl`
|
|
387
|
+
synchronously and pre-freezes Google Consent Mode v2 to denied:
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
import { buildSyncGpcSnippet } from '@consenti/ui'
|
|
391
|
+
|
|
392
|
+
buildSyncGpcSnippet() // default dataLayer name
|
|
393
|
+
buildSyncGpcSnippet({ dataLayerName: 'myDataLayer' }) // custom dataLayer name
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Safe to run before Consenti's own `gtm` config pushes its defaults on init — same values, same
|
|
397
|
+
stub-queue, so the second push is a no-op rather than a conflict. Full walkthrough with the
|
|
398
|
+
`<head>` ordering: [Advanced Configuration → GPC](https://consenti.dev/docs/ui/advanced-configuration).
|
|
399
|
+
|
|
312
400
|
---
|
|
313
401
|
|
|
314
402
|
## Age Gate
|
|
315
403
|
|
|
316
|
-
|
|
317
|
-
|
|
404
|
+
Age gate is a **per-profile, per-locale** setting — not a widget-config option. GDPR Article 8's
|
|
405
|
+
consent age varies 13–16 by EU member state and DPDPA has its own child-data age rules, so one
|
|
406
|
+
global age doesn't fit a multi-region deployment. Enable it on a profile in the dashboard's Profile
|
|
407
|
+
Editor Step 1 (minimum age, optional parental-consent requirement), and author the modal's
|
|
408
|
+
heading/body/button text per locale in the Main Banner content step — the widget reads it off the
|
|
409
|
+
resolved profile (`profile.ageGate`/`profile.ageGateModal`) automatically, no widget config needed.
|
|
410
|
+
For a standalone/local profile (`registerProfile()`), set `ageGate`/`ageGateModal` directly on the
|
|
411
|
+
`EmbeddedProfile`/`EmbeddedTranslations` you register.
|
|
318
412
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
compliance: {
|
|
322
|
-
ageGate: { enabled: true, minimumAge: 16, requireParentalConsent: true },
|
|
323
|
-
},
|
|
324
|
-
})
|
|
325
|
-
```
|
|
413
|
+
When enabled, it blocks every other consent UI (banner, GPC, CCPA opt-out — all of it waits behind
|
|
414
|
+
the age gate on first visit):
|
|
326
415
|
|
|
327
416
|
- **Confirmed** (visitor is old enough) → the normal banner/GPC/CCPA flow proceeds exactly as if
|
|
328
417
|
the age gate didn't exist, and every consent submission from then on carries `ageVerified: true`.
|
|
329
418
|
- **Declined, `requireParentalConsent: false`** → a deny-all consent is submitted immediately
|
|
330
419
|
(mandatory/strictly-necessary cookies still granted), no banner shown, `ageVerified: false`.
|
|
331
420
|
- **Declined, `requireParentalConsent: true`** → same deny-all submission, plus a
|
|
332
|
-
`parentalConsentToken` is
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
`
|
|
421
|
+
`parentalConsentToken` is requested from the backend (`POST /consent/:visitorId/parental-consent-request`,
|
|
422
|
+
signed with the backend's `compliance.dataSigningHash` when configured — falls back to a
|
|
423
|
+
client-generated, unsigned token if `api.enabled` is false) and a
|
|
424
|
+
`consenti:parentalConsentRequired` event fires carrying it. There's no email-sending pipeline
|
|
425
|
+
built into this package — the event (or your backend's `eventBus.on('consent.parentalConsentRequired', ...)`
|
|
426
|
+
listener, see the `@consenti/api` README's "Parental consent" section) is the hook for wiring
|
|
427
|
+
your own out-of-band verification process.
|
|
428
|
+
|
|
429
|
+
When the parent later completes that process (often on a fresh page with no live widget instance —
|
|
430
|
+
their own device, not the child's), resolve the token with the standalone `resolveParentalConsent`
|
|
431
|
+
export instead of a `ConsentiSetup` method:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
import { resolveParentalConsent } from '@consenti/ui'
|
|
435
|
+
|
|
436
|
+
const { visitorId, profileId } = await resolveParentalConsent(token, { baseUrl: 'https://consent.example.com' })
|
|
437
|
+
// dispatches consenti:parentalConsentResolved on window — a page with a live widget instance
|
|
438
|
+
// can listen for it and react (e.g. re-show the banner for the real consent choice); what
|
|
439
|
+
// "resolved" should actually do to consent state is host-defined, same as the request side.
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
This is plumbing, not verifiable parental consent by itself — see the `@consenti/api` README for
|
|
443
|
+
the full caveats (token replay isn't prevented, staying stateless is a deliberate tradeoff).
|
|
336
444
|
|
|
337
445
|
The prompt only asks once per visitor per profile — it doesn't re-appear once any consent record
|
|
338
446
|
exists (same "already decided" check the banner itself uses).
|
|
@@ -359,9 +467,50 @@ may own `window.__tcfapi` per page — if it's already set (a real CMP, or anoth
|
|
|
359
467
|
instance on a multi-profile page), this stub does not overwrite it.
|
|
360
468
|
|
|
361
469
|
This is a simplified implementation: `tcString` is a base64url-encoded JSON payload (the same
|
|
362
|
-
format the backend's `tcfString` uses), not the full IAB binary bitfield encoding, and
|
|
363
|
-
metadata (`gvlVersion`, `publisherCC`) is
|
|
364
|
-
client-side
|
|
470
|
+
default format the backend's `tcfString` uses), not the full IAB binary bitfield encoding, and
|
|
471
|
+
vendor-list metadata (`gvlVersion`, `publisherCC`) is operator-supplied via `TcfWidgetConfig`
|
|
472
|
+
rather than fetched client-side (the GVL is multi-megabyte; IAB policy expects CMPs to cache their
|
|
473
|
+
own copy server-side rather than have every visitor's browser re-fetch it). `gdprApplies` is
|
|
474
|
+
derived automatically from the resolved profile's compliance group.
|
|
475
|
+
|
|
476
|
+
For spec-correct binary TC-string encoding, run with the `@consenti/api` backend, set
|
|
477
|
+
`compliance.tcf.publisherCC`, and install the optional `@iabtechlabtcf/core` peer dependency
|
|
478
|
+
(the actively-maintained IAB Tech Lab package — not `iabtcf-core`, which doesn't exist on npm).
|
|
479
|
+
See the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration) for details.
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## GPP — IAB Global Privacy Platform (US National)
|
|
484
|
+
|
|
485
|
+
Set `compliance.gpp.enabled: true` to install `window.__gpp`, the standard IAB entry point
|
|
486
|
+
scripts call for US state-privacy-law signals (`ping`, `addEventListener`, `removeEventListener`,
|
|
487
|
+
`getSection`, `hasSection`) — `usnat` (US National) section only:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
new ConsentiSetup({
|
|
491
|
+
compliance: {
|
|
492
|
+
gpp: {
|
|
493
|
+
enabled: true,
|
|
494
|
+
cmpId: 280, // must match the backend's GppConfig
|
|
495
|
+
cmpVersion: 1,
|
|
496
|
+
mspaCoveredTransaction: true, // whether this deployment falls under MSPA signatory obligations
|
|
497
|
+
mspaOptOutOptionMode: 1, // 0 = not applicable | 1 = yes | 2 = no
|
|
498
|
+
mspaServiceProviderMode: 0,
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
})
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Sale/sharing opt-out flags are derived automatically from the resolved profile's cookies tagged
|
|
505
|
+
`cpraCategory: 'sale'` / `'sharing'` and the visitor's actual consent — no separate config needed.
|
|
506
|
+
Like TCF, only one CMP may own `window.__gpp` per page; this stub does not overwrite an existing one,
|
|
507
|
+
and follows the standard stub-queue convention so load order relative to third-party scripts doesn't
|
|
508
|
+
matter.
|
|
509
|
+
|
|
510
|
+
Unlike TCF, GPP's US National section needs no Global Vendor List, so both this stub's `gppString`
|
|
511
|
+
and the backend's `gppString` use the same real, spec-correct encoder when the optional
|
|
512
|
+
`@iabgpp/cmpapi` peer dependency is installed on `@consenti/api` — there's no "simplified fallback"
|
|
513
|
+
format the way TCF has. Without it, `gppString` falls back to a base64url-encoded JSON payload.
|
|
365
514
|
|
|
366
515
|
---
|
|
367
516
|
|
|
@@ -433,7 +582,7 @@ const profile = new ConsentiProfile({
|
|
|
433
582
|
// key = machine id, rendered as the button's DOM id: `consenti-btn-{id}`, for targeting a specific button
|
|
434
583
|
// cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
|
|
435
584
|
'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
|
|
436
|
-
'reject-optional': { text: 'Reject Optional', style: '
|
|
585
|
+
'reject-optional': { text: 'Reject Optional', style: 'primary', action: 'custom', cookies: '!' }, // same weight as accept-all — CNIL requires reject not be visually subordinate
|
|
437
586
|
customize: { text: 'Customize', style: 'secondary', action: 'manage' },
|
|
438
587
|
'privacy-policy': { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
|
|
439
588
|
},
|
|
@@ -652,11 +801,19 @@ widget.deleteConsent() // delete consent record (cookie + backend if e
|
|
|
652
801
|
widget.reConsent() // delete consent and re-show banner
|
|
653
802
|
widget.destroy() // unmount DOM elements and remove all event listeners
|
|
654
803
|
|
|
804
|
+
// Logged-in user identity
|
|
805
|
+
widget.getUserId() // string | null — current application user ID
|
|
806
|
+
widget.setUserId('user-42') // reconsents (by default) if it differs from the stored cookie's user
|
|
807
|
+
widget.setUserId(null) // clear identity (e.g. logout)
|
|
808
|
+
widget.setUserId('user-42', false) // update identity without reconsenting
|
|
809
|
+
// Or dispatch from anywhere (e.g. right after your own login flow completes):
|
|
810
|
+
// window.dispatchEvent(new CustomEvent('consenti:listener:identify', { detail: { userId: 'user-42' } }))
|
|
811
|
+
|
|
655
812
|
// Runtime configuration
|
|
656
813
|
widget.setDarkMode() // toggle dark mode
|
|
657
814
|
widget.setDarkMode(true) // force dark on
|
|
658
815
|
widget.setDarkMode(false) // force light
|
|
659
|
-
widget.setTheme({
|
|
816
|
+
widget.setTheme({ colorPrimary: '#ff0000' }) // hot-swap CSS tokens (merges into current theme)
|
|
660
817
|
widget.setConfig({ darkMode: true }) // deep-merge partial config (no re-init)
|
|
661
818
|
widget.setProfile({ mainBanner: { heading: 'Updated' } }) // re-render UI with new profile data
|
|
662
819
|
|
|
@@ -743,6 +900,14 @@ Custom DOM events fire on `window` at every lifecycle step. All are prefixed `co
|
|
|
743
900
|
| `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) |
|
|
744
901
|
| `consenti:parentalConsentRequired` | Age gate declined with `requireParentalConsent: true` — carries `parentalConsentToken` |
|
|
745
902
|
|
|
903
|
+
The events above are **outbound** (widget → host). `consenti:listener:*` is the opposite
|
|
904
|
+
direction — **inbound** (host → widget), dispatched by your own code to tell the widget
|
|
905
|
+
something:
|
|
906
|
+
|
|
907
|
+
| Event | Dispatch when |
|
|
908
|
+
|--------------------------------|----------------------------------------------------------|
|
|
909
|
+
| `consenti:listener:identify` | Your own login/logout flow completes — `detail: { userId: string \| null, reConsent?: boolean }`, equivalent to calling `widget.setUserId(userId, reConsent)` |
|
|
910
|
+
|
|
746
911
|
**Recommended:** use `widget.on()` / `widget.off()` for typed subscriptions (the `consenti:` prefix is optional):
|
|
747
912
|
|
|
748
913
|
```ts
|
|
@@ -843,20 +1008,64 @@ new ConsentScript({
|
|
|
843
1008
|
})
|
|
844
1009
|
```
|
|
845
1010
|
|
|
846
|
-
###
|
|
1011
|
+
### ConsentAction — run a callback on a parameter's grant status
|
|
1012
|
+
|
|
1013
|
+
For integrations that expose their own `sdk.optIn()`/`sdk.optOut()` API (Segment, Mixpanel,
|
|
1014
|
+
Amplitude, Sentry, etc.) instead of a `<script>` tag — use `ConsentScript` for that case instead.
|
|
1015
|
+
|
|
1016
|
+
```ts
|
|
1017
|
+
import { ConsentAction } from '@consenti/ui'
|
|
1018
|
+
|
|
1019
|
+
new ConsentAction({
|
|
1020
|
+
id: 'analytics_storage',
|
|
1021
|
+
widget,
|
|
1022
|
+
onGrant: () => analyticsSdk.optIn(),
|
|
1023
|
+
onDeny: () => analyticsSdk.optOut(),
|
|
1024
|
+
// bind: true (default) — re-fires on every future consent change
|
|
1025
|
+
})
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
### CategoryScript / CategoryAction — gate on a whole category
|
|
1029
|
+
|
|
1030
|
+
Same as `ConsentScript`/`ConsentAction`, but keyed on a preference-modal category instead of a
|
|
1031
|
+
single parameter — "granted" only when *every* parameter in the category is `'granted'`.
|
|
1032
|
+
|
|
1033
|
+
```ts
|
|
1034
|
+
import { CategoryScript, CategoryAction } from '@consenti/ui'
|
|
1035
|
+
|
|
1036
|
+
new CategoryScript({ categoryId: 'marketing', widget, src: 'https://example.com/ad-pixel.js' })
|
|
1037
|
+
|
|
1038
|
+
new CategoryAction({
|
|
1039
|
+
id: 'marketing',
|
|
1040
|
+
widget,
|
|
1041
|
+
onGrant: () => adSdk.enableAll(),
|
|
1042
|
+
onDeny: () => adSdk.disableAll(),
|
|
1043
|
+
})
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
### scanConsentScripts — declarative `data-*` script gating
|
|
1047
|
+
|
|
1048
|
+
Zero-JS integration path: mark `<script>` tags with `data-consenti-consent-script` /
|
|
1049
|
+
`data-consenti-category-script` and Consenti scans and wires them up automatically at the end of
|
|
1050
|
+
every `init()` cycle (call `scanConsentScripts(widget)` again manually after adding tags at runtime).
|
|
1051
|
+
|
|
1052
|
+
```html
|
|
1053
|
+
<script type="text/plain" data-consenti-consent-script="analytics_storage" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"></script>
|
|
1054
|
+
<script type="text/plain" data-consenti-category-script="marketing" src="https://example.com/pixel.js"></script>
|
|
1055
|
+
<script type="text/plain" data-consenti-consent-script="ad_storage" data-consenti-bind="false">/* inline snippet, evaluated once */</script>
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
### BannerTrigger — open banner or modal from any element
|
|
847
1059
|
|
|
848
1060
|
```ts
|
|
849
|
-
import {
|
|
1061
|
+
import { BannerTrigger } from '@consenti/ui'
|
|
850
1062
|
|
|
851
1063
|
// Attach to existing element
|
|
852
|
-
new
|
|
1064
|
+
new BannerTrigger({ widget, el: '#cookie-settings', action: 'modal' })
|
|
853
1065
|
|
|
854
1066
|
// Auto-create a button
|
|
855
|
-
new
|
|
856
|
-
|
|
857
|
-
label: 'Manage Cookies',
|
|
858
|
-
appendTo: document.querySelector('#footer'),
|
|
859
|
-
})
|
|
1067
|
+
const trigger = new BannerTrigger({ widget, action: 'modal', label: 'Manage Cookies' })
|
|
1068
|
+
document.querySelector('#footer')?.appendChild(trigger.getElement())
|
|
860
1069
|
```
|
|
861
1070
|
|
|
862
1071
|
---
|
|
@@ -870,41 +1079,50 @@ Override any token in your own stylesheet:
|
|
|
870
1079
|
```css
|
|
871
1080
|
:root {
|
|
872
1081
|
/* Colors */
|
|
873
|
-
--consenti-
|
|
874
|
-
--consenti-
|
|
875
|
-
--consenti-
|
|
876
|
-
--consenti-
|
|
877
|
-
--consenti-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
--consenti-
|
|
881
|
-
--consenti-
|
|
882
|
-
--consenti-
|
|
883
|
-
--consenti-
|
|
884
|
-
--consenti-
|
|
885
|
-
--consenti-btn-padding: 0.5rem 1.25rem;
|
|
886
|
-
--consenti-btn-font-weight: 600;
|
|
887
|
-
|
|
888
|
-
/* Banner */
|
|
889
|
-
--consenti-banner-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
|
|
890
|
-
--consenti-banner-radius: 0;
|
|
891
|
-
--consenti-banner-padding: 1.5rem;
|
|
892
|
-
--consenti-banner-max-width: 960px;
|
|
893
|
-
|
|
894
|
-
/* Modal */
|
|
895
|
-
--consenti-modal-radius: 12px;
|
|
896
|
-
--consenti-modal-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
|
|
897
|
-
--consenti-modal-width: 560px;
|
|
898
|
-
--consenti-modal-max-height: 80vh;
|
|
899
|
-
|
|
900
|
-
/* Toggle */
|
|
901
|
-
--consenti-toggle-on: #1565c0;
|
|
902
|
-
--consenti-toggle-off: #ccc;
|
|
1082
|
+
--consenti-color-bg: #ffffff;
|
|
1083
|
+
--consenti-color-text: #1a2e4a;
|
|
1084
|
+
--consenti-color-text-muted: #949dab;
|
|
1085
|
+
--consenti-color-primary: #04111f;
|
|
1086
|
+
--consenti-color-primary-text: #ffffff;
|
|
1087
|
+
--consenti-color-secondary: #f0f4f8;
|
|
1088
|
+
--consenti-color-secondary-text: #1a2e4a;
|
|
1089
|
+
--consenti-color-border: #dbe4ee;
|
|
1090
|
+
--consenti-color-secondary-border: #1a2e4a;
|
|
1091
|
+
--consenti-color-overlay: #04111f;
|
|
1092
|
+
--consenti-color-accent: #d32f2f;
|
|
1093
|
+
--consenti-color-accent-text: #ffffff;
|
|
903
1094
|
|
|
904
1095
|
/* Typography */
|
|
905
|
-
--consenti-font-size-base: 14px;
|
|
906
|
-
--consenti-font-size-mult: 1;
|
|
907
1096
|
--consenti-font-family: system-ui, -apple-system, sans-serif;
|
|
1097
|
+
--consenti-font-family-mono: ui-monospace, monospace;
|
|
1098
|
+
--consenti-font-size-base: 14px;
|
|
1099
|
+
--consenti-font-size-heading: 16px;
|
|
1100
|
+
--consenti-font-weight-heading: 600;
|
|
1101
|
+
--consenti-line-height: 1.5;
|
|
1102
|
+
|
|
1103
|
+
/* Spacing */
|
|
1104
|
+
--consenti-spacing-xs: 5px;
|
|
1105
|
+
--consenti-spacing-sm: 8px;
|
|
1106
|
+
--consenti-spacing-md: 16px;
|
|
1107
|
+
--consenti-spacing-lg: 24px;
|
|
1108
|
+
|
|
1109
|
+
/* Shape */
|
|
1110
|
+
--consenti-border-radius: 8px;
|
|
1111
|
+
--consenti-border-radius-btn: 0;
|
|
1112
|
+
--consenti-shadow: 0 4px 24px rgba(21, 101, 192, 0.14);
|
|
1113
|
+
|
|
1114
|
+
/* Toggle (preference modal) */
|
|
1115
|
+
--consenti-toggle-bg-on: #43a047;
|
|
1116
|
+
--consenti-toggle-bg-partial: #97c098;
|
|
1117
|
+
--consenti-toggle-bg-off: #9ca3af;
|
|
1118
|
+
--consenti-toggle-knob: #ffffff;
|
|
1119
|
+
--consenti-toggle-width: 52px;
|
|
1120
|
+
--consenti-toggle-height: 28px;
|
|
1121
|
+
|
|
1122
|
+
/* Stacking */
|
|
1123
|
+
--consenti-z-banner: 9999;
|
|
1124
|
+
--consenti-z-overlay: 9998;
|
|
1125
|
+
--consenti-z-modal: 10000;
|
|
908
1126
|
}
|
|
909
1127
|
```
|
|
910
1128
|
|
|
@@ -915,10 +1133,10 @@ new ConsentiSetup({
|
|
|
915
1133
|
compliance: { type: 'opt-in' },
|
|
916
1134
|
core: {
|
|
917
1135
|
theme: {
|
|
918
|
-
|
|
919
|
-
|
|
1136
|
+
colorPrimary: '#7c3aed', // purple brand
|
|
1137
|
+
colorPrimaryText: '#ffffff',
|
|
920
1138
|
borderRadius: '12px',
|
|
921
|
-
|
|
1139
|
+
borderRadiusBtn: '999px', // pill buttons
|
|
922
1140
|
fontFamily: 'Inter, sans-serif',
|
|
923
1141
|
},
|
|
924
1142
|
},
|
|
@@ -983,7 +1201,6 @@ new ConsentiSetup({
|
|
|
983
1201
|
|
|
984
1202
|
import { useEffect } from 'react'
|
|
985
1203
|
import { ConsentiSetup } from '@consenti/ui'
|
|
986
|
-
import '@consenti/ui/dist/index.css'
|
|
987
1204
|
|
|
988
1205
|
export function ConsentSetup() {
|
|
989
1206
|
useEffect(() => {
|
|
@@ -1036,7 +1253,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
1036
1253
|
|
|
1037
1254
|
import { useEffect, useRef } from 'react'
|
|
1038
1255
|
import type { ConsentiSetup as WidgetType } from '@consenti/ui'
|
|
1039
|
-
import '@consenti/ui/dist/index.css'
|
|
1040
1256
|
|
|
1041
1257
|
export function ConsentSetup() {
|
|
1042
1258
|
const widgetRef = useRef<WidgetType | null>(null)
|
|
@@ -1067,7 +1283,6 @@ let widget: unknown = null
|
|
|
1067
1283
|
|
|
1068
1284
|
onMounted(async () => {
|
|
1069
1285
|
const { ConsentiSetup } = await import('@consenti/ui')
|
|
1070
|
-
await import('@consenti/ui/dist/index.css')
|
|
1071
1286
|
widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
|
|
1072
1287
|
})
|
|
1073
1288
|
|
|
@@ -1106,15 +1321,18 @@ export class ConsentService implements OnDestroy {
|
|
|
1106
1321
|
}
|
|
1107
1322
|
```
|
|
1108
1323
|
|
|
1109
|
-
Or use the built-in Angular
|
|
1324
|
+
Or use the built-in Angular integration (`setConsentiWidget()` once at app root, then
|
|
1325
|
+
`injectConsent()` in any component/service):
|
|
1110
1326
|
|
|
1111
1327
|
```ts
|
|
1112
|
-
import {
|
|
1328
|
+
import { injectConsent } from '@consenti/ui/angular'
|
|
1113
1329
|
|
|
1114
1330
|
@Component({ /* ... */ })
|
|
1115
1331
|
export class MyComponent {
|
|
1116
|
-
consent =
|
|
1332
|
+
consent = injectConsent()
|
|
1117
1333
|
// consent.hasConsent() | consent.showModal() | consent.getConsent()
|
|
1334
|
+
// consent.onConsentChange(cb) / onBannerChange(cb) / onModalChange(cb) — call in
|
|
1335
|
+
// ngOnInit, unsubscribe with the returned function in ngOnDestroy
|
|
1118
1336
|
}
|
|
1119
1337
|
```
|
|
1120
1338
|
|
|
@@ -1122,7 +1340,6 @@ export class MyComponent {
|
|
|
1122
1340
|
|
|
1123
1341
|
```js
|
|
1124
1342
|
import { ConsentiSetup } from '@consenti/ui'
|
|
1125
|
-
import '@consenti/ui/dist/index.css'
|
|
1126
1343
|
|
|
1127
1344
|
const widget = new ConsentiSetup({ compliance: { type: 'opt-in' }, core: { locale: 'en' } })
|
|
1128
1345
|
|
|
@@ -1227,10 +1444,8 @@ new ConsentiSetup({
|
|
|
1227
1444
|
// Arabic site — RTL derived automatically from locale
|
|
1228
1445
|
new ConsentiSetup({ core: { locale: 'ar' } })
|
|
1229
1446
|
|
|
1230
|
-
// COPPA — block under-13s, require parental consent
|
|
1231
|
-
|
|
1232
|
-
compliance: { ageGate: { enabled: true, minimumAge: 13, requireParentalConsent: true } },
|
|
1233
|
-
})
|
|
1447
|
+
// COPPA — block under-13s, require parental consent: set on the profile in the dashboard
|
|
1448
|
+
// (Profile Editor Step 1 → Enable age gate → minimum age 13, require parental consent), not here.
|
|
1234
1449
|
|
|
1235
1450
|
// TCF — install the __tcfapi stub for ad-tech scripts (cmpId must match the backend's tcf config)
|
|
1236
1451
|
new ConsentiSetup({
|
|
@@ -1250,11 +1465,11 @@ import type {
|
|
|
1250
1465
|
CoreConfig, // core section
|
|
1251
1466
|
ApiConfig, // api section
|
|
1252
1467
|
ComplianceWidgetConfig, // compliance section
|
|
1253
|
-
AgeGateWidgetConfig, // compliance.ageGate section
|
|
1254
1468
|
WidgetCountryResolverFn, // custom geo resolver function type
|
|
1255
1469
|
UtilsConfig, // utils section
|
|
1256
1470
|
GtmConfig, // utils.gtm section
|
|
1257
1471
|
ThemeConfig, // core.theme section
|
|
1472
|
+
IdentifyEventDetail, // consenti:listener:identify event detail
|
|
1258
1473
|
ConsentValue, // 'granted' | 'denied' | 'objected'
|
|
1259
1474
|
ConsentiProfile, // local profile class
|
|
1260
1475
|
NonEmptyArray, // [T, ...T[]] — used by usePrebuiltProfiles
|
|
@@ -1274,6 +1489,17 @@ The following `CoreConfig` fields were **removed** in v0.1.x:
|
|
|
1274
1489
|
|
|
1275
1490
|
---
|
|
1276
1491
|
|
|
1492
|
+
## Security notes
|
|
1493
|
+
|
|
1494
|
+
**`core.cookieSigningKey` is spoofable in standalone mode.** Without a `@consenti/api` backend,
|
|
1495
|
+
this key has nowhere to live but the shipped browser bundle — so anyone can read it out of your JS
|
|
1496
|
+
and re-sign a forged cookie with it. It still catches accidental tampering (e.g. a stale value from
|
|
1497
|
+
an older config), but it is not tamper-*proof* against a motivated attacker. If you need consent
|
|
1498
|
+
records that hold up as evidence, sign them server-side instead with `compliance.dataSigningHash`
|
|
1499
|
+
(`@consenti/api`), where the key never reaches the browser.
|
|
1500
|
+
|
|
1501
|
+
---
|
|
1502
|
+
|
|
1277
1503
|
## Contributing
|
|
1278
1504
|
|
|
1279
1505
|
See [CONTRIBUTING.md](../../CONTRIBUTING.md) in the monorepo root.
|