Repository navigation
feat(design): add cookie consent and footer #509
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
uniqueg
wants to merge
6
commits into
dev
Choose a base branch
from
feat/compliance
base: dev
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
6 commits
Select commit
Hold shift + click to select a range
1b70e1b
feat(design): add cookie consent and footer
uniqueg b42e8d8
fix(design): harden consent storage and embeds
uniqueg e7e8fc0
fix(design): follow consent from other tabs
uniqueg 0440672
fix(design): scope consent events and merge prefs
uniqueg 38ccc63
chore: merge dev into feat/compliance
uniqueg c79f40e
Merge remote-tracking branch 'origin/dev' into feat/compliance
uniqueg 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
130 changes: 130 additions & 0 deletions
130
apps/documentation/pages/docs/design/components/compliance-footer.mdx
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 |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { Callout, Tabs } from 'nextra/components' | ||
| import ComponentName from '@/components/common/ComponentName' | ||
| import ComplianceFooter from '@elixir-cloud/design/react/compliance-footer'; | ||
|
|
||
| # EccUtilsDesignComplianceFooter Component | ||
|
|
||
| <ComponentName | ||
| tagName='ecc-utils-design-compliance-footer' | ||
| reactTagName='EccUtilsDesignComplianceFooter' | ||
| version='2.0.0-alpha.50' | ||
| /> | ||
|
|
||
| ## Overview | ||
|
|
||
| The `ecc-utils-design-compliance-footer` component renders the links to legal and regulatory information that operators must provide, configured per deployment. It reads the same configuration as the [cookie consent](/docs/design/components/cookie-consent), so an app adds both from configuration alone. | ||
|
|
||
| Each item has an action: | ||
|
|
||
| | Action | Configuration | Result | | ||
| |---------------|-------------------------------------------------------|------------------------------------------------------------| | ||
| | `link` | `{ type: 'link', href, newTab? }` | A link (`http(s)` or relative). | | ||
| | `mailto` | `{ type: 'mailto', email, subject? }` | Opens the mail client; `email` is a plain address (letters, digits, `.`, `_`, `+`, `-`). | | ||
| | `preferences` | `{ type: 'preferences' }` | Opens the cookie preferences. | | ||
| | `page` | `{ type: 'page', markdown, title? }` | Shows a page from Markdown in a dialog. | | ||
| | `embed` | `{ type: 'embed', src, title?, consent? }` | Shows an `https` page in a frame in a dialog, e.g. a policy hosted by Iubenda, Termly or GetTerms; with `consent`, only once users consented to that cookie category. | | ||
|
|
||
| <Callout type='warning'> | ||
| Operators are responsible for the content: which items their deployment needs, under which regulations, and what the pages say. The defaults are only a starting point. | ||
| </Callout> | ||
|
|
||
| ## Usage | ||
|
|
||
| <Tabs items={['Preview', 'React', 'HTML']}> | ||
| <Tabs.Tab> | ||
| <ComplianceFooter | ||
| config={{ | ||
| footer: { | ||
| items: [ | ||
| { id: 'privacy-policy', action: { type: 'page', markdown: '# Privacy Policy\n\nThis demo stores **no** personal data.\n\n- No analytics\n- No tracking' } }, | ||
| { id: 'imprint', action: { type: 'page', markdown: 'Operated by **Example Institute**, Example Street 1, Example City.' } }, | ||
| { id: 'contact', action: { type: 'mailto', email: 'support@example.org', subject: 'Question' } }, | ||
| { id: 'status', label: 'Service Status', action: { type: 'link', href: 'https://example.org/status', newTab: true } }, | ||
| ], | ||
| }, | ||
| }} | ||
| /> | ||
| </Tabs.Tab> | ||
| <Tabs.Tab> | ||
| ```tsx showLineNumbers copy filename="footer.tsx" | ||
| import EccUtilsDesignComplianceFooter from '@elixir-cloud/design/react/compliance-footer'; | ||
|
|
||
| const compliance = { | ||
| footer: { | ||
| items: [ | ||
| { id: 'privacy-policy', action: { type: 'link', href: '/privacy' } }, | ||
| { id: 'imprint', action: { type: 'page', markdown: 'Operated by **Example Institute**.' } }, | ||
| { id: 'contact', action: { type: 'mailto', email: 'support@example.org' } }, | ||
| { id: 'accessibility', hidden: true }, | ||
| ], | ||
| }, | ||
| }; | ||
|
|
||
| export default function Footer() { | ||
| return <EccUtilsDesignComplianceFooter config={compliance} />; | ||
| } | ||
| ``` | ||
| </Tabs.Tab> | ||
| <Tabs.Tab> | ||
| ```html showLineNumbers copy filename="footer.html" | ||
| <script type="module" src="https://esm.sh/@elixir-cloud/design@2.0.0-alpha.50/components/compliance-footer"></script> | ||
| <ecc-utils-design-compliance-footer></ecc-utils-design-compliance-footer> | ||
| <script type="module"> | ||
| document.querySelector('ecc-utils-design-compliance-footer').config = { | ||
| footer: { | ||
| items: [{ id: 'privacy-policy', action: { type: 'link', href: '/privacy' } }], | ||
| }, | ||
| }; | ||
| </script> | ||
| ``` | ||
| </Tabs.Tab> | ||
| </Tabs> | ||
|
|
||
| ## Properties | ||
|
|
||
| | Property | Type | Default | Description | | ||
| |----------|--------------------|---------|------------------------------------------------------------------------------| | ||
| | `config` | `ComplianceConfig` | `{}` | Shared compliance configuration; this component uses `config.footer`, and `config.cookies` for embeds that need consent. | | ||
|
|
||
| ## Events | ||
|
|
||
| | Event Name | React Event Name | Detail Type | Description | | ||
| |--------------------------------|--------------------------------|------------------------|-------------| | ||
| | `ecc-compliance-item-selected` | `onEccComplianceItemSelected` | `{ item: FooterItem }` | Fired when the user selects an item, before its action runs. Call `event.preventDefault()` to handle the item yourself, e.g. with the app's router. | | ||
|
|
||
| ## Configuration (`config.footer`) | ||
|
|
||
| | Field | Type | Description | | ||
| |-------------------|----------------------|-------------------------------------------------------------------------------| | ||
| | `items` | `FooterItemConfig[]` | `{ id, label?, action?, hidden? }`. An entry with the `id` of a default item changes it (`label`, `action`) or removes it (`hidden: true`); other entries are added after the defaults, in their order. | | ||
| | `replaceDefaults` | `boolean` | Use only the configured items. | | ||
| | `label` | `string` | Accessible name of the footer's navigation, default "Legal". | | ||
| | `texts` | `{ close?: string }` | Texts of the footer's dialog, e.g. for translations; `close` defaults to `config.cookies.texts.close`, then "Close". | | ||
|
|
||
| Items without an action, or whose link, address or embed URL isn't valid, aren't shown. | ||
|
|
||
| ### Default items | ||
|
|
||
| | ID | Label | Default action | | ||
| |----------------------|-------------------------|-----------------| | ||
| | `privacy-policy` | Privacy Policy | none | | ||
| | `terms` | Terms & Conditions | none | | ||
| | `cookie-policy` | Cookie Policy | none | | ||
| | `cookie-preferences` | Cookie Preferences | `preferences` | | ||
| | `imprint` | Imprint | none | | ||
| | `accessibility` | Accessibility Statement | none | | ||
| | `contact` | Contact Us | none | | ||
| | `privacy-choices` | Your Privacy Choices | none | | ||
|
|
||
| So without configuration, the footer shows only "Cookie Preferences"; each item that the operator gives an action appears, in this order. "Cookie Preferences" opens the preferences of the [cookie consent](/docs/design/components/cookie-consent) component, so add that component to the page too, or hide the item (`{ id: 'cookie-preferences', hidden: true }`). | ||
|
|
||
| ### Markdown pages | ||
|
|
||
| `page` items support a small subset of Markdown: headings (`#` to `###`), paragraphs, lists (`-`, `*`, `1.`), `**bold**`, `*emphasis*` and links (`[text](url)`, only `http(s)`, `mailto` and relative URLs; links to other origins open in a new tab). All text is escaped; HTML in the Markdown is shown as text, not rendered. | ||
|
|
||
| ### Embeds | ||
|
|
||
| - Only `https` URLs (and `http` on `localhost`) are embedded. The frame is sandboxed (`allow-scripts allow-same-origin allow-popups`, so pages it opens stay sandboxed; without `allow-same-origin` for pages of the app's own origin, which couldn't be isolated otherwise) and sends no referrer. For pages of your own, prefer `page` or `link`. | ||
| - Allow the embed's origin in the app's Content Security Policy (`frame-src`). | ||
| - An embed loads its frame when users open the item, whether or not they chose their cookies yet. Embeds whose provider sets cookies or tracks visitors (check the provider's documentation) must therefore name a cookie category in `consent`; until users consent to it, the dialog shows a note and a button to the preferences instead of the frame. | ||
| - Script-based embeds (a `<script>` tag that injects the policy) aren't supported, since configuration must not run code on the page. Most policy services also offer a page URL that can be embedded or linked. |
179 changes: 179 additions & 0 deletions
179
apps/documentation/pages/docs/design/components/cookie-consent.mdx
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 |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| import { Callout, Tabs } from 'nextra/components' | ||
| import ComponentName from '@/components/common/ComponentName' | ||
| import CookieConsent from '@elixir-cloud/design/react/cookie-consent'; | ||
| import ComplianceFooter from '@elixir-cloud/design/react/compliance-footer'; | ||
|
|
||
| # EccUtilsDesignCookieConsent Component | ||
|
|
||
| <ComponentName | ||
| tagName='ecc-utils-design-cookie-consent' | ||
| reactTagName='EccUtilsDesignCookieConsent' | ||
| version='2.0.0-alpha.50' | ||
| /> | ||
|
|
||
| ## Overview | ||
|
|
||
| The `ecc-utils-design-cookie-consent` component asks users for their consent to cookies (and similar storage, such as `localStorage`), per category, and lets them change their choice at any time. It works together with the [compliance footer](/docs/design/components/compliance-footer), which links to the preferences, and both read the same configuration, so an app adds them from configuration alone. | ||
|
|
||
| - **Banner** on the first visit, with "Accept all", "Necessary only" (as prominent as accepting) and "Customize". | ||
| - **Preferences** dialog with one switch per category; strictly necessary categories are always on. | ||
| - **Stored** in the browser (`localStorage`, key `ecc-cookie-consent`), and asked again when the configured categories or the `version` change. | ||
| - **Checks** for other code: `hasConsent('analytics')`, `onConsentChange(...)` and the `ecc-cookie-consent-changed` event on `window`. | ||
|
|
||
| <Callout type='warning'> | ||
| The component provides the mechanics only. Operators decide which categories their deployment needs and are responsible for the texts, the policies they link to, and for loading optional scripts only with consent (see [Loading scripts with consent](#loading-scripts-with-consent)). | ||
| </Callout> | ||
|
|
||
| Without optional categories, nothing needs consent and the banner isn't shown (unless `notice` is set); the preferences still list the necessary category, e.g. for the footer's "Cookie Preferences" item. | ||
|
|
||
| ## Preview | ||
|
|
||
| This page shows the banner until you choose (stored only for this demo); the footer's "Cookie Preferences" opens the preferences again. | ||
|
|
||
| <div className="mt-4 rounded-md border p-4"> | ||
| <ComplianceFooter config={{}} /> | ||
| <CookieConsent | ||
| config={{ | ||
| cookies: { | ||
| storageKey: 'ecc-docs-cookie-consent-demo', | ||
| categories: [ | ||
| { id: 'necessary', label: 'Necessary', required: true, description: 'Remembers these choices.' }, | ||
| { id: 'analytics', label: 'Analytics', description: 'Not used on this page; for the demo only.' }, | ||
| ], | ||
| }, | ||
| }} | ||
| /> | ||
| </div> | ||
|
|
||
| ## Usage | ||
|
|
||
| Add the component once per page, e.g. in the app's layout, next to the compliance footer: | ||
|
|
||
| <Tabs items={['React', 'HTML']}> | ||
| <Tabs.Tab> | ||
| ```tsx showLineNumbers copy filename="layout.tsx" | ||
| import EccUtilsDesignCookieConsent from '@elixir-cloud/design/react/cookie-consent'; | ||
| import EccUtilsDesignComplianceFooter from '@elixir-cloud/design/react/compliance-footer'; | ||
| import type { ComplianceConfig } from '@elixir-cloud/design/components/cookie-consent/index.js'; | ||
|
|
||
| // e.g. from the deployment's runtime settings | ||
| const compliance: ComplianceConfig = { | ||
| cookies: { | ||
| version: '2026-10', | ||
| policyUrl: '/cookies', | ||
| categories: [ | ||
| { id: 'necessary', label: 'Necessary', required: true, | ||
| description: 'Login and these choices.' }, | ||
| { id: 'analytics', label: 'Analytics', | ||
| description: 'Anonymous usage statistics.' }, | ||
| ], | ||
| }, | ||
| footer: { | ||
| items: [ | ||
| { id: 'privacy-policy', action: { type: 'link', href: '/privacy' } }, | ||
| { id: 'contact', action: { type: 'mailto', email: 'support@example.org' } }, | ||
| ], | ||
| }, | ||
| }; | ||
|
|
||
| export default function Layout({ children }) { | ||
| return ( | ||
| <> | ||
| {children} | ||
| <footer> | ||
| <EccUtilsDesignComplianceFooter config={compliance} /> | ||
| </footer> | ||
| <EccUtilsDesignCookieConsent | ||
| config={compliance} | ||
| onEccCookieConsentChanged={(event) => | ||
| console.log(event.detail.consent.categories) | ||
| } | ||
| /> | ||
| </> | ||
| ); | ||
| } | ||
| ``` | ||
| </Tabs.Tab> | ||
| <Tabs.Tab> | ||
| ```html showLineNumbers copy filename="index.html" | ||
| <script type="module" src="https://esm.sh/@elixir-cloud/design@2.0.0-alpha.50/components/cookie-consent"></script> | ||
| <ecc-utils-design-cookie-consent></ecc-utils-design-cookie-consent> | ||
| <script type="module"> | ||
| document.querySelector('ecc-utils-design-cookie-consent').config = { | ||
| cookies: { | ||
| categories: [{ id: 'analytics', label: 'Analytics' }], | ||
| }, | ||
| }; | ||
| </script> | ||
| ``` | ||
| </Tabs.Tab> | ||
| </Tabs> | ||
|
|
||
| ## Properties | ||
|
|
||
| | Property | Type | Default | Description | | ||
| |----------|--------------------|---------|-----------------------------------------------------------------------------| | ||
| | `config` | `ComplianceConfig` | `{}` | Shared compliance configuration; this component uses `config.cookies`. A new object with the same categories, `version`, `storageKey` and `notice` keeps the choices users haven't saved yet, so it may be created on every render. | | ||
|
|
||
| ## Methods | ||
|
|
||
| | Method | Description | | ||
| |---------------------|--------------------------------------------------------------------| | ||
| | `openPreferences()` | Opens the preferences. `openCookiePreferences()` does the same from anywhere on the page. | | ||
|
|
||
| ## Events | ||
|
|
||
| | Event Name | React Event Name | Detail Type | Description | | ||
| |------------------------------|-----------------------------|------------------------------|----------------------------------------------| | ||
| | `ecc-cookie-consent-changed` | `onEccCookieConsentChanged` | `{ consent: CookieConsent }` | Fired when the user saved their choices. It doesn't bubble; `saveConsent()` fires the same event on `window`. | | ||
|
|
||
| ## Configuration (`config.cookies`) | ||
|
|
||
| | Field | Type | Description | | ||
| |--------------|-------------------------------|-----------------------------------------------------------------------------| | ||
| | `categories` | `CookieCategory[]` | `{ id, label, description?, required? }`. A required "necessary" category is added if none is required. | | ||
| | `version` | `string` | Version of the cookie policy; changing it asks users again. | | ||
| | `policyUrl` | `string` | Link to the cookie policy (`http(s)` or relative), shown in the banner and the preferences. | | ||
| | `notice` | `boolean` | Show the banner although there are no optional categories. | | ||
| | `storageKey` | `string` | `localStorage` key, default `ecc-cookie-consent`. | | ||
| | `texts` | `Partial<CookieConsentTexts>` | Texts of the banner and the preferences, e.g. for translations: `bannerTitle`, `bannerText`, `policyLink`, `acceptAll`, `rejectAll`, `customize`, `preferencesTitle`, `preferencesText`, `save`, `close`, `alwaysOn`, `embedConsent`. | | ||
|
|
||
| Changing a category's `id` or `required`, adding or removing a category, or changing `version` asks users again; changing texts or descriptions doesn't. | ||
|
|
||
| ## Loading scripts with consent | ||
|
|
||
| The functions below come from `@elixir-cloud/design/components/cookie-consent/index.js` (also exported by the package's main entry). Pass the same `cookies` configuration that the component uses. | ||
|
|
||
| ```ts copy | ||
| import { | ||
| hasConsent, | ||
| onConsentChange, | ||
| openCookiePreferences, | ||
| } from '@elixir-cloud/design/components/cookie-consent/index.js'; | ||
|
|
||
| const loadAnalytics = () => { /* add the analytics script */ }; | ||
|
|
||
| if (hasConsent('analytics', compliance.cookies)) loadAnalytics(); | ||
| onConsentChange((consent) => { | ||
| if (consent?.categories.analytics) loadAnalytics(); | ||
| // revoking consent: stop tracking and remove its cookies, or reload | ||
| }, compliance.cookies); | ||
|
|
||
| // e.g. from a link of your own | ||
| openCookiePreferences(); | ||
| ``` | ||
|
|
||
| | Function | Description | | ||
| |----------------------------------------|-----------------------------------------------------------------| | ||
| | `hasConsent(category, config?)` | Whether users consented to a category (required ones: always). | | ||
| | `readConsent(config?)` | The stored `CookieConsent`, or `null` if users must be asked. | | ||
| | `saveConsent(config, choices)` | Stores choices and fires `ecc-cookie-consent-changed` on `window`. | | ||
| | `onConsentChange(listener, config?)` | Calls `listener` on every change for the storage key of `config`, in this page and in other tabs (with `null` if that consent was removed); returns a function to stop. | | ||
| | `openCookiePreferences()` | Asks the page's component to open the preferences. | | ||
|
|
||
| ## Notes for operators | ||
|
|
||
| - List every category that your deployment uses, with a description users understand. Keep "Necessary" for what the site can't work without (e.g. login cookies); these need no consent under the GDPR and ePrivacy rules, but should be explained in the cookie policy. | ||
| - Bump `version` when the cookie policy changes in a way users should see. | ||
| - Provide the cookie policy (`policyUrl`, or a footer item) and load optional scripts only after `hasConsent()`. | ||
| - Consent is stored per browser and origin; it isn't sent to a server. If the browser can't store it, it applies to the current page only. | ||
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.
Uh oh!
There was an error while loading. Please reload this page.