feat(cmp): unified consent view — Website-Besucher + Login-Nutzer tabs

Merges two separate consent views into one unified page at /sdk/einwilligungen:
- Tab "Website-Besucher": device-based banner consents with site selector
- Tab "Login-Nutzer": user-based DSGVO consents (existing, unchanged)

Backend:
- New endpoint GET /admin/consents for paginated banner consent records
- Fix: categories JSON string parsing (was iterating chars instead of array)

CMP Dashboard:
- Dynamic site selector replacing hardcoded "preview-test-site"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-10 22:41:56 +02:00
parent 9c0d471277
commit bdbc30e47b
7 changed files with 478 additions and 52 deletions
+39 -10
View File
@@ -54,27 +54,41 @@ export default function CMPDashboardPage() {
const [consentStats, setConsentStats] = useState<ConsentStats | null>(null)
const [dsrStats, setDSRStats] = useState<DSRStats | null>(null)
const [sites, setSites] = useState<any[]>([])
const [selectedSite, setSelectedSite] = useState<string>('')
const [loading, setLoading] = useState(true)
const fb = (path: string) => fetch(`${BANNER_API}/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
// Load sites + consent/dsr stats on mount
useEffect(() => {
async function load() {
const fb = (path: string) => fetch(`${BANNER_API}/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
const fa = (path: string) => fetch(`/api/sdk/v1/compliance/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
const [banner, consent, dsr, siteList] = await Promise.all([
fb('admin/stats/preview-test-site'),
const [consent, dsr, siteList] = await Promise.all([
fa('einwilligungen/consents/stats'),
fa('dsr/stats'),
fb('admin/sites'),
])
setBannerStats(banner)
setConsentStats(consent)
setDSRStats(dsr)
setSites(siteList || [])
const loadedSites = Array.isArray(siteList) ? siteList : []
setSites(loadedSites)
// Auto-select first site
if (loadedSites.length > 0) {
setSelectedSite(loadedSites[0].site_id || loadedSites[0].siteId || '')
}
setLoading(false)
}
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load banner stats when selected site changes
useEffect(() => {
if (!selectedSite) return
fb(`admin/stats/${selectedSite}`).then(setBannerStats)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSite])
const totalConsents = (bannerStats?.total_consents || 0) + (consentStats?.total_consents || 0)
const dsrOpen = dsrStats ? (dsrStats.by_status?.intake || 0) + (dsrStats.by_status?.processing || 0) + (dsrStats.by_status?.identity_verification || 0) : 0
const dsrOverdue = dsrStats?.overdue || 0
@@ -86,12 +100,27 @@ export default function CMPDashboardPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Consent Management Platform</h1>
<p className="text-gray-500 mt-1">Ueberblick ueber Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
<p className="text-gray-500 mt-1">Überblick über Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
</div>
<div className="flex items-center gap-3">
{sites.length > 0 && (
<select
value={selectedSite}
onChange={e => setSelectedSite(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{sites.map((s: any) => (
<option key={s.site_id || s.siteId} value={s.site_id || s.siteId}>
{s.site_name || s.siteName || s.site_id || s.siteId}
</option>
))}
</select>
)}
<Link href="/sdk/cookie-banner/preview"
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
Banner testen
</Link>
</div>
<Link href="/sdk/cookie-banner/preview"
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
Banner testen
</Link>
</div>
{/* KPI Cards */}
@@ -0,0 +1,197 @@
'use client'
import { useState } from 'react'
import { useBannerConsents } from '../_hooks/useBannerConsents'
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
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' })
}
function shortenFingerprint(fp: string): string {
return fp.length > 12 ? fp.slice(0, 12) + '...' : fp
}
function shortenUA(ua: string | null): string {
if (!ua) return '—'
const match = ua.match(/(Chrome|Safari|Firefox|Edge|Opera)\/[\d.]+/)
if (match) return match[0]
return ua.length > 30 ? ua.slice(0, 30) + '...' : ua
}
const categoryColors: Record<string, string> = {
essential: 'bg-gray-100 text-gray-700',
functional: 'bg-blue-100 text-blue-700',
analytics: 'bg-purple-100 text-purple-700',
marketing: 'bg-pink-100 text-pink-700',
}
export default function BannerConsentsTab() {
const {
records, sites, selectedSite, changeSite,
stats, currentPage, setCurrentPage, totalRecords, loading,
} = useBannerConsents()
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
return (
<div className="space-y-6">
{/* Stats + Site Selector */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-500">
<span className="text-2xl font-bold text-gray-900">{totalRecords}</span> Consents
</div>
{stats && Object.keys(stats.category_acceptance).length > 0 && (
<div className="flex gap-2">
{Object.entries(stats.category_acceptance).map(([cat, data]) => (
<span key={cat} className={`text-xs px-2 py-1 rounded-full ${categoryColors[cat] || 'bg-gray-100 text-gray-600'}`}>
{cat}: {data.rate}%
</span>
))}
</div>
)}
</div>
{sites.length > 0 && (
<select
value={selectedSite}
onChange={e => changeSite(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm bg-white"
>
{sites.map(s => (
<option key={s.site_id} value={s.site_id}>
{s.site_name || s.site_id}
</option>
))}
</select>
)}
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-500">Device</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Kategorien</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Verknüpft mit</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Erteilt am</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Ablauf</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Browser</th>
<th className="text-right px-4 py-3 font-medium text-gray-500">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading && records.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-400">Laden...</td></tr>
) : records.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-400">Keine Consents vorhanden</td></tr>
) : (
records.map(record => (
<tr key={record.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3 font-mono text-xs text-gray-600">{shortenFingerprint(record.device_fingerprint)}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{record.categories.length > 0 ? record.categories.map(cat => (
<span key={cat} className={`text-xs px-2 py-0.5 rounded-full ${categoryColors[cat] || 'bg-gray-100 text-gray-600'}`}>
{cat}
</span>
)) : <span className="text-xs text-gray-400"></span>}
</div>
</td>
<td className="px-4 py-3 text-xs">
{record.linked_email
? <span className="text-purple-600">{record.linked_email}</span>
: <span className="text-gray-400"> (anonym)</span>
}
</td>
<td className="px-4 py-3 text-xs text-gray-600">{formatDate(record.created_at)}</td>
<td className="px-4 py-3 text-xs text-gray-600">{formatDate(record.expires_at)}</td>
<td className="px-4 py-3 text-xs text-gray-500">{shortenUA(record.user_agent)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setDetail(record)}
className="text-xs text-purple-600 hover:text-purple-800 font-medium"
>
Details
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
Seite {currentPage} von {totalPages} ({totalRecords} Einträge)
</span>
<div className="flex gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage <= 1}
className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-30"
>
Zurück
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-30"
>
Weiter
</button>
</div>
</div>
)}
{/* Detail Modal */}
{detail && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setDetail(null)}>
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full p-6 max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">Consent Details</h3>
<button onClick={() => setDetail(null)} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between"><span className="text-gray-500">ID</span><span className="font-mono text-xs">{detail.id}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Site</span><span>{detail.site_id}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Device</span><span className="font-mono text-xs">{detail.device_fingerprint}</span></div>
<div className="flex justify-between items-start">
<span className="text-gray-500">Kategorien</span>
<div className="flex flex-wrap gap-1 justify-end">
{detail.categories.map(cat => (
<span key={cat} className={`text-xs px-2 py-0.5 rounded-full ${categoryColors[cat] || 'bg-gray-100'}`}>{cat}</span>
))}
</div>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Verknüpft mit</span>
<span>{detail.linked_email || '— (anonym)'}</span>
</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>
<div className="flex justify-between"><span className="text-gray-500">Aktualisiert</span><span>{formatDate(detail.updated_at)}</span></div>
<div className="border-t border-gray-100 pt-3">
<span className="text-gray-500 text-xs">User-Agent</span>
<p className="text-xs text-gray-600 mt-1 font-mono break-all">{detail.user_agent || '—'}</p>
</div>
{detail.ip_hash && (
<div>
<span className="text-gray-500 text-xs">IP-Hash</span>
<p className="text-xs text-gray-600 mt-1 font-mono">{detail.ip_hash}</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,73 @@
import { useState, useEffect, useCallback } from 'react'
import { BannerConsentRecord, BannerConsentStats, BannerSite, PAGE_SIZE } from '../_types'
const BANNER_API = '/api/sdk/v1/banner'
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const HEADERS = { 'x-tenant-id': TENANT_ID }
function fb(path: string) {
return fetch(`${BANNER_API}/${path}`, { headers: HEADERS })
.then(r => r.ok ? r.json() : null)
.catch(() => null)
}
export function useBannerConsents() {
const [records, setRecords] = useState<BannerConsentRecord[]>([])
const [sites, setSites] = useState<BannerSite[]>([])
const [selectedSite, setSelectedSite] = useState<string>('')
const [stats, setStats] = useState<BannerConsentStats | null>(null)
const [currentPage, setCurrentPage] = useState(1)
const [totalRecords, setTotalRecords] = useState(0)
const [loading, setLoading] = useState(true)
// Load sites on mount
useEffect(() => {
fb('admin/sites').then(data => {
const list = Array.isArray(data) ? data : []
setSites(list)
if (list.length > 0) {
setSelectedSite(list[0].site_id)
}
setLoading(false)
})
}, [])
// Load consents + stats when site or page changes
const loadData = useCallback(async () => {
if (!selectedSite) return
setLoading(true)
const offset = (currentPage - 1) * PAGE_SIZE
const [consentsData, statsData] = await Promise.all([
fb(`admin/consents?site_id=${selectedSite}&limit=${PAGE_SIZE}&offset=${offset}`),
fb(`admin/stats/${selectedSite}`),
])
if (consentsData) {
setRecords(consentsData.consents || [])
setTotalRecords(consentsData.total || 0)
}
setStats(statsData)
setLoading(false)
}, [selectedSite, currentPage])
useEffect(() => {
loadData()
}, [loadData])
const changeSite = (siteId: string) => {
setSelectedSite(siteId)
setCurrentPage(1)
}
return {
records,
sites,
selectedSite,
changeSite,
stats,
currentPage,
setCurrentPage,
totalRecords,
loading,
reload: loadData,
}
}
@@ -100,3 +100,30 @@ export function formatDate(date: Date | null): string {
}
export const PAGE_SIZE = 50
// Banner (Device-based) Consent
export interface BannerConsentRecord {
id: string
site_id: string
device_fingerprint: string
categories: string[]
vendors: string[]
ip_hash: string | null
user_agent: string | null
linked_email: string | null
consent_string: string | null
expires_at: string | null
created_at: string | null
updated_at: string | null
}
export interface BannerConsentStats {
total_consents: number
category_acceptance: Record<string, { count: number; rate: number }>
}
export interface BannerSite {
site_id: string
site_name: string
site_url: string
}
@@ -2,7 +2,7 @@
import { useState } from 'react'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { History } from 'lucide-react'
import { History, Globe, User } from 'lucide-react'
import { ConsentRecord } from './_types'
import { useConsents } from './_hooks/useConsents'
@@ -12,8 +12,13 @@ import { SearchAndFilter } from './_components/SearchAndFilter'
import { RecordsTable } from './_components/RecordsTable'
import { Pagination } from './_components/Pagination'
import { ConsentDetailModal } from './_components/ConsentDetailModal'
import BannerConsentsTab from './_components/BannerConsentsTab'
type ConsentTab = 'visitors' | 'users'
export default function EinwilligungenPage() {
const [activeTab, setActiveTab] = useState<ConsentTab>('visitors')
const {
records,
currentPage,
@@ -63,51 +68,84 @@ export default function EinwilligungenPage() {
{/* Navigation Tabs */}
<EinwilligungenNavTabs />
{/* Stats */}
<StatsGrid
total={globalStats.total}
active={globalStats.active}
revoked={globalStats.revoked}
versionUpdates={versionUpdates}
/>
{/* Info Banner */}
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-4 flex items-start gap-3">
<History className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
<div className="text-sm text-purple-700">
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
Klicken Sie auf "Details" um die vollständige Historie eines Nutzers einzusehen.
</div>
</div>
{/* Consent Type Tabs: Website-Besucher / Login-Nutzer */}
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl w-fit">
<button
onClick={() => setActiveTab('visitors')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'visitors'
? 'bg-white text-purple-700 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Globe className="w-4 h-4" />
Website-Besucher
</button>
<button
onClick={() => setActiveTab('users')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'users'
? 'bg-white text-purple-700 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<User className="w-4 h-4" />
Login-Nutzer
</button>
</div>
{/* Search and Filter */}
<SearchAndFilter
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filter={filter}
onFilterChange={setFilter}
/>
{/* Tab Content */}
{activeTab === 'visitors' ? (
<BannerConsentsTab />
) : (
<>
{/* Stats */}
<StatsGrid
total={globalStats.total}
active={globalStats.active}
revoked={globalStats.revoked}
versionUpdates={versionUpdates}
/>
{/* Records Table */}
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
{/* Info Banner */}
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-4 flex items-start gap-3">
<History className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
<div className="text-sm text-purple-700">
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
Klicken Sie auf &quot;Details&quot; um die vollständige Historie eines Nutzers einzusehen.
</div>
</div>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalRecords={totalRecords}
onPageChange={setCurrentPage}
/>
{/* Search and Filter */}
<SearchAndFilter
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filter={filter}
onFilterChange={setFilter}
/>
{/* Detail Modal */}
{selectedRecord && (
<ConsentDetailModal
record={selectedRecord}
onClose={() => setSelectedRecord(null)}
onRevoke={handleRevoke}
/>
{/* Records Table */}
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalRecords={totalRecords}
onPageChange={setCurrentPage}
/>
{/* Detail Modal */}
{selectedRecord && (
<ConsentDetailModal
record={selectedRecord}
onClose={() => setSelectedRecord(null)}
onRevoke={handleRevoke}
/>
)}
</>
)}
</div>
)
@@ -204,6 +204,19 @@ async def get_site_stats(
return service.get_site_stats(tenant_id, site_id)
@router.get("/admin/consents")
async def list_banner_consents(
site_id: str | None = None,
limit: int = 50,
offset: int = 0,
tenant_id: str = Depends(_get_tenant),
service: BannerConsentService = Depends(get_consent_service),
) -> dict[str, Any]:
"""Paginated list of banner consents for admin dashboard."""
with translate_domain_errors():
return service.list_consents(tenant_id, site_id, limit, offset)
# =============================================================================
# Admin — Sites
# =============================================================================
@@ -348,7 +348,14 @@ class BannerConsentService:
total = base.count()
category_stats: dict[str, int] = {}
for c in base.all():
cats: list[str] = list(c.categories or [])
raw = c.categories or []
if isinstance(raw, str):
try:
import json
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
raw = []
cats: list[str] = list(raw) if isinstance(raw, list) else []
for cat in cats:
category_stats[cat] = category_stats.get(cat, 0) + 1
return {
@@ -362,3 +369,45 @@ class BannerConsentService:
for cat, count in category_stats.items()
},
}
def list_consents(
self, tenant_id: str, site_id: str | None = None,
limit: int = 50, offset: int = 0,
) -> dict[str, Any]:
"""List paginated banner consents with parsed categories."""
import json as _json
tid = uuid.UUID(tenant_id)
base = self.db.query(BannerConsentDB).filter(BannerConsentDB.tenant_id == tid)
if site_id:
base = base.filter(BannerConsentDB.site_id == site_id)
total = base.count()
rows = base.order_by(BannerConsentDB.created_at.desc()).offset(offset).limit(limit).all()
consents = []
for c in rows:
raw_cats = c.categories or []
if isinstance(raw_cats, str):
try:
raw_cats = _json.loads(raw_cats)
except (ValueError, TypeError):
raw_cats = []
raw_vendors = c.vendors or []
if isinstance(raw_vendors, str):
try:
raw_vendors = _json.loads(raw_vendors)
except (ValueError, TypeError):
raw_vendors = []
consents.append({
"id": str(c.id),
"site_id": c.site_id,
"device_fingerprint": c.device_fingerprint,
"categories": list(raw_cats) if isinstance(raw_cats, list) else [],
"vendors": list(raw_vendors) if isinstance(raw_vendors, list) else [],
"ip_hash": c.ip_hash,
"user_agent": c.user_agent,
"linked_email": c.linked_email,
"consent_string": c.consent_string,
"expires_at": c.expires_at.isoformat() if c.expires_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
})
return {"consents": consents, "total": total, "limit": limit, "offset": offset}