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:
@@ -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,12 +100,27 @@ 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 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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* 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">×</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,51 +68,84 @@ export default function EinwilligungenPage() {
|
|||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<EinwilligungenNavTabs />
|
<EinwilligungenNavTabs />
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Consent Type Tabs: Website-Besucher / Login-Nutzer */}
|
||||||
<StatsGrid
|
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl w-fit">
|
||||||
total={globalStats.total}
|
<button
|
||||||
active={globalStats.active}
|
onClick={() => setActiveTab('visitors')}
|
||||||
revoked={globalStats.revoked}
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
versionUpdates={versionUpdates}
|
activeTab === 'visitors'
|
||||||
/>
|
? 'bg-white text-purple-700 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
{/* 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" />
|
<Globe className="w-4 h-4" />
|
||||||
<div>
|
Website-Besucher
|
||||||
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
|
</button>
|
||||||
<div className="text-sm text-purple-700">
|
<button
|
||||||
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
|
onClick={() => setActiveTab('users')}
|
||||||
Klicken Sie auf "Details" um die vollständige Historie eines Nutzers einzusehen.
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
</div>
|
activeTab === 'users'
|
||||||
</div>
|
? 'bg-white text-purple-700 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
Login-Nutzer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
{/* Tab Content */}
|
||||||
<SearchAndFilter
|
{activeTab === 'visitors' ? (
|
||||||
searchQuery={searchQuery}
|
<BannerConsentsTab />
|
||||||
onSearchChange={setSearchQuery}
|
) : (
|
||||||
filter={filter}
|
<>
|
||||||
onFilterChange={setFilter}
|
{/* Stats */}
|
||||||
/>
|
<StatsGrid
|
||||||
|
total={globalStats.total}
|
||||||
|
active={globalStats.active}
|
||||||
|
revoked={globalStats.revoked}
|
||||||
|
versionUpdates={versionUpdates}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Records Table */}
|
{/* Info Banner */}
|
||||||
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Search and Filter */}
|
||||||
<Pagination
|
<SearchAndFilter
|
||||||
currentPage={currentPage}
|
searchQuery={searchQuery}
|
||||||
totalRecords={totalRecords}
|
onSearchChange={setSearchQuery}
|
||||||
onPageChange={setCurrentPage}
|
filter={filter}
|
||||||
/>
|
onFilterChange={setFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Detail Modal */}
|
{/* Records Table */}
|
||||||
{selectedRecord && (
|
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
|
||||||
<ConsentDetailModal
|
|
||||||
record={selectedRecord}
|
{/* Pagination */}
|
||||||
onClose={() => setSelectedRecord(null)}
|
<Pagination
|
||||||
onRevoke={handleRevoke}
|
currentPage={currentPage}
|
||||||
/>
|
totalRecords={totalRecords}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{selectedRecord && (
|
||||||
|
<ConsentDetailModal
|
||||||
|
record={selectedRecord}
|
||||||
|
onClose={() => setSelectedRecord(null)}
|
||||||
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user