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
+35 -6
View File
@@ -54,27 +54,41 @@ export default function CMPDashboardPage() {
const [consentStats, setConsentStats] = useState<ConsentStats | null>(null) const [consentStats, setConsentStats] = useState<ConsentStats | null>(null)
const [dsrStats, setDSRStats] = useState<DSRStats | null>(null) const [dsrStats, setDSRStats] = useState<DSRStats | null>(null)
const [sites, setSites] = useState<any[]>([]) const [sites, setSites] = useState<any[]>([])
const [selectedSite, setSelectedSite] = useState<string>('')
const [loading, setLoading] = useState(true) 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(() => { useEffect(() => {
async function load() { 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 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([ const [consent, dsr, siteList] = await Promise.all([
fb('admin/stats/preview-test-site'),
fa('einwilligungen/consents/stats'), fa('einwilligungen/consents/stats'),
fa('dsr/stats'), fa('dsr/stats'),
fb('admin/sites'), fb('admin/sites'),
]) ])
setBannerStats(banner)
setConsentStats(consent) setConsentStats(consent)
setDSRStats(dsr) 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) setLoading(false)
} }
load() 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 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 dsrOpen = dsrStats ? (dsrStats.by_status?.intake || 0) + (dsrStats.by_status?.processing || 0) + (dsrStats.by_status?.identity_verification || 0) : 0
const dsrOverdue = dsrStats?.overdue || 0 const dsrOverdue = dsrStats?.overdue || 0
@@ -86,13 +100,28 @@ export default function CMPDashboardPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Consent Management Platform</h1> <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>
<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" <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"> className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
Banner testen Banner testen
</Link> </Link>
</div> </div>
</div>
{/* KPI Cards */} {/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
@@ -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 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 { useState } from 'react'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader' 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 { ConsentRecord } from './_types'
import { useConsents } from './_hooks/useConsents' import { useConsents } from './_hooks/useConsents'
@@ -12,8 +12,13 @@ import { SearchAndFilter } from './_components/SearchAndFilter'
import { RecordsTable } from './_components/RecordsTable' import { RecordsTable } from './_components/RecordsTable'
import { Pagination } from './_components/Pagination' import { Pagination } from './_components/Pagination'
import { ConsentDetailModal } from './_components/ConsentDetailModal' import { ConsentDetailModal } from './_components/ConsentDetailModal'
import BannerConsentsTab from './_components/BannerConsentsTab'
type ConsentTab = 'visitors' | 'users'
export default function EinwilligungenPage() { export default function EinwilligungenPage() {
const [activeTab, setActiveTab] = useState<ConsentTab>('visitors')
const { const {
records, records,
currentPage, currentPage,
@@ -63,6 +68,37 @@ export default function EinwilligungenPage() {
{/* Navigation Tabs */} {/* Navigation Tabs */}
<EinwilligungenNavTabs /> <EinwilligungenNavTabs />
{/* 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>
{/* Tab Content */}
{activeTab === 'visitors' ? (
<BannerConsentsTab />
) : (
<>
{/* Stats */} {/* Stats */}
<StatsGrid <StatsGrid
total={globalStats.total} total={globalStats.total}
@@ -78,7 +114,7 @@ export default function EinwilligungenPage() {
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div> <div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
<div className="text-sm text-purple-700"> <div className="text-sm text-purple-700">
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten. 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. Klicken Sie auf &quot;Details&quot; um die vollständige Historie eines Nutzers einzusehen.
</div> </div>
</div> </div>
</div> </div>
@@ -109,6 +145,8 @@ export default function EinwilligungenPage() {
onRevoke={handleRevoke} onRevoke={handleRevoke}
/> />
)} )}
</>
)}
</div> </div>
) )
} }
@@ -204,6 +204,19 @@ async def get_site_stats(
return service.get_site_stats(tenant_id, site_id) 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 # Admin — Sites
# ============================================================================= # =============================================================================
@@ -348,7 +348,14 @@ class BannerConsentService:
total = base.count() total = base.count()
category_stats: dict[str, int] = {} category_stats: dict[str, int] = {}
for c in base.all(): 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: for cat in cats:
category_stats[cat] = category_stats.get(cat, 0) + 1 category_stats[cat] = category_stats.get(cat, 0) + 1
return { return {
@@ -362,3 +369,45 @@ class BannerConsentService:
for cat, count in category_stats.items() 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}