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 [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">×</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 "Details" 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}
|
||||
|
||||
Reference in New Issue
Block a user