Skip to content

Commit 918f65b

Browse files
Fix GDPR cookie consent manager
- Show consent banner to all visitors (remove unreliable timezone-based region detection — VPN gaps, missing timezones, maintenance burden) - Add CONSENT_VERSION with invalidation: bumping re-prompts all users - Store _timestamp and _version in consent cookie for GDPR audit trail - Always send Clarity denied signal on init for new visitors (was never receiving a consent signal before user interaction) - Fix personalization_storage bound to advertising, not analytics - Add wait_for_update: 500 to gtag consent defaults (prevents GA pings before banner renders) - Fire gtag('config') unconditionally per Consent Mode v2 Advanced pattern (GA handles denied state with cookieless modeled pings) - Remove duplicate gtag('consent', 'default') from initGoogleConsentMode() - Fix clearTrackingCookies() to delete domain-scoped GA/Clarity cookies - Remove conflicting Clarity v1 API call from revokeAllConsent() - Add Secure flag to consent cookie on HTTPS - Add privacy policy link to consent banner - Replace fragile setTimeout in openConsentPreferences() with direct call - Prevent functionality_storage/security_storage from being user-overridden
1 parent 959a6ad commit 918f65b

2 files changed

Lines changed: 75 additions & 92 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: 59 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +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 {
88
constructor(options = {}) {
99
this.COOKIE_NAME = 'essential-csharp-consent';
1010
this.COOKIE_DURATION = 365; // days
11+
this.CONSENT_VERSION = '2'; // Bump this to re-prompt all users when consent terms change
1112
this.GOOGLE_ANALYTICS_ID = options.googleAnalyticsId || 'G-761B4BMK2R';
1213
this.consentState = {
1314
analytics_storage: 'denied',
@@ -19,20 +20,20 @@ class ConsentManager {
1920
personalization_storage: 'denied'
2021
};
2122

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

2826
init() {
29-
// Initialize Google Consent Mode
3027
this.initGoogleConsentMode();
3128

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

35-
// Show banner if consent required and not yet given
36+
// Show banner if no valid consent stored
3637
if (this.shouldShowConsentBanner()) {
3738
this.showConsentBanner();
3839
}
@@ -42,77 +43,40 @@ class ConsentManager {
4243
}
4344

4445
dispatchInitializationEvent() {
45-
// Create and dispatch custom event to signal consent manager is ready
4646
const event = new CustomEvent('consentManagerReady', {
4747
detail: {
4848
hasAnalyticsConsent: this.hasAnalyticsConsent(),
49-
hasAdvertisingConsent: this.hasAdvertisingConsent(),
50-
requiresConsent: this.requiresConsent
49+
hasAdvertisingConsent: this.hasAdvertisingConsent()
5150
}
5251
});
5352
document.dispatchEvent(event);
5453
}
5554

5655
initGoogleConsentMode() {
57-
// Initialize gtag if not already loaded
56+
// Ensure gtag infrastructure exists — the actual 'consent default' is set inline
57+
// in _Layout.cshtml before gtag.js loads (required by Google Consent Mode v2).
5858
window.dataLayer = window.dataLayer || [];
5959
function gtag(){dataLayer.push(arguments);}
6060
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);
10361
}
10462

10563
loadConsentPreferences() {
10664
const saved = this.getCookie(this.COOKIE_NAME);
10765
if (saved) {
10866
try {
10967
const preferences = JSON.parse(saved);
68+
69+
// Invalidate stale consent if the version has changed — treat user as new visitor
70+
if (preferences._version !== this.CONSENT_VERSION) {
71+
this.deleteCookie(this.COOKIE_NAME);
72+
return;
73+
}
11074

111-
// Validate and only apply known consent properties for security
75+
// Validate and only apply known consent properties for security.
76+
// Exclude functionality_storage and security_storage — always essential, never user-overrideable.
11277
const validConsentKeys = [
11378
'analytics_storage', 'ad_storage', 'ad_user_data',
114-
'ad_personalization', 'functionality_storage',
115-
'security_storage', 'personalization_storage'
79+
'ad_personalization', 'personalization_storage'
11680
];
11781

11882
const validatedPreferences = {};
@@ -132,8 +96,11 @@ class ConsentManager {
13296
}
13397

13498
shouldShowConsentBanner() {
135-
// Show banner if in EEA/UK/Switzerland and no consent stored
136-
return this.requiresConsent && !this.getCookie(this.COOKIE_NAME);
99+
// Allow forcing the banner via URL param (useful for testing)
100+
const urlParams = new URLSearchParams(window.location.search);
101+
if (urlParams.get('testConsent') === 'true') return true;
102+
// Show to all visitors who haven't given valid consent yet
103+
return !this.getCookie(this.COOKIE_NAME);
137104
}
138105

139106
showConsentBanner() {
@@ -154,7 +121,7 @@ class ConsentManager {
154121
<div class="consent-banner-content">
155122
<div class="consent-banner-text">
156123
<h3>Cookie Preferences</h3>
157-
<p>We use cookies to improve your experience and analyze website usage. You can manage your preferences below.</p>
124+
<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>
158125
</div>
159126
<div class="consent-banner-actions">
160127
<button id="consent-reject-all" class="btn btn-outline-secondary me-2">Reject All</button>
@@ -272,15 +239,20 @@ class ConsentManager {
272239
ad_storage: advertisingChecked ? 'granted' : 'denied',
273240
ad_user_data: advertisingChecked ? 'granted' : 'denied',
274241
ad_personalization: advertisingChecked ? 'granted' : 'denied',
275-
personalization_storage: analyticsChecked ? 'granted' : 'denied'
242+
personalization_storage: advertisingChecked ? 'granted' : 'denied'
276243
};
277244

278245
this.saveConsentAndClose();
279246
}
280247

281248
saveConsentAndClose() {
282-
// Save consent to cookie
283-
this.setCookie(this.COOKIE_NAME, JSON.stringify(this.consentState), this.COOKIE_DURATION);
249+
// Save consent with audit metadata
250+
const payload = {
251+
...this.consentState,
252+
_timestamp: new Date().toISOString(),
253+
_version: this.CONSENT_VERSION
254+
};
255+
this.setCookie(this.COOKIE_NAME, JSON.stringify(payload), this.COOKIE_DURATION);
284256

285257
// Update consent mode
286258
this.updateConsentMode();
@@ -290,17 +262,20 @@ class ConsentManager {
290262

291263
// Remove banner
292264
this.removeConsentBanner();
265+
266+
// Notify layout scripts so they can fire gtag('config') on interactive consent
267+
document.dispatchEvent(new CustomEvent('consentUpdated', {
268+
detail: {
269+
hasAnalyticsConsent: this.hasAnalyticsConsent(),
270+
hasAdvertisingConsent: this.hasAdvertisingConsent()
271+
}
272+
}));
293273
}
294274

295275
updateConsentMode() {
296276
if (window.gtag) {
297277
try {
298278
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-
}
304279
} catch (error) {
305280
console.warn('Failed to update Google Consent Mode:', error);
306281
}
@@ -439,7 +414,13 @@ class ConsentManager {
439414
setCookie(name, value, days) {
440415
const expires = new Date();
441416
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
442-
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
417+
const secure = window.location.protocol === 'https:' ? ';Secure' : '';
418+
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax${secure}`;
419+
}
420+
421+
deleteCookie(name) {
422+
const secure = window.location.protocol === 'https:' ? ';Secure' : '';
423+
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax${secure}`;
443424
}
444425

445426
getCookie(name) {
@@ -456,12 +437,7 @@ class ConsentManager {
456437
// Public API for consent preference management
457438
openConsentPreferences() {
458439
this.showConsentBanner();
459-
// If banner already exists, show customize options
460-
setTimeout(() => {
461-
if (document.getElementById('consent-banner')) {
462-
this.showCustomizeOptions();
463-
}
464-
}, 100);
440+
this.showCustomizeOptions();
465441
}
466442

467443
// Check current consent status
@@ -478,17 +454,21 @@ class ConsentManager {
478454
this.rejectAllConsent();
479455
// Also clear any existing tracking cookies
480456
this.clearTrackingCookies();
481-
// Erase Clarity cookies according to documentation
482-
if (window.clarity) {
483-
clarity('consent', false);
484-
}
457+
// Note: Clarity consent signal is sent via updateClarityConsent() inside rejectAllConsent() → saveConsentAndClose()
485458
}
486459

487460
clearTrackingCookies() {
488461
// Clear common tracking cookies (Google Analytics and Microsoft Clarity)
489462
const trackingCookies = ['_ga', '_gid', '_gat', '_clck', '_clsk', 'CLID', 'ANONCHK', 'MR', 'MUID', 'SM'];
463+
const expired = 'expires=Thu, 01 Jan 1970 00:00:00 GMT';
464+
// GA and Clarity cookies are often set on the root domain, so attempt deletion on both the
465+
// exact hostname and the parent domain (e.g. .essentialcsharp.com)
466+
const hostname = window.location.hostname;
467+
const rootDomain = '.' + hostname.split('.').slice(-2).join('.');
490468
trackingCookies.forEach(cookieName => {
491-
document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
469+
document.cookie = `${cookieName}=;${expired};path=/`;
470+
document.cookie = `${cookieName}=;${expired};path=/;domain=${hostname}`;
471+
document.cookie = `${cookieName}=;${expired};path=/;domain=${rootDomain}`;
492472
});
493473
}
494474
}

0 commit comments

Comments
 (0)