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
@@ -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>