Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 49 additions & 22 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Your extension will then appear in the consent UI, and optional scripts will sta
- [`consentManager.getState()`](#consentmanagergetstate)
- [`window.phpbbConsentManagerPayload`](#windowphpbbconsentmanagerpayload)
- [Embedded `<iframe>` media patterns](#embedded-iframe-media-patterns)
- [Google Consent Mode](#google-consent-mode)
- [Examples of Consent Manager integrations](#examples-of-consent-manager-integrations)

## Strategy guide
Expand Down Expand Up @@ -642,34 +643,67 @@ Use this pattern for:
- ACP/custom-code features that inject raw HTML into pages
- integrations that output iframe markup after phpBB's bbcode and Twig rendering have already finished

## Google Consent Mode

Consent Manager handles Google Consent Mode automatically when optional consent categories are enabled.

It maps Consent Manager categories to Google consent types:

| Consent Manager category | Google consent type(s) |
|--------------------------|----------------------------------------------------|
| `analytics` | `analytics_storage` |
| `marketing` | `ad_storage`, `ad_user_data`, `ad_personalization` |

Consent Manager sets Google consent defaults to `denied` before later header scripts should run, then updates the current state from the user's stored consent. When the user changes consent, Consent Manager updates Google Consent Mode before activating newly consented deferred scripts.

Extensions that use Google tags should not implement their own Google Consent Mode bridge unless they have a specialized need. Register the service with Consent Manager and let Consent Manager publish the consent state to Google.

Extensions that initialize `gtag` should preserve any existing `window.gtag` function created by Consent Manager or another extension:

```js
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function(){window.dataLayer.push(arguments);};
gtag('js', new Date());
```

Avoid global function declarations such as `function gtag(){dataLayer.push(arguments);}` in extension templates. They usually still work, but can replace an existing `window.gtag` wrapper unnecessarily.

Template ordering matters. If your extension outputs Google tag code or any script that relies on Google Consent Mode, place it in a later template event than Consent Manager's `overall_header_head_append.html`, for example:

- `overall_header_stylesheets_after`
- later `overall_header_*` events
- `overall_footer_*` events

Do not place Google tag code in an earlier event such as `overall_header_feeds`, because Consent Manager may not have set the default consent state yet.

## Examples of Consent Manager integrations

### phpBB Google Analytics Extension
### Example Analytics Extension

For this extension, which adds a Google Analytics code snippet to the page, Pattern 2 was the best choice.
For this extension, which adds a generic analytics code snippet to the page, Pattern 2 was the best choice.

The following PHP registration was added to the extension's event listener class:

```php
/**
* Register Google Analytics with Consent Manager when available.
* Register Example Analytics with the Consent Manager when available.
*
* @param \phpbb\event\data|array $event The event object or event data
* @return void
*/
public function register_analytics($event)
{
if (!$this->config['googleanalytics_id'])
if (!$this->config['example_analytics_id'])
{
return;
}

$this->language->add_lang('common', 'phpbb/googleanalytics');
$this->language->add_lang('common', 'vendor/exampleanalytics');

$event['consent_manager']->register('phpbb.googleanalytics', [
'label' => $this->language->lang('GOOGLEANALYTICS_LABEL'),
$event['consent_manager']->register('vendor.exampleanalytics', [
'label' => $this->language->lang('EXAMPLE_ANALYTICS_LABEL'),
'category' => 'analytics',
'description' => $this->language->lang('GOOGLEANALYTICS_DESCRIPTION'),
'description' => $this->language->lang('EXAMPLE_ANALYTICS_DESCRIPTION'),
]);
}
```
Expand All @@ -679,26 +713,19 @@ public function register_analytics($event)
The following placeholder changes were made to its `script` tags in its template file:

```twig
{% if GOOGLEANALYTICS_ID %}
<!-- Google tag (gtag.js) - Google Analytics -->
<script{% if S_CONSENTMANAGER_ANALYTICS_ENABLED %} type="text/plain" data-consent-category="analytics"{% endif %} async src="https://www.googletagmanager.com/gtag/js?id={{ GOOGLEANALYTICS_ID }}"></script>
{% if EXAMPLE_ANALYTICS_ID %}
<script{% if S_CONSENTMANAGER_ANALYTICS_ENABLED %} type="text/plain" data-consent-category="analytics"{% endif %} src="https://analytics.example.com/tracker.js?id={{ EXAMPLE_ANALYTICS_ID }}" async></script>
<script{% if S_CONSENTMANAGER_ANALYTICS_ENABLED %} type="text/plain" data-consent-category="analytics"{% endif %}>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

gtag('config', '{{ GOOGLEANALYTICS_ID }}', {
{%- EVENT phpbb_googleanalytics_gtag_options -%}
{%- if S_REGISTERED_USER %}'user_id': '{{ GOOGLEANALYTICS_USER_ID }}',{% endif -%}
{%- if S_ANONYMIZE_IP %}'anonymize_ip': true,{% endif -%}
{%- if S_COOKIE_SECURE -%}'cookie_flags': 'samesite=none;secure',{%- endif -%}
});
window.exampleAnalytics = window.exampleAnalytics || [];
window.exampleAnalytics.push(['init', '{{ EXAMPLE_ANALYTICS_ID|e('js') }}']);
window.exampleAnalytics.push(['trackPageView']);
</script>
{% endif %}
```

We used the `S_CONSENTMANAGER_ANALYTICS_ENABLED` flag and the `data-consent-category="analytics"` attribute to tell Consent Manager when it may activate the script.

These changes ensure that Google Analytics appears in the Consent UI to the user, and that its scripts only run when consent is granted.
These changes ensure that Example Analytics appears in the Consent UI to the user, and that its scripts only run when analytics consent is granted.

---

Expand Down
30 changes: 30 additions & 0 deletions service/consent_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ public function build_frontend_payload($log_url, $log_hash)
'enabledCategories' => $this->get_enabled_category_ids($categories),
'optionalCategories' => $this->get_optional_category_ids($categories),
'categories' => $this->get_frontend_payload_categories($categories),
'googleConsentMode' => $this->get_google_consent_mode_config($categories),
'scripts' => array_values($scripts),
'logEndpoint' => $log_url,
'logHash' => $log_hash,
Expand Down Expand Up @@ -746,6 +747,35 @@ protected function get_enabled_category_ids(array $categories)
return array_values($enabled_categories);
}

/**
* Return the Google Consent Mode consent type to category mapping.
*
* @param array $categories Category metadata
*
* @return array
*/
protected function get_google_consent_mode_config(array $categories)
{
$types = [];

if (!empty($categories[self::ANALYTICS_CATEGORY]['enabled']))
{
$types['analytics_storage'] = self::ANALYTICS_CATEGORY;
}

if (!empty($categories[self::MARKETING_CATEGORY]['enabled']))
{
$types['ad_storage'] = self::MARKETING_CATEGORY;
$types['ad_user_data'] = self::MARKETING_CATEGORY;
$types['ad_personalization'] = self::MARKETING_CATEGORY;
}

return [
'enabled' => !empty($types),
'types' => $types,
];
}

/**
* Return category ids that are optional and enabled.
*
Expand Down
28 changes: 28 additions & 0 deletions styles/all/template/event/overall_header_head_append.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
const payload = {{ CONSENTMANAGER_PAYLOAD|raw }};
const requiredCategories = payload.requiredCategories || [];
const enabledCategories = payload.enabledCategories || [];
const googleConsentMode = payload.googleConsentMode || {};
const googleConsentTypes = googleConsentMode.types || {};

window.phpbbConsentManagerPayload = payload;
window.phpbbConsentManagerLang = {
Expand Down Expand Up @@ -110,6 +112,32 @@
return hasValue(requiredCategories, category) || !!(state && hasValue(enabledCategories, category) && state.categories.indexOf(category) !== -1);
};
window.consentManager = stub;

if (googleConsentMode.enabled)
{
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function(){window.dataLayer.push(arguments);};

const defaults = {};
const updates = {};
let hasConsentTypes = false;

for (const consentType in googleConsentTypes)
{
if (Object.prototype.hasOwnProperty.call(googleConsentTypes, consentType))
{
defaults[consentType] = 'denied';
updates[consentType] = stub.hasConsent(googleConsentTypes[consentType]) ? 'granted' : 'denied';
hasConsentTypes = true;
}
}

if (hasConsentTypes)
{
window.gtag('consent', 'default', defaults);
window.gtag('consent', 'update', updates);
}
}
})(window, document);
</script>
{% INCLUDEJS '@phpbb_consentmanager/js/consentmanager.js' %}
Expand Down
45 changes: 45 additions & 0 deletions styles/all/template/js/consentmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
const categoriesById = {};
const deferredSelector = 'script[type="text/plain"][data-consent-category]';
const deferredEmbedSelector = '[data-consent-media-container][data-consent-category]';
const googleConsentMode = payload.googleConsentMode || {};
const googleConsentTypes = googleConsentMode.types || {};
let requiredCategories = [];
let enabledCategories = [];
let optionalCategories = [];
Expand Down Expand Up @@ -322,6 +324,45 @@
}
}

function buildGoogleConsentModeState()
{
const consentState = {};

if (!googleConsentMode.enabled)
{
return consentState;
}

for (const consentType in googleConsentTypes)
{
if (Object.prototype.hasOwnProperty.call(googleConsentTypes, consentType))
{
consentState[consentType] = hasConsent(googleConsentTypes[consentType]) ? 'granted' : 'denied';
}
}

return consentState;
}

function applyGoogleConsentMode()
{
if (!googleConsentMode.enabled || typeof window.gtag !== 'function')
{
return;
}

const consentState = buildGoogleConsentModeState();

for (const consentType in consentState)
{
if (Object.prototype.hasOwnProperty.call(consentState, consentType))
{
window.gtag('consent', 'update', consentState);
return;
}
}
}

function sameCategories(left, right)
{
return left.join('|') === right.join('|');
Expand Down Expand Up @@ -460,12 +501,14 @@
state.timestamp = nextState.timestamp;
persistState(state);
updateUi();
applyGoogleConsentMode();
return;
}

state = nextState;
persistState(state);
updateUi();
applyGoogleConsentMode();
processRegisteredScripts();
processDeferredNodes(document);
processDeferredEmbeds(document);
Expand Down Expand Up @@ -1143,6 +1186,8 @@

window.consentManager = api;

applyGoogleConsentMode();

for (let i = 0; i < payload.scripts.length; i++)
{
registerScript(payload.scripts[i].id, payload.scripts[i]);
Expand Down
52 changes: 52 additions & 0 deletions tests/javascript/consentmanager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ function createPayload(overrides) {
requiredCategories: [ 'necessary' ],
enabledCategories: [ 'necessary', 'analytics', 'marketing', 'media' ],
optionalCategories: [ 'analytics', 'marketing', 'media' ],
googleConsentMode: {
enabled: true,
types: {
analytics_storage: 'analytics',
ad_storage: 'marketing',
ad_user_data: 'marketing',
ad_personalization: 'marketing'
}
},
scripts: []
}, overrides || {});
}
Expand Down Expand Up @@ -104,6 +113,7 @@ function setupConsentManager(options) {
});
const { window } = dom;
const requests = [];
const gtagCalls = settings.gtagCalls || [];

Object.defineProperty(window.document, 'readyState', {
configurable: true,
Expand Down Expand Up @@ -150,6 +160,13 @@ function setupConsentManager(options) {
window.consentManager = settings.queue;
}

if (settings.withGtag) {
window.gtagCalls = gtagCalls;
window.gtag = function() {
gtagCalls.push(Array.prototype.slice.call(arguments));
};
}

if (settings.localState) {
window.localStorage.setItem(payload.storageKey, JSON.stringify(settings.localState));
}
Expand All @@ -172,6 +189,7 @@ function setupConsentManager(options) {
document: window.document,
payload,
requests,
gtagCalls,
jsdomErrors
};
}
Expand Down Expand Up @@ -245,6 +263,40 @@ test('accept-all persists consent, logs the decision, and updates the UI state',
});
});

test('updates Google consent mode before activating newly consented marketing scripts', () => {
const { window, gtagCalls } = setupConsentManager({
withGtag: true,
extraMarkup: `
<script type="text/plain" data-consent-category="marketing">
window.marketingConsentAtExecution = window.gtagCalls[window.gtagCalls.length - 1];
</script>
`
});

click(window, '[data-consent-action="accept-all"]');

expect(gtagCalls[0]).toEqual([
'consent',
'update',
{
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied'
}
]);
expect(window.marketingConsentAtExecution).toEqual([
'consent',
'update',
{
analytics_storage: 'granted',
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted'
}
]);
});

test('reject-all logs the updated decision before reloading when consent is revoked', () => {
const { window, payload, requests, jsdomErrors } = setupConsentManager({
localState: createState([ 'necessary', 'analytics', 'marketing', 'media' ], '2026-04-28T00:00:00.000Z')
Expand Down
15 changes: 15 additions & 0 deletions tests/service/consent_manager_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,12 @@ public function test_build_frontend_payload_collects_registered_and_configured_i
self::assertSame('/app.php/consent/log', $payload['logEndpoint']);
self::assertSame('deadbeef', $payload['logHash']);
self::assertArrayNotHasKey('services', $payload);
self::assertSame(array(
'enabled' => true,
'types' => array(
'analytics_storage' => 'analytics',
),
), $payload['googleConsentMode']);
self::assertSame(array('vendor.bundle.loader', 'board.analytics'), array_column($payload['scripts'], 'id'));
}

Expand All @@ -658,6 +664,15 @@ public function test_get_frontend_template_data_returns_json_payload()
self::assertSame($this->language->lang('CONSENTMANAGER_DEFAULT_BANNER_SUBTEXT'), $data['CONSENTMANAGER_BANNER_SUBTEXT']);
self::assertSame('/app.php/consent/log?x=<test>', $payload['logEndpoint']);
self::assertSame('abc123', $payload['logHash']);
self::assertSame(array(
'enabled' => true,
'types' => array(
'analytics_storage' => 'analytics',
'ad_storage' => 'marketing',
'ad_user_data' => 'marketing',
'ad_personalization' => 'marketing',
),
), $payload['googleConsentMode']);
self::assertArrayNotHasKey('label', $payload['categories'][0]);
self::assertArrayNotHasKey('description', $payload['categories'][0]);
self::assertArrayNotHasKey('banner', $payload);
Expand Down
Loading