diff --git a/admin-compliance/app/sdk/cmp/page.tsx b/admin-compliance/app/sdk/cmp/page.tsx index 9ee4f96..e53a006 100644 --- a/admin-compliance/app/sdk/cmp/page.tsx +++ b/admin-compliance/app/sdk/cmp/page.tsx @@ -54,27 +54,41 @@ export default function CMPDashboardPage() { const [consentStats, setConsentStats] = useState(null) const [dsrStats, setDSRStats] = useState(null) const [sites, setSites] = useState([]) + const [selectedSite, setSelectedSite] = useState('') 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() {

Consent Management Platform

-

Ueberblick ueber Einwilligungen, Betroffenenrechte und Vendor-Compliance

+

Überblick über Einwilligungen, Betroffenenrechte und Vendor-Compliance

+
+
+ {sites.length > 0 && ( + + )} + + Banner testen +
- - Banner testen -
{/* KPI Cards */} diff --git a/admin-compliance/app/sdk/einwilligungen/_components/BannerConsentsTab.tsx b/admin-compliance/app/sdk/einwilligungen/_components/BannerConsentsTab.tsx new file mode 100644 index 0000000..a5ccf8f --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_components/BannerConsentsTab.tsx @@ -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 = { + 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(null) + const totalPages = Math.ceil(totalRecords / PAGE_SIZE) + + return ( +
+ {/* Stats + Site Selector */} +
+
+
+ {totalRecords} Consents +
+ {stats && Object.keys(stats.category_acceptance).length > 0 && ( +
+ {Object.entries(stats.category_acceptance).map(([cat, data]) => ( + + {cat}: {data.rate}% + + ))} +
+ )} +
+ {sites.length > 0 && ( + + )} +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {loading && records.length === 0 ? ( + + ) : records.length === 0 ? ( + + ) : ( + records.map(record => ( + + + + + + + + + + )) + )} + +
DeviceKategorienVerknüpft mitErteilt amAblaufBrowserAktion
Laden...
Keine Consents vorhanden
{shortenFingerprint(record.device_fingerprint)} +
+ {record.categories.length > 0 ? record.categories.map(cat => ( + + {cat} + + )) : } +
+
+ {record.linked_email + ? {record.linked_email} + : — (anonym) + } + {formatDate(record.created_at)}{formatDate(record.expires_at)}{shortenUA(record.user_agent)} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Seite {currentPage} von {totalPages} ({totalRecords} Einträge) + +
+ + +
+
+ )} + + {/* Detail Modal */} + {detail && ( +
setDetail(null)}> +
e.stopPropagation()}> +
+

Consent Details

+ +
+ +
+
ID{detail.id}
+
Site{detail.site_id}
+
Device{detail.device_fingerprint}
+
+ Kategorien +
+ {detail.categories.map(cat => ( + {cat} + ))} +
+
+
+ Verknüpft mit + {detail.linked_email || '— (anonym)'} +
+
Erteilt{formatDate(detail.created_at)}
+
Ablauf{formatDate(detail.expires_at)}
+
Aktualisiert{formatDate(detail.updated_at)}
+
+ User-Agent +

{detail.user_agent || '—'}

+
+ {detail.ip_hash && ( +
+ IP-Hash +

{detail.ip_hash}

+
+ )} +
+
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/einwilligungen/_hooks/useBannerConsents.ts b/admin-compliance/app/sdk/einwilligungen/_hooks/useBannerConsents.ts new file mode 100644 index 0000000..55fa447 --- /dev/null +++ b/admin-compliance/app/sdk/einwilligungen/_hooks/useBannerConsents.ts @@ -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([]) + const [sites, setSites] = useState([]) + const [selectedSite, setSelectedSite] = useState('') + const [stats, setStats] = useState(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, + } +} diff --git a/admin-compliance/app/sdk/einwilligungen/_types.ts b/admin-compliance/app/sdk/einwilligungen/_types.ts index 9245a9f..d7d4476 100644 --- a/admin-compliance/app/sdk/einwilligungen/_types.ts +++ b/admin-compliance/app/sdk/einwilligungen/_types.ts @@ -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 +} + +export interface BannerSite { + site_id: string + site_name: string + site_url: string +} diff --git a/admin-compliance/app/sdk/einwilligungen/page.tsx b/admin-compliance/app/sdk/einwilligungen/page.tsx index 8f07199..b4ae05e 100644 --- a/admin-compliance/app/sdk/einwilligungen/page.tsx +++ b/admin-compliance/app/sdk/einwilligungen/page.tsx @@ -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('visitors') + const { records, currentPage, @@ -63,51 +68,84 @@ export default function EinwilligungenPage() { {/* Navigation Tabs */} - {/* Stats */} - - - {/* Info Banner */} -
- -
-
Consent-Historie aktiviert
-
- 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. -
-
+ {/* Consent Type Tabs: Website-Besucher / Login-Nutzer */} +
+ +
- {/* Search and Filter */} - + {/* Tab Content */} + {activeTab === 'visitors' ? ( + + ) : ( + <> + {/* Stats */} + - {/* Records Table */} - + {/* Info Banner */} +
+ +
+
Consent-Historie aktiviert
+
+ 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. +
+
+
- {/* Pagination */} - + {/* Search and Filter */} + - {/* Detail Modal */} - {selectedRecord && ( - setSelectedRecord(null)} - onRevoke={handleRevoke} - /> + {/* Records Table */} + + + {/* Pagination */} + + + {/* Detail Modal */} + {selectedRecord && ( + setSelectedRecord(null)} + onRevoke={handleRevoke} + /> + )} + )}
) diff --git a/backend-compliance/compliance/api/banner_routes.py b/backend-compliance/compliance/api/banner_routes.py index 614e360..b67365a 100644 --- a/backend-compliance/compliance/api/banner_routes.py +++ b/backend-compliance/compliance/api/banner_routes.py @@ -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 # ============================================================================= diff --git a/backend-compliance/compliance/services/banner_consent_service.py b/backend-compliance/compliance/services/banner_consent_service.py index 8affecf..f6f3fc3 100644 --- a/backend-compliance/compliance/services/banner_consent_service.py +++ b/backend-compliance/compliance/services/banner_consent_service.py @@ -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}