-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
First public version, with demo and documentation
- Loading branch information
Florens Verschelde
committed
Jun 8, 2016
0 parents
commit 452b275
Showing
10 changed files
with
589 additions
and
0 deletions.
There are no files selected for viewing
This file contains 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 |
---|---|---|
@@ -0,0 +1,21 @@ | ||
The MIT License (MIT) | ||
|
||
Copyright (c) 2016 | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains 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 |
---|---|---|
@@ -0,0 +1,113 @@ | ||
# CookiePolicyManager | ||
|
||
This script helps you manage the requirements of EU cookie regulations, while enabling you to provide a good user experience. It enables: | ||
|
||
1. Saving and retrieving information about the user’s choice (info is saved locally using localStorage or… a cookie). | ||
2. Detecting user navigation to a second page, which counts as implicit consent *provided you have shown a notice to the user*. | ||
3. Executing one or several functions (callbacks) *when* the user consents to tracking cookies, *or* on page load if consent was already given and recorded. | ||
|
||
This script does *not* provide a user interface, such as a cookie “banner”. You will have to build your own UI, and write a JS script for basic features such as showing or hiding the UI, adding event listeners to buttons, etc. If that looks intimidating, you can [look at our demo](./demo/) for inspiration. | ||
|
||
Read more about the legal side: [gov.uk guide](https://www.gov.uk/service-manual/making-software/cookies) and [CNIL guide (in French)](https://www.cnil.fr/fr/cookies-traceurs-que-dit-la-loi). | ||
|
||
## `CookiePolicyManager` usage | ||
|
||
These examples use native JavaScript (and DOM) methods, but you can convert to jQuery-based code if you like it better. Basic usage of `CookiePolicyManager` might look like this: | ||
|
||
```js | ||
var cookiepm = CookiePolicyManager({ navigation: true }); | ||
|
||
// Hide the banner on user agreement (or immediately) | ||
cookiepm.action(function(){ | ||
document.querySelector('.CookieNotice').setAttribute('hidden', ''); | ||
}); | ||
``` | ||
|
||
This only hides the banner when we detect a second page navigation (and on page views after that). If you want to provide a button for closing the banner (and registering the user choice), you will need to add an event listener: | ||
|
||
```js | ||
document.querySelector('.CookieNotice button').addEventListener('click', function(){ | ||
cookiepm.update('explicit', 'close button'); | ||
}); | ||
``` | ||
|
||
You can register additional actions: | ||
|
||
```js | ||
cookiepm.action(function(){ | ||
// For instance: copy Google Analytics code here, | ||
// to only use Analytics AFTER implicit or explicit | ||
// user agreement. (It’s a legal requirement!) | ||
}); | ||
``` | ||
|
||
What if you want to give users a way to opt out? Let’s say that you have a “Legal notice” or “Privacy policy” page, and you want to create a button for denying tracking there: | ||
|
||
``` | ||
var denyButton = document.querySelector('#refuse-cookies'); | ||
denyButton && denyButton.addEventListener('click', function(){ | ||
cookiepm.update('deny', 'deny-button'); | ||
}); | ||
``` | ||
|
||
Want to see a different example? Look at [our demo’s script](./demo/demo.js). | ||
|
||
## Options and methods | ||
|
||
The `CookiePolicyManager` function takes an optional object of configuration with the following options: | ||
|
||
- `navigation` (defaults to `false`): whether to automatically check for more than one page view in the same session (which counts as explicit agreement under EU policies). | ||
- `ignoreUrls` (defaults to `[]`): an array of URLs which should not count as page views for implicit agreement (typically, the "more info about cookies" page should not count for the `navigation` mechanism). | ||
|
||
And it returns an object with these methods: | ||
|
||
- `action(callback)`: add a callback function to be called immediately (if we already have a user agreement) or when we get the user's agreement. | ||
|
||
- `update(type, subType)`: saves information on user agreement in localStorage. | ||
- `type` must be a string with one of those values: `'deny'`, `'explicit'` or `'implicit'`. | ||
- `subType` can be a string, with any value you like (use it to store additional information on the “source” of user agreement). | ||
|
||
- `status()`: shows saved information on user agreement for this user, with two properties: | ||
- `allowed` (boolean) | ||
- `because` (string: type and subtype of user agreement) | ||
|
||
- `clear()`: remove all saved information on user agreement for this browser (localStorage or cookie, sessionStorage if any). | ||
|
||
|
||
## FAQ (Nobody asked these yet but they might!) | ||
|
||
### I made a cookie banner but it flashes briefly before hiding | ||
|
||
Like any HTML content, it can be rendered by the browser before your script has the chance to hide it. This means it can flash briefly or even be displayed for one or two seconds in some situations. You could make sure the banner is hidden by default, e.g. using the `hidden` attribute. | ||
|
||
```html | ||
<aside class="CookieNotice" hidden> | ||
… | ||
</aside> | ||
``` | ||
|
||
And then remove the `hidden` attribute if the `cookiepm.status().allowed` property is false. See [the demo script](./demo/demo.js) for an example of that behavior. | ||
|
||
### I’m calling `update()` but my banner is still there | ||
|
||
Are you calling `CookiePolicyManager().update('explicit', 'something')`? | ||
|
||
Every time you call `CookiePolicyManager()`, you get a new object with its own action queue. So the code in the previous paragraph will save the provided information in localStorage (good), but it will execute an empty action queue. | ||
|
||
If you need to use `CookiePolicyManager` from several scripts, add to its action queue, etc., you should create one instance and expose it globally: | ||
|
||
```js | ||
// cookie-policy.js | ||
window.myCPM = CookiePolicyManager(options); | ||
|
||
// marketing-automation.js | ||
myCPM.action(function(){ /* do something */ }); | ||
``` | ||
|
||
### Safari in Private Browsing doesn’t detect page views | ||
|
||
In Private Browsing mode, Safari (on iOS at least) doesn’t allow access to localStorage or sessionStorage. We can store the user choice in a cookie, but detecting the navigation relies on sessionStorage, so it’s disabled. | ||
|
||
### So this solution relies on JavaScript? | ||
|
||
Yep. This shouldn’t be an issue because all tracking, analytics etc. tools targetted by the EU regulations tend to be JavaScript-only, too, so if a user has disabled JS your probably respect EU regulations already. Note that session cookies, authentification cookies and shopping cart cookies are always valid and require no consent. |
This file contains 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 |
---|---|---|
@@ -0,0 +1,228 @@ | ||
/** | ||
* Record explicit or implicit EU Cookie Policy agreement in localStorage, | ||
* and hides the cookie policy notice if we have a user agreement. | ||
* Also tries to detect second pageviews, which count as implicit agreement. | ||
* | ||
* @param {Object} options | ||
* @param {boolean} options.navigation - track last page view to detect implicit agreement? | ||
* @param {array} options.ignoreUrls - URLs that should not count as a page view | ||
* | ||
* @returns {CookiePolicyManager} manager | ||
* @typedef CookiePolicyManager | ||
* @type {object} | ||
* @property {Function} status - Show cookie agreement status | ||
* @property {Function} action - Provide a function to call on user agreement | ||
* @property {Function} update - Update cookie agreement status | ||
* @property {Function} clear - Remove user data | ||
*/ | ||
function CookiePolicyManager(options) { | ||
'use strict'; | ||
|
||
var AGREEMENT_TYPES = { | ||
'deny': false, | ||
'explicit': true, | ||
'implicit': true | ||
}; | ||
var AGREEMENT_KEY = 'cpm-agree'; | ||
var PAGEVIEW_KEY = 'cpm-prev'; | ||
|
||
// Chek that localStorage is usable | ||
var LS = testStorage('local'); | ||
|
||
// Default settings | ||
var settings = { | ||
navigation: false, | ||
ignoreUrls: [] | ||
}; | ||
|
||
// Action queue | ||
var actions = []; | ||
|
||
// Merge settings | ||
if (typeof options === 'object') { | ||
for (var key in settings) { | ||
if (settings.hasOwnProperty(key) && typeof options[key] === typeof settings[key]) { | ||
settings[key] = options[key]; | ||
} | ||
} | ||
} | ||
|
||
// Check if we have a "page navigation" implicit agreement, | ||
// and record current page URL | ||
// for the next page view. We clean up after ourselves if we have an agreement. | ||
if (settings.navigation) checkNavigation(); | ||
|
||
return { | ||
status: showAgreementStatus, | ||
action: addAgreementAction, | ||
update: updateAgreement, | ||
clear: clearUserData | ||
}; | ||
|
||
/** | ||
* Reads localStorage and returns a type and subtype of agreement | ||
* @returns { {type:string, subType:string, rawValue:string} } | ||
*/ | ||
function getAgreementValue() { | ||
var types = decodeTypes(remember(AGREEMENT_KEY)); | ||
var allowed = AGREEMENT_TYPES[types[0]]; | ||
return { | ||
allowed: typeof allowed === 'boolean' ? allowed : false, | ||
type: types[0], | ||
subType: types[1] | ||
}; | ||
} | ||
|
||
/** | ||
* Add a callback to the queue or execute immediately | ||
* @param {function} callback | ||
*/ | ||
function addAgreementAction(callback) { | ||
if (typeof callback !== 'function') { | ||
throw 'callback must be a function, was: ' + typeof callback; | ||
} | ||
if (actions.indexOf(callback) === -1) { | ||
actions.push(callback); | ||
callMeMaybe(); | ||
} | ||
} | ||
|
||
/** | ||
* Public - update the agreement info or clear recorded data | ||
* @param {string} type | ||
* @param {string|undefined} subType | ||
*/ | ||
function updateAgreement(type, subType) { | ||
type = (typeof type === 'string') ? type.trim().toLowerCase() : ''; | ||
subType = (typeof subType === 'string') ? subType.trim().toLocaleLowerCase() : ''; | ||
if (typeof AGREEMENT_TYPES[type] !== 'boolean') { | ||
throw 'type must be one of "' + Object.keys(AGREEMENT_TYPES).join('", "') + '"'; | ||
} | ||
// Don't override 'deny' or 'explicit' with an 'implicit' value, | ||
// but avoid throwing in this situation | ||
var prev = getAgreementValue(); | ||
if (type === 'implicit' && prev.type !== '' && prev.type !== type) { | ||
'console' in window && console.log('Ignored implicit agreement. Current type: "' | ||
+ encodeTypes(prev.type, prev.subType) + '"'); | ||
} | ||
else { | ||
remember(AGREEMENT_KEY, encodeTypes(type, subType)); | ||
} | ||
callMeMaybe(); | ||
} | ||
|
||
/** | ||
* Remove all user data (localStorage or cookie, sessionStorage) | ||
*/ | ||
function clearUserData() { | ||
remember(AGREEMENT_KEY, ''); | ||
try { sessionStorage.removeItem(PAGEVIEW_KEY) } catch(e) {} | ||
} | ||
|
||
/** | ||
* Public - show current status of the user's consent to cookie use | ||
*/ | ||
function showAgreementStatus() { | ||
var value = getAgreementValue(); | ||
return { | ||
allowed: value.allowed, | ||
because: encodeTypes(value.type, value.subType) | ||
}; | ||
} | ||
|
||
/** | ||
* Run the each callback function if we have an agreement, and only once | ||
*/ | ||
function callMeMaybe() { | ||
if (getAgreementValue().allowed) { | ||
actions.forEach(function(action){ action() }); | ||
actions = []; | ||
} | ||
} | ||
|
||
/** | ||
* Store the current URL in sessionStorage and/or check existing data | ||
* to determine if we have an implicit "navigation"-type agreement. | ||
* (Note that we're stripping the hashes when comparing URLs.) | ||
*/ | ||
function checkNavigation() { | ||
if (!testStorage('session') || getAgreementValue().type !== '') { | ||
return; | ||
} | ||
var url = location.href.split('#')[0]; | ||
var prev = (sessionStorage.getItem(PAGEVIEW_KEY) || '').split('#')[0]; | ||
var ignore = settings.ignoreUrls.map(function(s){ | ||
return s.split('#')[0]; | ||
}); | ||
// We have a different recorded previous page | ||
if (prev !== '' && prev !== url && ignore.indexOf(url) === -1) { | ||
updateAgreement('implicit', 'navigation'); | ||
sessionStorage.removeItem(PAGEVIEW_KEY); | ||
} | ||
else { | ||
sessionStorage.setItem(PAGEVIEW_KEY, location.href); | ||
} | ||
} | ||
|
||
/** | ||
* Simple utility to set or get a localStorage value, falling back to a cookie | ||
* in case localStorage is blocked (especially in Safari Private Browsing mode). | ||
* Cookie reading implementation from: | ||
* https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie/Simple_document.cookie_framework | ||
* @param {string} key | ||
* @param {string|undefined} value - value to set, or empty string to unset | ||
*/ | ||
function remember(key, value) { | ||
// one week tops for the cookie fallback | ||
var COOKIE_MAX = 7 * 24 * 3600; | ||
var LS = typeof LS === 'boolean' ? LS : testStorage('local'); | ||
if (value === '') { | ||
if (LS) localStorage.removeItem(key); | ||
else document.cookie = key + '=;max-age=0'; | ||
return; | ||
} | ||
else if (typeof value === 'string') { | ||
if (LS) localStorage.setItem(key, value); | ||
else document.cookie = key + '=' + encodeURIComponent(value) + ';max-age=' + COOKIE_MAX; | ||
return; | ||
} | ||
return LS ? localStorage.getItem(key) : decodeURIComponent(document.cookie.replace(new RegExp("(?:(?:^|.*;)\\s*" + encodeURIComponent(key).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1")) || null | ||
} | ||
|
||
/** | ||
* Test either localStorage or sessionStorage support | ||
* @param {object} name - storage name | ||
*/ | ||
function testStorage(name) { | ||
try { | ||
var x = 'cpm-test'; | ||
var store = window[name + 'Storage']; | ||
store.setItem(x, x); | ||
store.removeItem(x); | ||
return true; | ||
} catch(err) { | ||
return false; | ||
} | ||
} | ||
|
||
/** | ||
* Make sure we always convert type and subtype to a single string format | ||
* @param {string} type | ||
* @param {string} subType | ||
* @returns {string} | ||
*/ | ||
function encodeTypes(type, subType) { | ||
return type + (subType ? '/' + subType : '') | ||
} | ||
|
||
/** | ||
* Make sure we always read type/subtype strings in the same way | ||
* @param {string} raw | ||
* @returns {array} | ||
*/ | ||
function decodeTypes(raw) { | ||
var details = (raw || '').split('/'); | ||
return [details[0], details.length ? details[1] : ''] | ||
} | ||
|
||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.