feat(cmp): Phase 3 — admin widerruf, email-linking, vendor display, TCF, E2E tests

Admin Modal:
- vendor_consents as green/red badges
- Consent withdraw button (DELETE /consent/{id}) with confirmation
- Email-linking inline input (POST /consent/link-email)

Cookie Banner Admin:
- TCF toggle reads tcf_enabled from site config (was hardcoded false)
- BannerSite interface extended with tcf_enabled

Document Generator:
- Backend Banner-Config auto-fetch when SDK state has no banner
- Maps vendors to CONSENT (analytics tools, marketing partners)

E2E Tests (cmp-phase3-dsr.spec.ts):
- Vendor-agnostic consent fields (20+ fields, upsert)
- DSR Art. 15 Auskunft (multi-device, email-link, export)
- DSR Art. 17 Löschung (erasure by email)
- Anonymous cookie banner user (export, withdraw)
- Customer lifecycle (consent → login → link → Art.15 → Art.17)
- Admin dashboard integration (list, stats)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-14 18:45:41 +02:00
parent eac42d4154
commit 65f978368d
6 changed files with 475 additions and 6 deletions
@@ -102,6 +102,7 @@ export interface BannerSite {
site_name: string
site_url: string
is_active: boolean
tcf_enabled?: boolean
}
export function useCookieBanner() {
@@ -105,7 +105,7 @@ export default function CookieBannerPage() {
{/* Tab: TCF/IAB */}
{activeTab === 'tcf' && (
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={false}
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={sites.find(s => s.site_id === activeSiteId)?.tcf_enabled ?? false}
onToggle={(enabled) => {
if (activeSiteId) {
fetch(`/api/sdk/v1/banner/admin/sites/${activeSiteId}`, {
@@ -101,7 +101,35 @@ function DocumentGeneratorPageInner() {
}
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
// ── MODULE WIRING: CookieBanner → CONSENT + FEATURES ─────────────────────
// ── MODULE WIRING: Backend Banner-Config → CONSENT + FEATURES ────────────
useEffect(() => {
// Fetch real vendor/category data from backend if SDK state has no banner
if (state?.cookieBanner) return // SDK state takes priority
fetch('/api/sdk/v1/banner/admin/sites', { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
.then(r => r.json())
.then((sites: Array<{ site_id: string }>) => {
if (!sites?.length) return
return fetch(`/api/sdk/v1/banner/config/${sites[0].site_id}`, { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
})
.then(r => r?.json())
.then(config => {
if (!config?.vendors?.length) return
const analytics = config.vendors.filter((v: { category_key: string }) => v.category_key === 'statistics' || v.category_key === 'analytics').map((v: { vendor_name: string }) => v.vendor_name)
const marketing = config.vendors.filter((v: { category_key: string }) => v.category_key === 'marketing').map((v: { vendor_name: string }) => v.vendor_name)
setContext(prev => ({
...prev,
CONSENT: {
...prev.CONSENT,
ANALYTICS_TOOLS: analytics.length > 0 ? analytics.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
MARKETING_PARTNERS: marketing.length > 0 ? marketing.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
},
FEATURES: { ...prev.FEATURES, CMP_NAME: 'BreakPilot CMP', CMP_LOGS_CONSENTS: true },
}))
})
.catch(() => {})
}, [state?.cookieBanner])
// ── MODULE WIRING: CookieBanner SDK State → CONSENT + FEATURES ──────────
useEffect(() => {
const banner = state?.cookieBanner
if (!banner) return
@@ -1,9 +1,12 @@
'use client'
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { useBannerConsents } from '../_hooks/useBannerConsents'
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
const BANNER_API = '/api/sdk/v1/banner'
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
@@ -42,12 +45,35 @@ const methodColors: Record<string, string> = {
export default function BannerConsentsTab() {
const {
records, sites, selectedSite, changeSite,
stats, currentPage, setCurrentPage, totalRecords, loading,
stats, currentPage, setCurrentPage, totalRecords, loading, reload,
} = useBannerConsents()
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
const [linkEmailInput, setLinkEmailInput] = useState('')
const [linkingEmail, setLinkingEmail] = useState(false)
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
const withdrawConsent = useCallback(async (id: string) => {
if (!confirm('Consent wirklich widerrufen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
await fetch(`${BANNER_API}/consent/${id}`, { method: 'DELETE', headers: { 'x-tenant-id': TENANT_ID } })
setDetail(null)
reload()
}, [reload])
const linkEmail = useCallback(async (record: BannerConsentRecord) => {
if (!linkEmailInput.includes('@')) return
setLinkingEmail(true)
await fetch(`${BANNER_API}/consent/link-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-tenant-id': TENANT_ID },
body: JSON.stringify({ site_id: record.site_id, device_fingerprint: record.device_fingerprint, email: linkEmailInput }),
})
setLinkingEmail(false)
setLinkEmailInput('')
setDetail({ ...record, linked_email: linkEmailInput })
reload()
}, [linkEmailInput, reload])
return (
<div className="space-y-6">
{/* Stats + Site Selector */}
@@ -184,6 +210,18 @@ export default function BannerConsentsTab() {
))}
</div>
</div>
{detail.vendor_consents && Object.keys(detail.vendor_consents).length > 0 && (
<div className="flex justify-between items-start">
<span className="text-gray-500">Vendors</span>
<div className="flex flex-wrap gap-1 justify-end">
{Object.entries(detail.vendor_consents).map(([name, accepted]) => (
<span key={name} className={`text-xs px-2 py-0.5 rounded-full ${accepted ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{name}
</span>
))}
</div>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500">Methode</span>
<span>{detail.consent_method ? (
@@ -192,9 +230,28 @@ export default function BannerConsentsTab() {
</span>
) : '—'}</span>
</div>
<div className="flex justify-between">
<div className="flex justify-between items-center">
<span className="text-gray-500">Verknüpft mit</span>
<span>{detail.linked_email || '— (anonym)'}</span>
{detail.linked_email ? (
<span className="text-purple-600 text-xs">{detail.linked_email}</span>
) : (
<div className="flex items-center gap-1">
<input
type="email"
placeholder="E-Mail verknüpfen..."
value={linkEmailInput}
onChange={e => setLinkEmailInput(e.target.value)}
className="text-xs border border-gray-200 rounded px-2 py-1 w-40"
/>
<button
onClick={() => linkEmail(detail)}
disabled={linkingEmail || !linkEmailInput.includes('@')}
className="text-xs px-2 py-1 bg-purple-600 text-white rounded disabled:opacity-40"
>
{linkingEmail ? '...' : 'Link'}
</button>
</div>
)}
</div>
<div className="flex justify-between"><span className="text-gray-500">Erteilt</span><span>{formatDate(detail.created_at)}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Ablauf</span><span>{formatDate(detail.expires_at)}</span></div>
@@ -264,6 +321,16 @@ export default function BannerConsentsTab() {
{detail.banner_config_hash && <div><span className="text-gray-500 text-xs">Config-Hash</span><p className="text-xs text-gray-600 font-mono">{detail.banner_config_hash}</p></div>}
</div>
</div>
{/* Widerruf-Button */}
<div className="border-t border-gray-100 pt-4 mt-4">
<button
onClick={() => withdrawConsent(detail.id)}
className="w-full px-4 py-2 text-xs font-semibold text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
>
Consent widerrufen (Art. 17 DSGVO)
</button>
</div>
</div>
</div>
</div>
@@ -108,6 +108,7 @@ export interface BannerConsentRecord {
device_fingerprint: string
categories: string[]
vendors: string[]
vendor_consents: Record<string, boolean>
ip_hash: string | null
user_agent: string | null
linked_email: string | null
@@ -144,4 +145,5 @@ export interface BannerSite {
site_id: string
site_name: string
site_url: string
tcf_enabled?: boolean
}
@@ -0,0 +1,371 @@
import { test, expect } from '@playwright/test'
/**
* CMP Phase 3 + DSR Integration Tests
*
* Tests the complete CMP lifecycle including:
* - Vendor-agnostic consent fields (consent_method, browser, os, etc.)
* - Script/cookie tracking (scripts_blocked, scripts_released, cookies_set)
* - Session ID tracking
* - GeoIP via timezone mapping
* - Vendor-level consent (vendor_consents dict)
* - DSR scenarios: Art. 15 Auskunft, Art. 17 Löschung, Art. 20 Portabilität
* - Email linking for DSR (device → user mapping)
* - Admin modal features (vendor display, withdraw, email linking)
*/
const API_BASE = process.env.PLAYWRIGHT_API_URL || 'https://macmini:3007/api/sdk/v1/banner'
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const HEADERS = {
'Content-Type': 'application/json',
'X-Tenant-ID': TENANT_ID,
}
const TS = Date.now()
const SITE_ID = `e2e-cmp3-${TS}`
const DEVICE_FP = `e2e-device-${TS}`
// ============================================================================
// 1. Vendor-Agnostic Consent Fields
// ============================================================================
test.describe('Vendor-Agnostic Consent Fields', () => {
test('should store all 20+ fields on consent', async ({ request }) => {
// Create site config first
await request.post(`${API_BASE}/admin/sites`, {
headers: HEADERS,
data: { site_id: SITE_ID, site_name: 'E2E CMP Phase 3', site_url: 'https://test.example.com' },
})
// Record consent with all vendor-agnostic fields
const res = await request.post(`${API_BASE}/consent`, {
headers: HEADERS,
data: {
site_id: SITE_ID,
device_fingerprint: DEVICE_FP,
categories: ['essential', 'functional', 'analytics'],
vendors: ['Google Analytics', 'Matomo'],
vendor_consents: { 'Google Analytics': true, 'Matomo': true, 'Facebook Pixel': false },
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) E2E-Test',
consent_method: 'custom_selection',
page_url: 'https://test.example.com/pricing',
referrer: 'https://google.com',
device_type: 'desktop',
browser: 'Chrome/120.0',
os: 'Mac OS X 10.15.7',
screen_resolution: '1920x1080',
consent_scope: 'domain',
session_id: 'e2e-session-001',
timezone: 'Europe/Berlin',
scripts_blocked: [{ src: 'https://connect.facebook.net/fbevents.js', category: 'marketing' }],
scripts_released: [{ src: 'https://www.googletagmanager.com/gtag/js', category: 'analytics' }],
cookies_set: [
{ name: '_ga', domain: '.test.example.com', expiry_days: 730, category: 'analytics' },
{ name: 'bp_consent', domain: '.test.example.com', expiry_days: 365, category: 'essential' },
],
},
})
expect(res.status()).toBe(200)
const consent = await res.json()
expect(consent.id).toBeTruthy()
expect(consent.consent_method).toBe('custom_selection')
expect(consent.device_type).toBe('desktop')
expect(consent.browser).toBe('Chrome/120.0')
expect(consent.os).toBe('Mac OS X 10.15.7')
expect(consent.page_url).toBe('https://test.example.com/pricing')
expect(consent.session_id).toBe('e2e-session-001')
expect(consent.geo_country).toBe('DE') // Europe/Berlin → DE
expect(consent.scripts_released).toHaveLength(1)
expect(consent.cookies_set).toHaveLength(2)
expect(consent.vendor_consents).toEqual({ 'Google Analytics': true, 'Matomo': true, 'Facebook Pixel': false })
})
test('should update consent on same fingerprint (upsert)', async ({ request }) => {
const res = await request.post(`${API_BASE}/consent`, {
headers: HEADERS,
data: {
site_id: SITE_ID,
device_fingerprint: DEVICE_FP,
categories: ['essential'], // changed from all 3 to essential only
vendors: [],
consent_method: 'reject_all',
page_url: 'https://test.example.com/settings',
timezone: 'Europe/Vienna',
},
})
expect(res.status()).toBe(200)
const consent = await res.json()
expect(consent.consent_method).toBe('reject_all')
expect(consent.geo_country).toBe('AT') // Europe/Vienna → AT
expect(consent.categories).toEqual(['essential'])
})
})
// ============================================================================
// 2. DSR Scenarios — Art. 15 Auskunft
// ============================================================================
test.describe('DSR — Art. 15 Auskunftsrecht', () => {
const DSR_EMAIL = `dsr-user-${TS}@example.com`
const DSR_DEVICE_1 = `dsr-desktop-${TS}`
const DSR_DEVICE_2 = `dsr-mobile-${TS}`
test.beforeAll(async ({ request }) => {
// Scenario: User visited website from 2 devices, then linked their email
// Device 1: Desktop consent
await request.post(`${API_BASE}/consent`, {
headers: HEADERS,
data: {
site_id: SITE_ID,
device_fingerprint: DSR_DEVICE_1,
categories: ['essential', 'analytics'],
consent_method: 'accept_all',
device_type: 'desktop',
browser: 'Firefox/121.0',
page_url: 'https://test.example.com/',
timezone: 'Europe/Berlin',
},
})
// Device 2: Mobile consent
await request.post(`${API_BASE}/consent`, {
headers: HEADERS,
data: {
site_id: SITE_ID,
device_fingerprint: DSR_DEVICE_2,
categories: ['essential'],
consent_method: 'reject_all',
device_type: 'mobile',
browser: 'Safari/17.0',
page_url: 'https://test.example.com/pricing',
timezone: 'Europe/Berlin',
},
})
// User logs in and links email to both devices
await request.post(`${API_BASE}/consent/link-email`, {
headers: HEADERS,
data: { site_id: SITE_ID, device_fingerprint: DSR_DEVICE_1, email: DSR_EMAIL },
})
await request.post(`${API_BASE}/consent/link-email`, {
headers: HEADERS,
data: { site_id: SITE_ID, device_fingerprint: DSR_DEVICE_2, email: DSR_EMAIL },
})
})
test('Art. 15 — should find all consents by email', async ({ request }) => {
const res = await request.get(`${API_BASE}/consent/by-email/${DSR_EMAIL}`, { headers: HEADERS })
expect(res.status()).toBe(200)
const consents = await res.json()
expect(consents).toHaveLength(2)
expect(consents.map((c: { device_fingerprint: string }) => c.device_fingerprint).sort()).toEqual(
[DSR_DEVICE_1, DSR_DEVICE_2].sort()
)
})
test('Art. 15/20 — should export all consent data for DSR', async ({ request }) => {
const res = await request.get(`${API_BASE}/consent/dsr-export/${DSR_EMAIL}`, { headers: HEADERS })
expect(res.status()).toBe(200)
const exportData = await res.json()
expect(exportData.consents).toHaveLength(2)
expect(exportData.audit_trail.length).toBeGreaterThan(0)
})
test('Art. 17 — should delete all consents by email (erasure)', async ({ request }) => {
const res = await request.delete(`${API_BASE}/consent/by-email/${DSR_EMAIL}`, { headers: HEADERS })
expect(res.status()).toBe(200)
const result = await res.json()
expect(result.deleted_count).toBe(2)
// Verify deletion
const check = await request.get(`${API_BASE}/consent/by-email/${DSR_EMAIL}`, { headers: HEADERS })
const remaining = await check.json()
expect(remaining).toHaveLength(0)
})
})
// ============================================================================
// 3. DSR Scenarios — Cookie Banner User (anonymous)
// ============================================================================
test.describe('DSR — Anonymous Cookie Banner User', () => {
const ANON_DEVICE = `anon-user-${TS}`
test.beforeAll(async ({ request }) => {
await request.post(`${API_BASE}/consent`, {
headers: HEADERS,
data: {
site_id: SITE_ID,
device_fingerprint: ANON_DEVICE,
categories: ['essential', 'functional'],
consent_method: 'custom_selection',
device_type: 'tablet',
browser: 'Chrome/120.0',
},
})
})
test('should export consent by device fingerprint', async ({ request }) => {
const res = await request.get(
`${API_BASE}/consent/export?site_id=${SITE_ID}&device_fingerprint=${ANON_DEVICE}`,
{ headers: HEADERS }
)
expect(res.status()).toBe(200)
const data = await res.json()
expect(data.device_fingerprint).toBe(ANON_DEVICE)
expect(data.consents).toHaveLength(1)
expect(data.audit_trail.length).toBeGreaterThan(0)
})
test('should withdraw consent by ID', async ({ request }) => {
// Get consent ID first
const getRes = await request.get(
`${API_BASE}/consent?site_id=${SITE_ID}&device_fingerprint=${ANON_DEVICE}`,
{ headers: HEADERS }
)
const { consent } = await getRes.json()
expect(consent).toBeTruthy()
// Withdraw
const delRes = await request.delete(`${API_BASE}/consent/${consent.id}`, { headers: HEADERS })
expect(delRes.status()).toBe(200)
// Verify
const checkRes = await request.get(
`${API_BASE}/consent?site_id=${SITE_ID}&device_fingerprint=${ANON_DEVICE}`,
{ headers: HEADERS }
)
const result = await checkRes.json()
expect(result.has_consent).toBe(false)
})
})
// ============================================================================
// 4. DSR Scenarios — Login User (Customer) who also used Cookie Banner
// ============================================================================
test.describe('DSR — Customer with Banner + Login', () => {
const CUSTOMER_EMAIL = `customer-${TS}@company.com`
const CUSTOMER_DEVICE = `customer-device-${TS}`
test('full lifecycle: consent → login → link → Art.15 → Art.17', async ({ request }) => {
// Step 1: Anonymous visit → cookie consent
const consentRes = await request.post(`${API_BASE}/consent`, {
headers: HEADERS,
data: {
site_id: SITE_ID,
device_fingerprint: CUSTOMER_DEVICE,
categories: ['essential', 'analytics'],
consent_method: 'accept_all',
device_type: 'desktop',
browser: 'Edge/120.0',
page_url: 'https://test.example.com/',
timezone: 'Europe/Zurich',
scripts_released: [{ src: 'https://cdn.matomo.cloud/test.js', category: 'analytics' }],
cookies_set: [{ name: '_pk_id', domain: '.test.example.com', expiry_days: 393, category: 'analytics' }],
},
})
expect(consentRes.status()).toBe(200)
const consent = await consentRes.json()
expect(consent.geo_country).toBe('CH') // Europe/Zurich → CH
// Step 2: Customer logs in → email linked
const linkRes = await request.post(`${API_BASE}/consent/link-email`, {
headers: HEADERS,
data: { site_id: SITE_ID, device_fingerprint: CUSTOMER_DEVICE, email: CUSTOMER_EMAIL },
})
expect(linkRes.status()).toBe(200)
// Step 3: Art. 15 — Customer requests their data
const exportRes = await request.get(`${API_BASE}/consent/dsr-export/${CUSTOMER_EMAIL}`, { headers: HEADERS })
expect(exportRes.status()).toBe(200)
const exportData = await exportRes.json()
expect(exportData.consents.length).toBeGreaterThan(0)
expect(exportData.audit_trail.length).toBeGreaterThan(0)
// Verify export contains all consent details
const exported = exportData.consents[0]
expect(exported.categories).toContain('analytics')
expect(exported.linked_email).toBe(CUSTOMER_EMAIL)
// Step 4: Art. 17 — Customer requests erasure
const deleteRes = await request.delete(`${API_BASE}/consent/by-email/${CUSTOMER_EMAIL}`, { headers: HEADERS })
expect(deleteRes.status()).toBe(200)
// Step 5: Verify complete erasure
const verifyRes = await request.get(`${API_BASE}/consent/by-email/${CUSTOMER_EMAIL}`, { headers: HEADERS })
const remaining = await verifyRes.json()
expect(remaining).toHaveLength(0)
})
})
// ============================================================================
// 5. Admin Dashboard Integration
// ============================================================================
test.describe('Admin Dashboard — Consent Management', () => {
const ADMIN_DEVICE = `admin-test-${TS}`
test.beforeAll(async ({ request }) => {
await request.post(`${API_BASE}/consent`, {
headers: HEADERS,
data: {
site_id: SITE_ID,
device_fingerprint: ADMIN_DEVICE,
categories: ['essential', 'functional', 'analytics'],
vendors: ['Matomo'],
vendor_consents: { Matomo: true },
consent_method: 'accept_all',
device_type: 'desktop',
browser: 'Chrome/121.0',
os: 'Windows NT 10.0',
screen_resolution: '2560x1440',
page_url: 'https://test.example.com/dashboard',
session_id: 'admin-session-001',
timezone: 'Europe/Berlin',
},
})
})
test('should list consents with new fields', async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/consents?site_id=${SITE_ID}`, { headers: HEADERS })
expect(res.status()).toBe(200)
const data = await res.json()
expect(data.total).toBeGreaterThan(0)
const consent = data.consents.find((c: { device_fingerprint: string }) => c.device_fingerprint === ADMIN_DEVICE)
expect(consent).toBeTruthy()
expect(consent.consent_method).toBe('accept_all')
expect(consent.device_type).toBe('desktop')
expect(consent.browser).toBe('Chrome/121.0')
expect(consent.os).toBe('Windows NT 10.0')
expect(consent.screen_resolution).toBe('2560x1440')
expect(consent.session_id).toBe('admin-session-001')
expect(consent.geo_country).toBe('DE')
expect(consent.vendor_consents).toEqual({ Matomo: true })
})
test('should show site stats with category acceptance', async ({ request }) => {
const res = await request.get(`${API_BASE}/admin/stats/${SITE_ID}`, { headers: HEADERS })
expect(res.status()).toBe(200)
const stats = await res.json()
expect(stats.total_consents).toBeGreaterThan(0)
expect(stats.category_acceptance).toBeTruthy()
expect(stats.category_acceptance.essential).toBeTruthy()
expect(stats.category_acceptance.essential.rate).toBeGreaterThan(0)
})
})
// ============================================================================
// 6. Cleanup
// ============================================================================
test.describe('Cleanup', () => {
test('should delete test site config', async ({ request }) => {
const res = await request.delete(`${API_BASE}/admin/sites/${SITE_ID}`, { headers: HEADERS })
expect(res.status()).toBe(204)
})
})