diff --git a/CHANGELOG.md b/CHANGELOG.md index caad1668..8615d3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.0.2](https://github.com/openmail/system1-cmp/compare/v2.0.1...v2.0.2) (2020-09-02) + +### Feat + +- [x] Automatically set and persist consent signal if valid TC String present on URLParam `?gdpr_consent` +- [x] Enforce boolean properties in logger + ## [2.0.1](https://github.com/openmail/system1-cmp/compare/v2.0.0...v2.0.1) (2020-08-31) ### Refactor diff --git a/README.md b/README.md index cd4350ee..c1025e78 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,18 @@ TCF 2.0 Consent Management Platform (CMP) UI tool. We are in the process of vali + - [Installation / Use](#installation--use) - [API](#api) - [Customized API](#customized-api) - - [init](#init) + - [init](#init) - [onConsentAllChanged](#onconsentallchanged) - [offConsentAllChanged](#offconsentallchanged) - [showConsentTool](#showconsenttool) - [changeLanguage](#changelanguage) - [Configuration / Config](#configuration--config) - [theme](#theme) +- [Initialize from URL Param](#initialize-from-url-param) - [Background and Resources](#background-and-resources) - [TODO](#todo) - [Support Matrix](#support-matrix) @@ -90,7 +92,7 @@ Read more about [\_\_tcfapi built-in API](https://github.com/InteractiveAdvertis - [showConsentTool](#showConsentTool) - [changeLanguage](#changeLanguage) -#### init +### init Calling `__tcfapi('init', 2, (store) => {})` will trigger the seed-file or loader to async load the larger CMP UI application. Once loaded, the cmp library calls `init` function to load additional dependencies and render the application. @@ -177,7 +179,7 @@ __tcfapi('init', 2, () => {}, { }, canLog: true, // pixel logging for monitoring and alerting canDebug: false, // console.logs for dev debugging - narrowedVendors: [1, 2, 3, 4, 5], // only show a select numuber of vendors + narrowedVendors: [1, 2, 3, 4, 5], // only show a select vendors cookieDomain: '', // which domain to set the euconsent and gdpr_opt_in cookie on }); ``` @@ -188,6 +190,7 @@ __tcfapi('init', 2, () => {}, { | `canDebug` | optional boolean | `false` | true enables internal console logging for debugging | | `baseUrl` | optional string | `./config/2.0` | relative or absolute url to load the global vendor list. Combines with `versionedFilename` to load vendorlist. | | `versionedFilename` | optional string | `vendor-list.json` | file name of the global vendor list. | +| `narrowedVendors` | optional array | `[]` | Only show select vendors. Example [1,4,5,19] | | `languageFilename` | optional string | `purposes/purposes-[LANG].json` | file name template for gvl localized purpose json files | | `translationFilename` | optional string | `translations/translations-[LANG].json` | file name template for custom localized json files for UI layer | | `cookieDomain` | optional string | empty | manage consent across subdomains. Example `.mysite.com` | @@ -208,6 +211,18 @@ Override styling choices using the following properties: - `secondaryColor`: '#869cc0' - `featuresColor`: '#d0d3d7' +## Initialize from URL Param + +We can leverage the spec provided for [URL-based services to process the TC String when it can't execute JavaScript](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md#how-does-a-url-based-service-process-the-tc-string-when-it-cant-execute-javascript) to pass along consent when domains are owned by the same entity. + +Using a URLParam `gdpr_consent` you can pass consent to another domain that is using this CMP. + +``` +?gdpr_consent=${TC_STRING} +``` + +The CMP will use `?gdpr_consent` URLParam to automatically persist consent and trigger consent change-events _if there is not already an existing consent signal stored in the EUConsent cookie_. + ## Background and Resources The UI layer is inspired by this [IAB TCF CMP UI Webinar presentation](https://iabeurope.eu/wp-content/uploads/2020/01/2020-01-21-TCF-v2.0-CMP-UI-Webinar.pdf). @@ -227,7 +242,6 @@ Following Google's [Additional Consent Mode](https://support.google.com/admanage - [ ] Layer 3 Purpose Details - [ ] Layer 2 Vendors - [ ] Theming -- [ ] Auto-consent using [TC-string-passing](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md#full-tc-string-passing) - [ ] non-personalized performance and monitoring analytics - [ ] Validate using the [TCF 2.0 validator extension](https://cmp-validator.consensu.org/chrome-extension/latest/IAB-Europe-CMP-Validator-User-Guide.pdf) - [ ] Separate polyfill bundle, use babelrc instead of manually importing from core-js diff --git a/package.json b/package.json index 9a7e3d25..5ac1cae4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "system1-cmp", - "version": "2.0.1", + "version": "2.0.2", "description": "System1 Consent Management Platform for TCF 1.1 GDPR Compliance", "scripts": { "clean": "rimraf ./dist", @@ -106,7 +106,7 @@ "dependencies": { "@iabtcf/cmpapi": "^1.1.0-3", "@iabtcf/core": "^1.1.0-3", - "@s1/dpl": "3.0.16", + "@s1/dpl": "3.0.18", "classnames": "^2.2.5", "codemirror": "^5.34.0", "core-js": "^2.5.3", diff --git a/src/s1/constants.js b/src/s1/constants.js index cdae9299..bfca6d88 100644 --- a/src/s1/constants.js +++ b/src/s1/constants.js @@ -1,5 +1,9 @@ /* global __VERSION__ */ - +export const COOKIES = { + VENDOR_CONSENT: 'euconsent-v2', + PUBLISHER_CONSENT: 'pubconsent-v2', + HAS_CONSENTED_ALL: 'gdpr_opt_in', +}; export const CONSENT_SCREENS = { STACKS_LAYER1: 1, PURPOSES_LAYER2: 2, diff --git a/src/s1/lib/config.js b/src/s1/lib/config.js index 891eca7a..65581d0f 100644 --- a/src/s1/lib/config.js +++ b/src/s1/lib/config.js @@ -1,3 +1,14 @@ +export const gdprConsentUrlParam = (() => { + let gdprConsent = ''; + if (window && window.location && window.location.search) { + const [, gdprConsentParam] = window.location.search.split('gdpr_consent='); + if (gdprConsentParam) { + gdprConsent = gdprConsentParam.split('&')[0]; + } + } + return gdprConsent; +})(); + export const theme = { primaryColor: '#0099ff', textLinkColor: '#0099ff', @@ -18,6 +29,7 @@ export const config = { ccpaApplies: false, experimentId: 'control', gdprApplies: false, + gdprConsentUrlParam, language: 'en', narrowedVendors: [], publisherCountryCode: 'US', diff --git a/src/s1/lib/cookie.js b/src/s1/lib/cookie.js index ac7b4312..51079b16 100644 --- a/src/s1/lib/cookie.js +++ b/src/s1/lib/cookie.js @@ -1,11 +1,10 @@ -const PUBLISHER_CONSENT_COOKIE_NAME = 'pubconsent_2.0'; -const PUBLISHER_CONSENT_COOKIE_MAX_AGE = 33696000; +import { COOKIES } from '../constants'; -const VENDOR_CONSENT_COOKIE_NAME = 'euconsent_2.0'; -const VENDOR_CONSENT_COOKIE_MAX_AGE = 33696000; +const { HAS_CONSENTED_ALL, PUBLISHER_CONSENT, VENDOR_CONSENT } = COOKIES; -const HAS_CONTENED_ALL = 'gdpr_opt_in'; -const HAS_CONTENED_ALL_MAX_AGE = 33696000; +const PUBLISHER_CONSENT_COOKIE_MAX_AGE = 33696000; +const VENDOR_CONSENT_COOKIE_MAX_AGE = 33696000; +const HAS_CONSENTED_ALL_MAX_AGE = 33696000; const getCookieDomain = (customCookieDomain) => { const hostname = (window && window.location && window.location.hostname) || ''; @@ -36,12 +35,12 @@ function writeCookie({ name, value, maxAgeSeconds, path = '/', domain }) { } function readPublisherConsentCookie() { - return readCookie(PUBLISHER_CONSENT_COOKIE_NAME); + return readCookie(PUBLISHER_CONSENT); } function writePublisherConsentCookie(value, domain) { writeCookie({ - name: PUBLISHER_CONSENT_COOKIE_NAME, + name: PUBLISHER_CONSENT, value, maxAgeSeconds: PUBLISHER_CONSENT_COOKIE_MAX_AGE, path: '/', @@ -56,7 +55,7 @@ function writePublisherConsentCookie(value, domain) { * @returns Promise resolved with decoded cookie object */ function readLocalVendorConsentCookie() { - return readCookie(VENDOR_CONSENT_COOKIE_NAME); + return readCookie(VENDOR_CONSENT); } /** @@ -68,7 +67,7 @@ function readLocalVendorConsentCookie() { function writeLocalVendorConsentCookie(value, domain) { return Promise.resolve( writeCookie({ - name: VENDOR_CONSENT_COOKIE_NAME, + name: VENDOR_CONSENT, value, maxAgeSeconds: VENDOR_CONSENT_COOKIE_MAX_AGE, path: '/', @@ -86,14 +85,14 @@ function writeVendorConsentCookie(value, domain) { } function readConsentedAllCookie() { - return readCookie(HAS_CONTENED_ALL); + return readCookie(HAS_CONSENTED_ALL); } function writeConsentedAllCookie(value, domain) { writeCookie({ - name: HAS_CONTENED_ALL, + name: HAS_CONSENTED_ALL, value, - maxAgeSeconds: HAS_CONTENED_ALL_MAX_AGE, + maxAgeSeconds: HAS_CONSENTED_ALL_MAX_AGE, path: '/', domain, }); diff --git a/src/s1/lib/store.js b/src/s1/lib/store.js index 60458076..4783e822 100644 --- a/src/s1/lib/store.js +++ b/src/s1/lib/store.js @@ -123,7 +123,7 @@ export default class Store { }); // fired after gvl.readyPromise and tcData updated if persisted onReady() { - const { narrowedVendors, cmpId, cmpVersion, publisherCountryCode } = this.config; + const { narrowedVendors, cmpId, cmpVersion, gdprConsentUrlParam, publisherCountryCode } = this.config; const { vendors } = this.gvl; if (narrowedVendors && narrowedVendors.length) { @@ -133,12 +133,17 @@ export default class Store { const tcModel = new TCModel(this.gvl); let persistedTcModel; - let encodedTCString = cookie.readVendorConsentCookie(); + const cookieTCString = cookie.readVendorConsentCookie(); + const encodedTCString = cookieTCString || gdprConsentUrlParam; try { persistedTcModel = encodedTCString && TCString.decode(encodedTCString); } catch (e) { - console.error('unable to decode tcstring'); + logger(LOG_EVENTS.CMPError, { + message: `storeReadyError: unable to decode TCString from ${ + gdprConsentUrlParam ? 'consentUrl' : 'consentCookie' + }`, + }); } // Merge persisted model into new model in memory @@ -150,7 +155,7 @@ export default class Store { consentScreen: CONSENT_SCREENS.STACKS_LAYER1, }); - // Handle a return user with persistedConsent vs a user that has not saved preferences + // Handle a new user if (!persistedTcModel) { tcModel.setAllVendorLegitimateInterests(); tcModel.setAllPurposeLegitimateInterests(); @@ -161,7 +166,11 @@ export default class Store { // update internal models, show ui, dont save to cookie this.updateCmp({ tcModel, shouldShowModal: true }); + this.setDisplayLayer1(); } else { + // handle a return user + + // Note: commented out because automatic vendor consent management creates unexpected result for user. // update the manually managed vendor consent model set since it's primarily automatically managed // this is a list of vendor consents that were likely manually revoked by the user // const { vendorConsents } = tcModel; @@ -170,10 +179,16 @@ export default class Store { // this.manualVendorConsents.add(parseInt(key, 10)); // } // }); - // update internal models, dont show the ui, dont save to cookie + + // update internal models, dont show the ui, save to cookie if persisting from URL this.updateCmp({ tcModel }); + this.setDisplayLayer1(); + + // trigger cookie storage when transfering consent from URL + if (gdprConsentUrlParam && !cookieTCString) { + this.updateCmp({ tcModel, shouldSaveCookie: true, isConsentByUrl: true }); + } } - this.setDisplayLayer1(); } onEvent(tcData, success) { @@ -207,8 +222,9 @@ export default class Store { * @oaram tcModelOpt - optional ModelObject, updates to the tcModel * @param shouldShowModal - optional boolean, displays UI if true * @param shouldSaveCookie - optional boolean, sets gdpr_opt_in and stores tcData.consentString too cookie if true + * @param isConsentByUrl - optional boolean, annotates logs to indicate save of consent transfered from URLParams */ - updateCmp = ({ tcModel, shouldShowModal, shouldSaveCookie, shouldShowSave }) => { + updateCmp = ({ tcModel, shouldShowModal, shouldSaveCookie, shouldShowSave, isConsentByUrl = false }) => { const tcModelNew = this.autoToggleVendorConsents(tcModel); const isModalShowing = shouldShowModal !== undefined ? shouldShowModal : this.isModalShowing; const isSaveShowing = shouldShowSave !== undefined ? shouldShowSave : this.isSaveShowing; @@ -267,6 +283,7 @@ export default class Store { logger(LOG_EVENTS.CMPSave, { consentScreen, hasConsentedAll, + consentByUrl: isConsentByUrl, declinedStack: this.getStackOptin(stack) ? '' : stack, declinedPurposes: declinedPurposes.join(','), declinedSpecialFeatures: declinedSpecialFeatures.join(','), diff --git a/src/s1/reference/tcf-2.0.html b/src/s1/reference/tcf-2.0.html index f777e45e..5507a84a 100644 --- a/src/s1/reference/tcf-2.0.html +++ b/src/s1/reference/tcf-2.0.html @@ -177,7 +177,7 @@

TCFString

// polyfillSrc: './polyfills.js', publisherCountryCode: 'US', language: 'en', // default - narrowedVendors: [], + narrowedVendors: [1, 2, 3, 4, 5, 6], }; __tcfapi('addEventListener', 2, function (tcData, success) { diff --git a/src/s1/tcf-2.0-cmp.js b/src/s1/tcf-2.0-cmp.js index 9a41830b..cbb7348b 100644 --- a/src/s1/tcf-2.0-cmp.js +++ b/src/s1/tcf-2.0-cmp.js @@ -28,8 +28,8 @@ export const setup = (configOpt) => { url: global && global.location && global.location.href ? global.location.href.split('?')[0] : 'unknown', experimentId: config.experimentId, business: config.business, - ccpaApplies: config.ccpaApplies, - gdprApplies: config.gdprApplies, + ccpaApplies: config.ccpaApplies === true, + gdprApplies: config.gdprApplies === true, }; logger(LOG_EVENTS.CMPSetupStart || 'CMPSetupStart'); diff --git a/yarn.lock b/yarn.lock index dbbe06a8..aba508e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -969,10 +969,10 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" -"@s1/dpl@3.0.16": - version "3.0.16" - resolved "https://system1.jfrog.io/system1/api/npm/npm-virtual/@s1/dpl/-/@s1/dpl-3.0.16.tgz#f135cd8958f05cb15fdc8659364bb4d1a0d2362b" - integrity sha1-8TXNiVjwXLFf3IZZNku00aDSNis= +"@s1/dpl@3.0.18": + version "3.0.18" + resolved "https://system1.jfrog.io/system1/api/npm/npm-virtual/@s1/dpl/-/@s1/dpl-3.0.18.tgz#a635d5e4015cda569c8699794b33133c1adfec2b" + integrity sha1-pjXV5AFc2lachpl5SzMTPBrf7Cs= dependencies: "@babel/runtime" "^7.10.5" doctoc "^1.4.0"