diff --git a/admin-compliance/components/sdk/CookieBannerOverlay.tsx b/admin-compliance/components/sdk/CookieBannerOverlay.tsx index dafe9f2..721d7e4 100644 --- a/admin-compliance/components/sdk/CookieBannerOverlay.tsx +++ b/admin-compliance/components/sdk/CookieBannerOverlay.tsx @@ -1,14 +1,20 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' +import { + CATEGORY_VENDORS, countNonEWRVendors, isEWR, isOutsideEWR, + type VendorInfo, +} from './cookie-banner-vendors' /** - * CookieBannerOverlay — Live cookie consent banner for the Compliance SDK. + * CookieBannerOverlay — Cookie consent banner with Drittland-Schutz. * - * - Opens automatically on first visit (localStorage check) - * - Can be reopened via FAB button (right-[10rem]) - * - Records consent choice to localStorage - * - Fires custom event 'sdkCookieConsentUpdated' for other components + * Unique feature: Users can accept a category (e.g. Marketing) but block + * all vendors that transfer data to non-EU countries. This means: + * - Marketing = ON, Drittland-Schutz = ON → LinkedIn (EU) loads, Facebook (USA) does NOT + * - The consent state records both category consent AND blocked vendors + * + * No other CMP offers per-vendor country-based blocking within accepted categories. */ const STORAGE_KEY = 'bp-sdk-cookie-consent' @@ -18,68 +24,12 @@ interface ConsentState { statistics: boolean marketing: boolean functional: boolean + ewrOnly: boolean + blockedVendors: string[] timestamp: string } -interface VendorInfo { - name: string - cookies: string - provider: string - retention: string - country: string -} -// Demo vendors per category — mirrors service registry + cookie_table_generator.py -const CATEGORY_VENDORS: Record = { - necessary: { - label: 'Notwendig', - description: 'Fuer die Grundfunktionen der Website erforderlich.', - vendors: [ - { name: 'Session', cookies: 'session_id', provider: 'Eigener Server', retention: 'Session', country: 'DE' }, - { name: 'Consent-Cookie', cookies: 'bp_consent', provider: 'Eigener Server', retention: '12 Monate', country: 'DE' }, - { name: 'Cloudflare', cookies: '__cf_bm', provider: 'Cloudflare Inc.', retention: '30 Min.', country: 'USA (DPF)' }, - { name: 'Stripe', cookies: '__stripe_mid', provider: 'Stripe Inc.', retention: 'Session', country: 'USA (DPF)' }, - ], - }, - statistics: { - label: 'Statistik', - description: 'Helfen uns zu verstehen, wie Besucher mit der Website interagieren.', - vendors: [ - { name: 'Google Analytics', cookies: '_ga, _gid', provider: 'Google LLC', retention: '2 Jahre', country: 'USA (DPF)' }, - { name: 'Hotjar', cookies: '_hj*', provider: 'Hotjar Ltd.', retention: '1 Jahr', country: 'EU (Malta)' }, - { name: 'Google Tag Manager', cookies: '_gcl_au', provider: 'Google LLC', retention: '90 Tage', country: 'USA (DPF)' }, - ], - }, - marketing: { - label: 'Marketing', - description: 'Werden verwendet, um Besuchern relevante Werbung zu zeigen.', - vendors: [ - { name: 'Facebook Pixel', cookies: '_fbp, _fbc', provider: 'Meta Platforms', retention: '90 Tage', country: 'USA (DPF)' }, - { name: 'Google Ads', cookies: '_gcl_aw, IDE', provider: 'Google LLC', retention: '90 Tage', country: 'USA (DPF)' }, - { name: 'LinkedIn Insight', cookies: 'bcookie, li_sugr', provider: 'LinkedIn Ireland', retention: '6 Monate', country: 'EU (Irland)' }, - ], - }, - functional: { - label: 'Funktional', - description: 'Ermoeglichen erweiterte Funktionen und Personalisierung.', - vendors: [ - { name: 'Spracheinstellung', cookies: 'bp_lang', provider: 'Eigener Server', retention: '12 Monate', country: 'DE' }, - { name: 'YouTube', cookies: 'YSC, VISITOR_INFO1_LIVE', provider: 'Google LLC', retention: '6 Monate', country: 'USA (DPF)' }, - { name: 'HubSpot Chat', cookies: '__hstc, hubspotutk', provider: 'HubSpot Inc.', retention: '13 Monate', country: 'USA (DPF)' }, - ], - }, -} - -function getStoredConsent(): ConsentState | null { - if (typeof window === 'undefined') return null - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return null - return JSON.parse(raw) - } catch { - return null - } -} export function CookieBannerOverlay() { const [isOpen, setIsOpen] = useState(false) @@ -89,21 +39,38 @@ export function CookieBannerOverlay() { statistics: false, marketing: false, functional: false, + ewrOnly: false, + blockedVendors: [], timestamp: '', }) - // Check on mount if consent was already given + const nonEWRCount = useMemo(() => countNonEWRVendors(), []) + + // Compute which vendors are actually blocked + const blockedVendors = useMemo(() => { + if (!consent.ewrOnly) return [] + const blocked: string[] = [] + for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) { + const catEnabled = key === 'necessary' || consent[key as keyof ConsentState] + if (!catEnabled) continue + for (const v of cat.vendors) { + if (isOutsideEWR(v.country)) { + blocked.push(v.name) + } + } + } + return blocked + }, [consent]) + useEffect(() => { const stored = getStoredConsent() if (!stored) { - // First visit — show banner setIsOpen(true) } else { setConsent(stored) } }, []) - // Listen for reopen event from FAB button useEffect(() => { const handler = () => { setIsOpen(true) @@ -114,20 +81,31 @@ export function CookieBannerOverlay() { }, []) const saveConsent = useCallback((state: ConsentState) => { - const withTimestamp = { ...state, timestamp: new Date().toISOString() } - localStorage.setItem(STORAGE_KEY, JSON.stringify(withTimestamp)) - setConsent(withTimestamp) + // Compute blocked vendors before saving + const blocked: string[] = [] + if (state.ewrOnly) { + for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) { + const catEnabled = key === 'necessary' || state[key as keyof ConsentState] + if (!catEnabled) continue + for (const v of cat.vendors) { + if (isOutsideEWR(v.country)) blocked.push(v.name) + } + } + } + const withMeta = { ...state, blockedVendors: blocked, timestamp: new Date().toISOString() } + localStorage.setItem(STORAGE_KEY, JSON.stringify(withMeta)) + setConsent(withMeta) setIsOpen(false) setShowSettings(false) - window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withTimestamp })) + window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withMeta })) }, []) const handleAcceptAll = () => { - saveConsent({ necessary: true, statistics: true, marketing: true, functional: true, timestamp: '' }) + saveConsent({ ...consent, necessary: true, statistics: true, marketing: true, functional: true }) } const handleRejectAll = () => { - saveConsent({ necessary: true, statistics: false, marketing: false, functional: false, timestamp: '' }) + saveConsent({ ...consent, necessary: true, statistics: false, marketing: false, functional: false }) } const handleSaveSettings = () => { @@ -138,13 +116,11 @@ export function CookieBannerOverlay() { return ( <> - {/* Overlay */}
{/* Don't close on overlay click — consent is required */}} + onClick={() => {}} /> - {/* Banner */}
{/* Header */} @@ -170,21 +146,70 @@ export function CookieBannerOverlay() {
- {/* Category Settings (expandable) */} + {/* Settings */} {showSettings && ( -
- {Object.entries(CATEGORY_VENDORS).map(([key, cat]) => ( - key !== 'necessary' && setConsent(prev => ({ ...prev, [key]: v }))} - /> - ))} +
+ {/* === DRITTLAND-SCHUTZ === */} +
+
+
+
+ + + +
+
+
+ Nur EU/EWR-Anbieter +
+

+ Erlaubt nur Anbieter mit Sitz im Europaeischen Wirtschaftsraum (EWR) oder + der Schweiz. Anbieter ausserhalb werden blockiert — auch wenn Sie einer + Kategorie zustimmen. {nonEWRCount} Anbieter betroffen. +

+ {consent.ewrOnly && blockedVendors.length > 0 && ( +
+ {blockedVendors.map(name => ( + + + + + {name} + + ))} +
+ )} +
+
+ +
+
+ + {/* Category Sections */} +
+ {Object.entries(CATEGORY_VENDORS).map(([key, cat]) => ( + key !== 'necessary' && setConsent(prev => ({ ...prev, [key]: v }))} + /> + ))} +
)} @@ -241,10 +266,6 @@ export function CookieBannerOverlay() { } -/** - * FAB button to reopen the cookie banner settings. - * Positioned next to ComplianceAdvisor and PipelineSidebar. - */ export function CookieBannerFAB() { const [hasConsent, setHasConsent] = useState(false) @@ -255,7 +276,6 @@ export function CookieBannerFAB() { return () => window.removeEventListener('sdkCookieConsentUpdated', handler) }, []) - // Only show FAB after consent was given (banner is closed) if (!hasConsent) return null return ( @@ -280,6 +300,7 @@ function CategorySection({ vendors, checked, disabled, + ewrOnly, onChange, }: { categoryKey: string @@ -288,13 +309,18 @@ function CategorySection({ vendors: VendorInfo[] checked: boolean disabled?: boolean + ewrOnly: boolean onChange: (v: boolean) => void }) { const [expanded, setExpanded] = useState(false) + const euVendors = vendors.filter(v => isEWR(v.country)) + const nonEuVendors = vendors.filter(v => isOutsideEWR(v.country)) + const blockedCount = ewrOnly && checked ? nonEuVendors.length : 0 + const activeCount = checked ? vendors.length - blockedCount : 0 + return (
- {/* Category Header with Toggle */}
- {/* Vendor Table (expandable) */} {expanded && (
+ @@ -346,14 +375,54 @@ function CategorySection({ - {vendors.map((v, i) => ( - - - - - - - ))} + {vendors.map((v, i) => { + const isBlocked = ewrOnly && checked && isOutsideEWR(v.country) + const isActive = checked && !isBlocked + return ( + + + + + + + + ) + })}
Verarbeiter Cookies Dauer
{v.name}{v.cookies}{v.retention}{v.country}
+ {isBlocked ? ( + + + + ) : isActive ? ( + + + + ) : ( + + + + )} + + {v.name} + + {v.cookies} + {v.retention} + {isOutsideEWR(v.country) ? ( + + + + + {v.country} + + ) : ( + + + + + + {v.country} + + )} +
diff --git a/admin-compliance/components/sdk/cookie-banner-vendors.ts b/admin-compliance/components/sdk/cookie-banner-vendors.ts new file mode 100644 index 0000000..be0abd9 --- /dev/null +++ b/admin-compliance/components/sdk/cookie-banner-vendors.ts @@ -0,0 +1,85 @@ +/** + * Cookie Banner — Vendor data and EWR classification. + * + * Demo vendors per category, mirroring the service registry + cookie_table_generator.py. + * Used by CookieBannerOverlay for vendor display and EWR filtering. + */ + +export interface VendorInfo { + name: string + cookies: string + provider: string + retention: string + country: string +} + +export interface CategoryVendorData { + label: string + description: string + vendors: VendorInfo[] +} + +// EWR = EU + Island, Liechtenstein, Norwegen. CH has adequacy decision. +const EWR_SAFE = ['de', 'at', 'fr', 'nl', 'ie', 'se', 'dk', 'fi', 'be', 'it', 'es', + 'pt', 'pl', 'cz', 'hu', 'ro', 'bg', 'hr', 'sk', 'si', 'lt', 'lv', 'ee', 'cy', + 'mt', 'lu', 'gr', 'is', 'li', 'no', 'ch', // CH: Angemessenheitsbeschluss + 'eu', 'ewr', 'eigener server'] + +export function isEWR(country: string): boolean { + if (!country) return true // No country info = assume first party + const lower = country.toLowerCase() + return EWR_SAFE.some(safe => lower.includes(safe)) +} + +export function isOutsideEWR(country: string): boolean { + return !isEWR(country) +} + +export function countNonEWRVendors(): number { + let count = 0 + for (const cat of Object.values(CATEGORY_VENDORS)) { + count += cat.vendors.filter(v => isOutsideEWR(v.country)).length + } + return count +} + +// Demo vendors per category — mirrors service registry + cookie_table_generator.py +export const CATEGORY_VENDORS: Record = { + necessary: { + label: 'Notwendig', + description: 'Fuer die Grundfunktionen der Website erforderlich.', + vendors: [ + { name: 'Session', cookies: 'session_id', provider: 'Eigener Server', retention: 'Session', country: 'DE' }, + { name: 'Consent-Cookie', cookies: 'bp_consent', provider: 'Eigener Server', retention: '12 Monate', country: 'DE' }, + { name: 'Cloudflare', cookies: '__cf_bm', provider: 'Cloudflare Inc.', retention: '30 Min.', country: 'USA (DPF)' }, + { name: 'Stripe', cookies: '__stripe_mid', provider: 'Stripe Inc.', retention: 'Session', country: 'USA (DPF)' }, + ], + }, + statistics: { + label: 'Statistik', + description: 'Helfen uns zu verstehen, wie Besucher mit der Website interagieren.', + vendors: [ + { name: 'Google Analytics', cookies: '_ga, _gid', provider: 'Google LLC', retention: '2 Jahre', country: 'USA (DPF)' }, + { name: 'Hotjar', cookies: '_hj*', provider: 'Hotjar Ltd.', retention: '1 Jahr', country: 'EU (Malta)' }, + { name: 'Google Tag Manager', cookies: '_gcl_au', provider: 'Google LLC', retention: '90 Tage', country: 'USA (DPF)' }, + ], + }, + marketing: { + label: 'Marketing', + description: 'Werden verwendet, um Besuchern relevante Werbung zu zeigen.', + vendors: [ + { name: 'Facebook Pixel', cookies: '_fbp, _fbc', provider: 'Meta Platforms', retention: '90 Tage', country: 'USA (DPF)' }, + { name: 'Google Ads', cookies: '_gcl_aw, IDE', provider: 'Google LLC', retention: '90 Tage', country: 'USA (DPF)' }, + { name: 'LinkedIn Insight', cookies: 'bcookie, li_sugr', provider: 'LinkedIn Ireland', retention: '6 Monate', country: 'EU (Irland)' }, + ], + }, + functional: { + label: 'Funktional', + description: 'Ermoeglichen erweiterte Funktionen und Personalisierung.', + vendors: [ + { name: 'Spracheinstellung', cookies: 'bp_lang', provider: 'Eigener Server', retention: '12 Monate', country: 'DE' }, + { name: 'YouTube', cookies: 'YSC, VISITOR_INFO1_LIVE', provider: 'Google LLC', retention: '6 Monate', country: 'USA (DPF)' }, + { name: 'HubSpot Chat', cookies: '__hstc, hubspotutk', provider: 'HubSpot Inc.', retention: '13 Monate', country: 'USA (DPF)' }, + ], + }, +}