Skip to content

Commit 345ca46

Browse files
Fix GDPR cookie consent manager (#1072)
## Summary Comprehensive overhaul of the cookie consent manager to fix GDPR compliance issues, bugs, and a broken Google Analytics integration. ## Changes ### Strategy change - **Show consent banner to all visitors** — removes unreliable timezone-based region detection (VPN bypass gaps, missing timezones, ongoing maintenance burden). One-time banner click for non-EEA users is a worthwhile tradeoff for zero compliance liability. ### Bug fixes - **Clarity denied signal for new visitors** — `updateClarityConsent()` was never called for first-time visitors (only on returning visitors and after interaction). Now called unconditionally in `init()` so Clarity always receives the current consent state on page load. - **`personalization_storage` mis-grouped** — was bound to `analyticsChecked` in `saveCustomPreferences()`, should be `advertisingChecked`. - **`gtag('config')` firing twice** — removed duplicate call; now fires unconditionally once per Google Consent Mode v2 "Advanced" pattern (GA handles denied state with cookieless modeled pings). - **Duplicate `gtag('consent', 'default')`** — removed from `initGoogleConsentMode()`; `_Layout.cshtml` is the single owner (required to fire before `gtag.js` loads). - **`clearTrackingCookies()` didn't clear domain-scoped cookies** — now deletes on exact hostname, root domain, and no-domain for full GA/Clarity cookie coverage. - **`revokeAllConsent()` called conflicting Clarity v1 API** — removed `clarity('consent', false)`; `consentv2` handles it. ### Improvements - **`wait_for_update: 500`** added to `gtag('consent', 'default')` — prevents GA from firing pings before the consent banner has a chance to render. - **`CONSENT_VERSION = '2'`** — bumping this constant re-prompts all existing users. Stale cookies are invalidated on load. - **`_timestamp` + `_version` stored in consent cookie** — GDPR Art. 7(1) audit trail. - **`Secure` flag** on consent cookie (HTTPS only). - **Privacy policy link** added to consent banner text. - **`functionality_storage`/`security_storage` protected** — can no longer be overridden via cookie tampering. - **`openConsentPreferences()` setTimeout removed** — direct synchronous call. ## Testing Use `?testConsent=true` to force the banner regardless of existing consent cookie.
1 parent 49d5e23 commit 345ca46

2 files changed

Lines changed: 92 additions & 120 deletions

File tree

EssentialCSharp.Web/Views/Shared/_Layout.cshtml

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,26 @@
6464
</script>
6565

6666
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css">
67-
<!-- Google tag (gtag.js) - Will be activated based on consent -->
68-
<script async src="https://www.googletagmanager.com/gtag/js?id=G-761B4BMK2R"></script>
67+
<!-- Google tag (gtag.js) - Consent defaults MUST be set before gtag.js loads (Google Consent Mode v2 requirement) -->
6968
<script>
7069
window.dataLayer = window.dataLayer || [];
71-
function gtag() { dataLayer.push(arguments); }
72-
73-
// Initialize gtag but don't configure until consent is given
74-
gtag('js', new Date());
75-
76-
// Configuration will be handled by consent manager
77-
// Listen for consent manager initialization event
78-
document.addEventListener('consentManagerReady', function(event) {
79-
if (event.detail.hasAnalyticsConsent || !event.detail.requiresConsent) {
80-
gtag('config', 'G-761B4BMK2R');
81-
}
70+
function gtag(){dataLayer.push(arguments);}
71+
gtag('consent', 'default', {
72+
analytics_storage: 'denied',
73+
ad_storage: 'denied',
74+
ad_user_data: 'denied',
75+
ad_personalization: 'denied',
76+
functionality_storage: 'granted',
77+
security_storage: 'granted',
78+
personalization_storage: 'denied',
79+
wait_for_update: 500 // ms to hold GA pings while consent banner loads
8280
});
81+
gtag('js', new Date());
82+
// Fire unconditionally — Consent Mode v2 handles denied state with cookieless modeled pings.
83+
// Cookies are only set after gtag('consent', 'update', { granted }) is called by consent-manager.js.
84+
gtag('config', 'G-761B4BMK2R');
8385
</script>
86+
<script async src="https://www.googletagmanager.com/gtag/js?id=G-761B4BMK2R"></script>
8487
<script src="~/js/hcaptcha-form.js" asp-append-version="true"></script>
8588
<!-- hCaptcha Script -->
8689
<script src="https://js.hcaptcha.com/1/api.js?render=explicit&onload=ecsOnHcaptchaLoad" async defer></script>

EssentialCSharp.Web/wwwroot/js/consent-manager.js

Lines changed: 76 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
/**
22
* Cookie Consent Manager for Essential C#
33
* Implements Google Consent Mode v2 for Microsoft Clarity and Google Analytics
4-
* Compliant with GDPR requirements for EEA, UK, and Switzerland
4+
* Shown to all visitors — ensures GDPR compliance globally without fragile region detection.
55
*/
66

77
class ConsentManager {
8-
constructor(options = {}) {
8+
constructor() {
99
this.COOKIE_NAME = 'essential-csharp-consent';
1010
this.COOKIE_DURATION = 365; // days
11-
this.GOOGLE_ANALYTICS_ID = options.googleAnalyticsId || 'G-761B4BMK2R';
11+
this.CONSENT_VERSION = '2'; // Bump this to re-prompt all users when consent terms change
1212
this.consentState = {
1313
analytics_storage: 'denied',
1414
ad_storage: 'denied',
@@ -19,100 +19,49 @@ class ConsentManager {
1919
personalization_storage: 'denied'
2020
};
2121

22-
// Check if user is in EEA/UK/Switzerland region
23-
this.requiresConsent = this.checkRegionRequiresConsent();
24-
2522
this.init();
2623
}
2724

2825
init() {
29-
// Initialize Google Consent Mode
3026
this.initGoogleConsentMode();
3127

32-
// Load saved consent preferences
28+
// Load saved consent preferences (signals GA if already consented)
3329
this.loadConsentPreferences();
30+
31+
// Always send Clarity the current consent state (denied by default for new visitors).
32+
// Clarity's polyfill queues this call and delivers it when the script loads.
33+
this.updateClarityConsent();
3434

35-
// Show banner if consent required and not yet given
35+
// Show banner if no valid consent stored
3636
if (this.shouldShowConsentBanner()) {
3737
this.showConsentBanner();
3838
}
39-
40-
// Dispatch initialization event for other scripts to listen to
41-
this.dispatchInitializationEvent();
42-
}
43-
44-
dispatchInitializationEvent() {
45-
// Create and dispatch custom event to signal consent manager is ready
46-
const event = new CustomEvent('consentManagerReady', {
47-
detail: {
48-
hasAnalyticsConsent: this.hasAnalyticsConsent(),
49-
hasAdvertisingConsent: this.hasAdvertisingConsent(),
50-
requiresConsent: this.requiresConsent
51-
}
52-
});
53-
document.dispatchEvent(event);
5439
}
5540

5641
initGoogleConsentMode() {
57-
// Initialize gtag if not already loaded
42+
// Ensure gtag infrastructure exists — the actual 'consent default' is set inline
43+
// in _Layout.cshtml before gtag.js loads (required by Google Consent Mode v2).
5844
window.dataLayer = window.dataLayer || [];
59-
function gtag(){dataLayer.push(arguments);}
60-
window.gtag = window.gtag || gtag;
61-
62-
// Set default consent state - denial for all except essential
63-
gtag('consent', 'default', this.consentState);
64-
}
65-
66-
checkRegionRequiresConsent() {
67-
// Check for forced testing via URL parameter
68-
const urlParams = new URLSearchParams(window.location.search);
69-
if (urlParams.get('testConsent') === 'true') {
70-
return true;
71-
}
72-
73-
// Timezone-based region detection for GDPR compliance
74-
// Users can change timezones, but this provides reasonable detection for most cases
75-
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
76-
77-
// EEA countries, UK, and Switzerland timezones
78-
const requiresConsentTimezones = [
79-
// Western Europe
80-
'Europe/London', 'Europe/Dublin', 'Europe/Lisbon', 'Europe/Madrid',
81-
'Europe/Paris', 'Europe/Amsterdam', 'Europe/Brussels', 'Europe/Luxembourg',
82-
'Europe/Zurich', 'Europe/Vienna', 'Europe/Rome', 'Europe/Vatican',
83-
'Europe/San_Marino', 'Europe/Malta', 'Europe/Monaco',
84-
85-
// Central Europe
86-
'Europe/Berlin', 'Europe/Prague', 'Europe/Budapest', 'Europe/Warsaw',
87-
'Europe/Bratislava', 'Europe/Ljubljana', 'Europe/Zagreb', 'Europe/Belgrade',
88-
'Europe/Sarajevo', 'Europe/Podgorica', 'Europe/Skopje', 'Europe/Tirane',
89-
90-
// Northern Europe
91-
'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Helsinki',
92-
'Europe/Tallinn', 'Europe/Riga', 'Europe/Vilnius', 'Europe/Reykjavik',
93-
94-
// Eastern Europe
95-
'Europe/Bucharest', 'Europe/Sofia', 'Europe/Athens', 'Europe/Nicosia',
96-
97-
// Additional EEA territories
98-
'Atlantic/Canary', 'Atlantic/Madeira', 'Atlantic/Azores',
99-
'Europe/Gibraltar', 'Africa/Ceuta'
100-
];
101-
102-
return requiresConsentTimezones.includes(timezone);
45+
window.gtag = window.gtag || function(){window.dataLayer.push(arguments);};
10346
}
10447

10548
loadConsentPreferences() {
10649
const saved = this.getCookie(this.COOKIE_NAME);
10750
if (saved) {
10851
try {
10952
const preferences = JSON.parse(saved);
53+
54+
// Invalidate stale consent if the version has changed — treat user as new visitor
55+
if (preferences._version !== this.CONSENT_VERSION) {
56+
this.deleteCookie(this.COOKIE_NAME);
57+
return;
58+
}
11059

111-
// Validate and only apply known consent properties for security
60+
// Validate and only apply known consent properties for security.
61+
// Exclude functionality_storage and security_storage — always essential, never user-overrideable.
11262
const validConsentKeys = [
11363
'analytics_storage', 'ad_storage', 'ad_user_data',
114-
'ad_personalization', 'functionality_storage',
115-
'security_storage', 'personalization_storage'
64+
'ad_personalization', 'personalization_storage'
11665
];
11766

11867
const validatedPreferences = {};
@@ -126,14 +75,19 @@ class ConsentManager {
12675
this.consentState = { ...this.consentState, ...validatedPreferences };
12776
this.updateConsentMode();
12877
} catch (e) {
78+
// Malformed cookie — delete it so the banner is shown again
12979
console.warn('Failed to parse consent preferences', e);
80+
this.deleteCookie(this.COOKIE_NAME);
13081
}
13182
}
13283
}
13384

13485
shouldShowConsentBanner() {
135-
// Show banner if in EEA/UK/Switzerland and no consent stored
136-
return this.requiresConsent && !this.getCookie(this.COOKIE_NAME);
86+
// Allow forcing the banner via URL param (useful for testing)
87+
const urlParams = new URLSearchParams(window.location.search);
88+
if (urlParams.get('testConsent') === 'true') return true;
89+
// Show to all visitors who haven't given valid consent yet
90+
return !this.getCookie(this.COOKIE_NAME);
13791
}
13892

13993
showConsentBanner() {
@@ -154,7 +108,7 @@ class ConsentManager {
154108
<div class="consent-banner-content">
155109
<div class="consent-banner-text">
156110
<h3>Cookie Preferences</h3>
157-
<p>We use cookies to improve your experience and analyze website usage. You can manage your preferences below.</p>
111+
<p>We use cookies to improve your experience and analyze website usage. See our <a href="https://intellitect.com/about/privacy-policy/" target="_blank" rel="noopener noreferrer">Privacy Policy</a> for details.</p>
158112
</div>
159113
<div class="consent-banner-actions">
160114
<button id="consent-reject-all" class="btn btn-outline-secondary me-2">Reject All</button>
@@ -188,8 +142,8 @@ class ConsentManager {
188142
<input type="checkbox" id="consent-advertising">
189143
<span class="consent-slider"></span>
190144
<div class="consent-info">
191-
<strong>Advertising Cookies</strong>
192-
<p>Used to deliver relevant advertisements and measure their effectiveness.</p>
145+
<strong>Google Signals</strong>
146+
<p>Allows Google to associate your visit with your Google account for analytics modeling and cross-site measurement. No advertisements are served on this site, but Google may use this data across its services.</p>
193147
</div>
194148
</label>
195149
</div>
@@ -226,7 +180,7 @@ class ConsentManager {
226180
showCustomizeOptions() {
227181
const details = document.getElementById('consent-details');
228182
if (details) {
229-
details.style.display = details.style.display === 'none' ? 'block' : 'none';
183+
details.style.display = 'block';
230184

231185
// Load current preferences into checkboxes
232186
document.getElementById('consent-analytics').checked =
@@ -272,15 +226,20 @@ class ConsentManager {
272226
ad_storage: advertisingChecked ? 'granted' : 'denied',
273227
ad_user_data: advertisingChecked ? 'granted' : 'denied',
274228
ad_personalization: advertisingChecked ? 'granted' : 'denied',
275-
personalization_storage: analyticsChecked ? 'granted' : 'denied'
229+
personalization_storage: advertisingChecked ? 'granted' : 'denied'
276230
};
277231

278232
this.saveConsentAndClose();
279233
}
280234

281235
saveConsentAndClose() {
282-
// Save consent to cookie
283-
this.setCookie(this.COOKIE_NAME, JSON.stringify(this.consentState), this.COOKIE_DURATION);
236+
// Save consent with audit metadata
237+
const payload = {
238+
...this.consentState,
239+
_timestamp: new Date().toISOString(),
240+
_version: this.CONSENT_VERSION
241+
};
242+
this.setCookie(this.COOKIE_NAME, JSON.stringify(payload), this.COOKIE_DURATION);
284243

285244
// Update consent mode
286245
this.updateConsentMode();
@@ -295,12 +254,7 @@ class ConsentManager {
295254
updateConsentMode() {
296255
if (window.gtag) {
297256
try {
298-
gtag('consent', 'update', this.consentState);
299-
300-
// Configure Google Analytics if analytics consent is granted
301-
if (this.consentState.analytics_storage === 'granted') {
302-
gtag('config', 'G-761B4BMK2R');
303-
}
257+
window.gtag('consent', 'update', this.consentState);
304258
} catch (error) {
305259
console.warn('Failed to update Google Consent Mode:', error);
306260
}
@@ -311,7 +265,7 @@ class ConsentManager {
311265
// Send consent signal to Microsoft Clarity using Consent API v2
312266
if (window.clarity) {
313267
try {
314-
clarity('consentv2', {
268+
window.clarity('consentv2', {
315269
ad_storage: this.consentState.ad_storage,
316270
analytics_storage: this.consentState.analytics_storage
317271
});
@@ -439,7 +393,13 @@ class ConsentManager {
439393
setCookie(name, value, days) {
440394
const expires = new Date();
441395
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
442-
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
396+
const secure = window.location.protocol === 'https:' ? ';Secure' : '';
397+
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires.toUTCString()};path=/;SameSite=Lax${secure}`;
398+
}
399+
400+
deleteCookie(name) {
401+
const secure = window.location.protocol === 'https:' ? ';Secure' : '';
402+
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax${secure}`;
443403
}
444404

445405
getCookie(name) {
@@ -448,20 +408,15 @@ class ConsentManager {
448408
for (let i = 0; i < ca.length; i++) {
449409
let c = ca[i];
450410
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
451-
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
411+
if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length));
452412
}
453413
return null;
454414
}
455415

456416
// Public API for consent preference management
457417
openConsentPreferences() {
458418
this.showConsentBanner();
459-
// If banner already exists, show customize options
460-
setTimeout(() => {
461-
if (document.getElementById('consent-banner')) {
462-
this.showCustomizeOptions();
463-
}
464-
}, 100);
419+
this.showCustomizeOptions();
465420
}
466421

467422
// Check current consent status
@@ -478,28 +433,42 @@ class ConsentManager {
478433
this.rejectAllConsent();
479434
// Also clear any existing tracking cookies
480435
this.clearTrackingCookies();
481-
// Erase Clarity cookies according to documentation
482-
if (window.clarity) {
483-
clarity('consent', false);
484-
}
436+
// Note: Clarity consent signal is sent via updateClarityConsent() inside rejectAllConsent() → saveConsentAndClose()
485437
}
486438

487439
clearTrackingCookies() {
488440
// Clear common tracking cookies (Google Analytics and Microsoft Clarity)
489441
const trackingCookies = ['_ga', '_gid', '_gat', '_clck', '_clsk', 'CLID', 'ANONCHK', 'MR', 'MUID', 'SM'];
490-
trackingCookies.forEach(cookieName => {
491-
document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
442+
const expired = 'expires=Thu, 01 Jan 1970 00:00:00 GMT';
443+
const hostname = window.location.hostname;
444+
// Build candidate domains: exact host plus progressively shorter parent domains.
445+
// This handles multi-part TLDs (e.g. .co.uk) by trying all suffixes.
446+
const parts = hostname.split('.');
447+
const domains = [hostname];
448+
for (let i = 0; i < parts.length - 1; i++) {
449+
domains.push('.' + parts.slice(i).join('.'));
450+
}
451+
452+
// Also collect GA4 measurement-ID cookies (_ga_XXXXXXXX) from document.cookie
453+
// since their suffix changes per-property and can't be hardcoded.
454+
const ga4Cookies = document.cookie.split(';')
455+
.map(c => c.trim().split('=')[0])
456+
.filter(name => name.startsWith('_ga_'));
457+
const allCookies = [...new Set([...trackingCookies, ...ga4Cookies])];
458+
459+
allCookies.forEach(cookieName => {
460+
const secure = window.location.protocol === 'https:' ? ';Secure' : '';
461+
document.cookie = `${cookieName}=;${expired};path=/${secure}`;
462+
domains.forEach(domain => {
463+
document.cookie = `${cookieName}=;${expired};path=/;domain=${domain}${secure}`;
464+
});
492465
});
493466
}
494467
}
495468

496469
// Initialize consent manager when DOM is ready
497470
document.addEventListener('DOMContentLoaded', function() {
498-
// Check for configuration from script tag data attributes
499-
const configScript = document.querySelector('script[data-consent-config]');
500-
const config = configScript ? JSON.parse(configScript.dataset.consentConfig) : {};
501-
502-
window.consentManager = new ConsentManager(config);
471+
window.consentManager = new ConsentManager();
503472
});
504473

505474
// Global function for opening consent preferences

0 commit comments

Comments
 (0)