feat: IAB TCF 2.2 — TC String encoder + purpose mapping + UI

- TCFEncoderService: generates base64url-encoded TC Strings per IAB spec
  with 12 purposes, vendor consent bitfield, CMP metadata
- Category-to-purpose mapping (necessary→none, statistics→1,7,8,9,10,
  marketing→1,2,3,4,5,6,7,12, functional→1,11)
- tcf_routes: 5 endpoints (purposes, features, mapping, encode, encode-categories)
- banner_consent_service: auto-generates TC String when tcf_enabled=true
- TCFSettings.tsx: enable/disable toggle, purpose grid with category mapping,
  TC String test generator, CMP registration info
- New "TCF/IAB" tab in cookie-banner page (7 tabs total)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-04 07:01:37 +02:00
parent c89a68e59e
commit d3c8811fdb
6 changed files with 505 additions and 1 deletions
@@ -0,0 +1,161 @@
'use client'
import { useState, useEffect } from 'react'
interface IABPurpose {
id: number
name: string
name_de: string
}
const API = '/api/sdk/v1/compliance/tcf'
export function TCFSettings({ siteId, tcfEnabled, onToggle }: {
siteId?: string
tcfEnabled: boolean
onToggle: (enabled: boolean) => void
}) {
const [purposes, setPurposes] = useState<IABPurpose[]>([])
const [categoryMap, setCategoryMap] = useState<Record<string, number[]>>({})
const [testResult, setTestResult] = useState<string | null>(null)
const [testing, setTesting] = useState(false)
useEffect(() => {
Promise.all([
fetch(`${API}/purposes`).then(r => r.ok ? r.json() : []),
fetch(`${API}/category-mapping`).then(r => r.ok ? r.json() : {}),
]).then(([p, m]) => {
setPurposes(p)
setCategoryMap(m)
}).catch(() => {})
}, [])
const handleTestEncode = async () => {
setTesting(true)
setTestResult(null)
try {
const res = await fetch(`${API}/encode-categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categories: ['necessary', 'statistics', 'marketing'] }),
})
if (res.ok) {
const data = await res.json()
setTestResult(`TC String: ${data.tc_string}\nPurposes: ${data.purposes_consented.join(', ')}`)
}
} catch { setTestResult('Fehler beim Generieren') }
setTesting(false)
}
return (
<div className="space-y-6">
{/* Enable/Disable */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">IAB TCF 2.2</h3>
<p className="text-xs text-gray-500 mt-1">
Transparency & Consent Framework Standardisierte Einwilligungssignale fuer programmatische Werbung
</p>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" checked={tcfEnabled} onChange={e => onToggle(e.target.checked)}
className="w-5 h-5 text-purple-600 rounded" />
<span className="text-sm font-medium">{tcfEnabled ? 'Aktiv' : 'Inaktiv'}</span>
</label>
</div>
{!tcfEnabled && (
<p className="mt-3 text-xs text-amber-600 bg-amber-50 p-3 rounded-lg">
TCF ist nur erforderlich wenn Sie programmatische Werbung (AdTech) einsetzen.
Fuer die meisten Websites reicht das Standard-Cookie-Banner.
</p>
)}
</div>
{tcfEnabled && (
<>
{/* IAB Purposes */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">12 IAB-Zwecke (Purposes)</h4>
<p className="text-xs text-gray-500 mb-4">
Diese Zwecke werden automatisch aus Ihren Cookie-Kategorien abgeleitet.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{purposes.map(p => {
const activeCats = Object.entries(categoryMap)
.filter(([, pids]) => pids.includes(p.id))
.map(([cat]) => cat)
return (
<div key={p.id} className={`flex items-start gap-2 p-2 rounded-lg text-xs ${activeCats.length > 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${activeCats.length > 0 ? 'bg-green-500 text-white' : 'bg-gray-300 text-white'}`}>
{p.id}
</span>
<div>
<div className="font-medium text-gray-700">{p.name_de}</div>
{activeCats.length > 0 && (
<div className="text-gray-400 mt-0.5">via: {activeCats.join(', ')}</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Category Mapping */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">Kategorie Purpose Zuordnung</h4>
<div className="space-y-2">
{Object.entries(categoryMap).map(([cat, pids]) => (
<div key={cat} className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 w-24 capitalize">{cat}</span>
<div className="flex gap-1 flex-wrap">
{pids.length === 0 ? (
<span className="text-xs text-gray-400">Keine Einwilligung noetig</span>
) : (
pids.map(pid => (
<span key={pid} className="px-2 py-0.5 text-[10px] bg-purple-100 text-purple-700 rounded-full">
Purpose {pid}
</span>
))
)}
</div>
</div>
))}
</div>
</div>
{/* TC String Test */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">TC String testen</h4>
<button onClick={handleTestEncode} disabled={testing}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{testing ? 'Generiere...' : 'Test TC String generieren'}
</button>
{testResult && (
<pre className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap">
{testResult}
</pre>
)}
<p className="text-xs text-gray-400 mt-2">
Simuliert: necessary + statistics + marketing generiert base64url-codierten TC String
</p>
</div>
{/* CMP Registration Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h4 className="font-semibold text-blue-800 text-sm">CMP-Registrierung</h4>
<p className="text-xs text-blue-700 mt-1">
Fuer den produktiven Einsatz muss Ihr CMP bei der IAB Europe registriert werden.
Sie erhalten eine eindeutige CMP-ID die im TC String codiert wird.
</p>
<p className="text-xs text-blue-600 mt-2">
Registrierung: <a href="https://iabeurope.eu/tcf-for-cmps/" target="_blank" rel="noopener"
className="underline">iabeurope.eu/tcf-for-cmps</a>
</p>
</div>
</>
)}
</div>
)
}
@@ -11,8 +11,9 @@ import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
import { SiteSelector } from './_components/SiteSelector' import { SiteSelector } from './_components/SiteSelector'
import { AnalyticsDashboard } from './_components/AnalyticsDashboard' import { AnalyticsDashboard } from './_components/AnalyticsDashboard'
import { ABTestPanel } from './_components/ABTestPanel' import { ABTestPanel } from './_components/ABTestPanel'
import { TCFSettings } from './_components/TCFSettings'
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest' type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest' | 'tcf'
export default function CookieBannerPage() { export default function CookieBannerPage() {
const { state } = useSDK() const { state } = useSDK()
@@ -79,6 +80,7 @@ export default function CookieBannerPage() {
{ id: 'embed' as const, label: 'Einbettung' }, { id: 'embed' as const, label: 'Einbettung' },
{ id: 'analytics' as const, label: 'Analytik' }, { id: 'analytics' as const, label: 'Analytik' },
{ id: 'abtest' as const, label: 'A/B-Test' }, { id: 'abtest' as const, label: 'A/B-Test' },
{ id: 'tcf' as const, label: 'TCF/IAB' },
]).map(tab => ( ]).map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} <button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${ className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
@@ -101,6 +103,20 @@ export default function CookieBannerPage() {
{/* Tab: A/B-Test */} {/* Tab: A/B-Test */}
{activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />} {activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />}
{/* Tab: TCF/IAB */}
{activeTab === 'tcf' && (
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={false}
onToggle={(enabled) => {
if (activeSiteId) {
fetch(`/api/sdk/v1/banner/admin/sites/${activeSiteId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tcf_enabled: enabled }),
})
}
}}
/>
)}
{/* Tab: Konfiguration */} {/* Tab: Konfiguration */}
{activeTab !== 'config' ? null : (<> {activeTab !== 'config' ? null : (<>
{/* Stats */} {/* Stats */}
@@ -69,6 +69,7 @@ _ROUTER_MODULES = [
"banner_ab_routes", "banner_ab_routes",
"compliance_report_routes", "compliance_report_routes",
"whistleblower_routes", "whistleblower_routes",
"tcf_routes",
] ]
_loaded_count = 0 _loaded_count = 0
@@ -0,0 +1,95 @@
"""
FastAPI routes for IAB TCF 2.2 (Transparency & Consent Framework).
Endpoints:
GET /tcf/purposes — list 12 IAB purposes with translations
GET /tcf/special-features — list 2 IAB special features
GET /tcf/category-mapping — banner category → IAB purpose mapping
POST /tcf/encode — generate TC String from consent decisions
POST /tcf/encode-categories — generate TC String from banner categories
"""
import logging
from typing import Optional, List, Dict
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .tenant_utils import get_tenant_id as _get_tenant_id
from compliance.services.tcf_encoder_service import TCFEncoderService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tcf", tags=["tcf"])
class TCFEncodeRequest(BaseModel):
purpose_consents: Dict[int, bool] = {}
vendor_consents: Dict[int, bool] = {}
purpose_li: Optional[Dict[int, bool]] = None
special_features: Optional[Dict[int, bool]] = None
cmp_id: int = 1
cmp_version: int = 1
consent_language: str = "DE"
class TCFCategoryEncodeRequest(BaseModel):
categories: List[str] = []
vendor_consents: Optional[Dict[int, bool]] = None
cmp_id: int = 1
consent_language: str = "DE"
@router.get("/purposes")
def list_purposes():
return TCFEncoderService.get_purposes()
@router.get("/special-features")
def list_special_features():
return TCFEncoderService.get_special_features()
@router.get("/category-mapping")
def get_category_mapping():
return TCFEncoderService.get_category_purpose_map()
@router.post("/encode")
def encode_tc_string(body: TCFEncodeRequest):
encoder = TCFEncoderService(
cmp_id=body.cmp_id,
cmp_version=body.cmp_version,
consent_language=body.consent_language,
)
tc_string = encoder.encode(
purpose_consents=body.purpose_consents,
vendor_consents=body.vendor_consents,
purpose_li=body.purpose_li,
special_features=body.special_features,
)
return {"tc_string": tc_string, "version": 2}
@router.post("/encode-categories")
def encode_from_categories(body: TCFCategoryEncodeRequest):
encoder = TCFEncoderService(
cmp_id=body.cmp_id,
consent_language=body.consent_language,
)
tc_string = encoder.encode_from_categories(
categories=body.categories,
vendor_consents=body.vendor_consents,
)
# Also return which purposes were set
from compliance.services.tcf_encoder_service import CATEGORY_PURPOSE_MAP
purpose_ids = set()
for cat in body.categories:
purpose_ids.update(CATEGORY_PURPOSE_MAP.get(cat, []))
return {
"tc_string": tc_string,
"version": 2,
"purposes_consented": sorted(purpose_ids),
"categories": body.categories,
}
@@ -133,6 +133,24 @@ class BannerConsentService:
return max(v.retention_days for v in vendors if v.retention_days) return max(v.retention_days for v in vendors if v.retention_days)
return max((CATEGORY_RETENTION_DAYS.get(c, 365) for c in categories), default=365) return max((CATEGORY_RETENTION_DAYS.get(c, 365) for c in categories), default=365)
def _maybe_generate_tc_string(
self, tenant_id: uuid.UUID, site_id: str, categories: list[str],
) -> Optional[str]:
"""Generate TC String if TCF is enabled for this site."""
config = (
self.db.query(BannerSiteConfigDB)
.filter(BannerSiteConfigDB.tenant_id == tenant_id, BannerSiteConfigDB.site_id == site_id)
.first()
)
if not config or not config.tcf_enabled:
return None
try:
from compliance.services.tcf_encoder_service import TCFEncoderService
encoder = TCFEncoderService()
return encoder.encode_from_categories(categories)
except Exception:
return None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Consent CRUD (public SDK) # Consent CRUD (public SDK)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -163,6 +181,10 @@ class BannerConsentService:
expires_at = now + timedelta(days=retention) expires_at = now + timedelta(days=retention)
config_hash, config_ver = self._compute_config_hash(tid, site_id) config_hash, config_ver = self._compute_config_hash(tid, site_id)
# Auto-generate TC String if TCF is enabled for this site
if not consent_string:
consent_string = self._maybe_generate_tc_string(tid, site_id, categories)
existing = ( existing = (
self.db.query(BannerConsentDB) self.db.query(BannerConsentDB)
.filter( .filter(
@@ -0,0 +1,209 @@
"""
TCF 2.2 TC String Encoder — generates IAB Transparency & Consent strings.
Implements the TC String v2.2 format per IAB specification.
The TC String is a base64url-encoded bitfield containing:
- CMP metadata (ID, version, screen, consent language)
- Purpose consents (12 standard IAB purposes)
- Vendor consents (per IAB vendor ID)
- Legitimate interest signals
Reference: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
NOTE: This is a simplified encoder for CMP integration. For full GVL
(Global Vendor List) support, integrate with the IAB GVL API.
"""
import base64
import math
from datetime import datetime, timezone
from typing import Any
# IAB TCF 2.2 Standard Purposes
IAB_PURPOSES = {
1: {"name": "Store and/or access information on a device", "name_de": "Informationen auf Geraet speichern/abrufen"},
2: {"name": "Select basic ads", "name_de": "Einfache Anzeigen auswaehlen"},
3: {"name": "Create a personalised ads profile", "name_de": "Personalisiertes Anzeigenprofil erstellen"},
4: {"name": "Select personalised ads", "name_de": "Personalisierte Anzeigen auswaehlen"},
5: {"name": "Create a personalised content profile", "name_de": "Personalisiertes Inhaltsprofil erstellen"},
6: {"name": "Select personalised content", "name_de": "Personalisierte Inhalte auswaehlen"},
7: {"name": "Measure ad performance", "name_de": "Anzeigen-Leistung messen"},
8: {"name": "Measure content performance", "name_de": "Inhalte-Leistung messen"},
9: {"name": "Apply market research to generate audience insights", "name_de": "Marktforschung fuer Zielgruppen"},
10: {"name": "Develop and improve products", "name_de": "Produkte entwickeln und verbessern"},
11: {"name": "Use limited data to select content", "name_de": "Eingeschraenkte Daten fuer Inhalte nutzen"},
12: {"name": "Use limited data to select ads", "name_de": "Eingeschraenkte Daten fuer Anzeigen nutzen"},
}
# IAB Special Features
IAB_SPECIAL_FEATURES = {
1: {"name": "Use precise geolocation data", "name_de": "Praezise Standortdaten verwenden"},
2: {"name": "Actively scan device characteristics for identification", "name_de": "Geraetemerkmale aktiv scannen"},
}
# Category-to-Purpose mapping (how our banner categories map to IAB purposes)
CATEGORY_PURPOSE_MAP = {
"necessary": [], # No consent needed
"functional": [1, 11], # Device access + limited data for content
"statistics": [1, 7, 8, 9, 10], # Device access + measurement + research
"marketing": [1, 2, 3, 4, 5, 6, 7, 12], # Most purposes
}
def _int_to_bits(value: int, length: int) -> str:
"""Convert integer to fixed-length bit string."""
return bin(value)[2:].zfill(length)
def _datetime_to_deciseconds(dt: datetime) -> int:
"""Convert datetime to deciseconds since epoch (IAB format)."""
epoch = datetime(2000, 1, 1, tzinfo=timezone.utc)
return int((dt - epoch).total_seconds() * 10)
def _bits_to_base64url(bits: str) -> str:
"""Convert bit string to base64url encoding (TC String format)."""
# Pad to multiple of 8
padding = (8 - len(bits) % 8) % 8
bits += "0" * padding
# Convert to bytes
byte_array = bytearray()
for i in range(0, len(bits), 8):
byte_array.append(int(bits[i:i+8], 2))
# Base64url encode (no padding)
return base64.urlsafe_b64encode(bytes(byte_array)).rstrip(b"=").decode("ascii")
class TCFEncoderService:
"""Generates TC Strings per IAB TCF 2.2 specification."""
def __init__(
self,
cmp_id: int = 1,
cmp_version: int = 1,
consent_screen: int = 1,
consent_language: str = "DE",
):
self.cmp_id = cmp_id
self.cmp_version = cmp_version
self.consent_screen = consent_screen
self.consent_language = consent_language
def encode(
self,
purpose_consents: dict[int, bool],
vendor_consents: dict[int, bool],
purpose_li: dict[int, bool] | None = None,
special_features: dict[int, bool] | None = None,
) -> str:
"""Generate a TC String from consent decisions.
Args:
purpose_consents: {purpose_id: True/False} for purposes 1-12
vendor_consents: {vendor_id: True/False} for IAB vendor IDs
purpose_li: Legitimate interest signals per purpose
special_features: Special feature opt-ins
Returns:
Base64url-encoded TC String
"""
now = datetime.now(timezone.utc)
created = _datetime_to_deciseconds(now)
updated = created
bits = ""
# Core TC String v2 fields
bits += _int_to_bits(2, 6) # Version (6 bits) = 2
bits += _int_to_bits(created, 36) # Created (36 bits)
bits += _int_to_bits(updated, 36) # LastUpdated (36 bits)
bits += _int_to_bits(self.cmp_id, 12) # CmpId (12 bits)
bits += _int_to_bits(self.cmp_version, 12) # CmpVersion (12 bits)
bits += _int_to_bits(self.consent_screen, 6) # ConsentScreen (6 bits)
# ConsentLanguage (12 bits = 2 × 6-bit letters)
lang = self.consent_language.upper()[:2]
bits += _int_to_bits(ord(lang[0]) - ord("A"), 6)
bits += _int_to_bits(ord(lang[1]) - ord("A"), 6)
# VendorListVersion (12 bits) — use 0 if not fetching GVL
bits += _int_to_bits(0, 12)
# TcfPolicyVersion (6 bits) = 4 for TCF 2.2
bits += _int_to_bits(4, 6)
# IsServiceSpecific (1 bit) = 1
bits += "1"
# UseNonStandardTexts (1 bit) = 0
bits += "0"
# SpecialFeatureOptIns (12 bits)
sf = special_features or {}
for i in range(1, 13):
bits += "1" if sf.get(i, False) else "0"
# PurposesConsent (24 bits)
for i in range(1, 25):
bits += "1" if purpose_consents.get(i, False) else "0"
# PurposesLITransparency (24 bits)
li = purpose_li or {}
for i in range(1, 25):
bits += "1" if li.get(i, False) else "0"
# Purpose one treatment (1 bit) = 0, PublisherCC (12 bits) = DE
bits += "0"
bits += _int_to_bits(ord("D") - ord("A"), 6)
bits += _int_to_bits(ord("E") - ord("A"), 6)
# Vendor consents — Range encoding
max_vendor = max(vendor_consents.keys()) if vendor_consents else 0
bits += _int_to_bits(max_vendor, 16) # MaxVendorId
# Use bitfield encoding (simpler than range)
bits += "0" # IsRangeEncoding = 0 (bitfield)
for i in range(1, max_vendor + 1):
bits += "1" if vendor_consents.get(i, False) else "0"
# Vendor legitimate interests (same pattern)
bits += _int_to_bits(max_vendor, 16)
bits += "0"
for i in range(1, max_vendor + 1):
bits += "1" if vendor_consents.get(i, False) else "0" # Simplified: same as consent
return _bits_to_base64url(bits)
def encode_from_categories(
self,
categories: list[str],
vendor_consents: dict[int, bool] | None = None,
) -> str:
"""Generate TC String from banner category selections.
Maps our banner categories (necessary, statistics, marketing, functional)
to IAB purposes and generates the TC String.
"""
purpose_consents: dict[int, bool] = {}
for cat in categories:
for purpose_id in CATEGORY_PURPOSE_MAP.get(cat, []):
purpose_consents[purpose_id] = True
return self.encode(
purpose_consents=purpose_consents,
vendor_consents=vendor_consents or {},
)
@staticmethod
def get_purposes() -> list[dict[str, Any]]:
"""Return all 12 IAB purposes with translations."""
return [
{"id": pid, "name": info["name"], "name_de": info["name_de"]}
for pid, info in IAB_PURPOSES.items()
]
@staticmethod
def get_special_features() -> list[dict[str, Any]]:
return [
{"id": fid, "name": info["name"], "name_de": info["name_de"]}
for fid, info in IAB_SPECIAL_FEATURES.items()
]
@staticmethod
def get_category_purpose_map() -> dict[str, list[int]]:
return CATEGORY_PURPOSE_MAP