289ec5f396
Build + Deploy / build-admin-compliance (push) Successful in 2m28s
Build + Deploy / build-backend-compliance (push) Successful in 3m48s
Build + Deploy / build-ai-sdk (push) Failing after 45s
Build + Deploy / build-developer-portal (push) Successful in 1m28s
Build + Deploy / build-tts (push) Successful in 1m48s
Build + Deploy / build-document-crawler (push) Successful in 48s
Build + Deploy / build-dsms-gateway (push) Successful in 34s
Build + Deploy / build-dsms-node (push) Successful in 20s
CI / branch-name (push) Has been skipped
Build + Deploy / trigger-orca (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 24s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m1s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 49s
CI / test-python-backend (push) Successful in 45s
CI / test-python-document-crawler (push) Successful in 31s
CI / test-python-dsms-gateway (push) Successful in 27s
CI / validate-canonical-controls (push) Successful in 18s
Extend banner consent records with consent_method, banner_version, banner_config_hash, geo, page_url, referrer, device info, session_id and consent_scope for full Art. 7 DSGVO proof with any tracking vendor. Migration 107, backward-compatible (all fields nullable). Admin detail modal shows tracking context, device info and technical data. Fix pre-existing str|None → Optional[str] for Python 3.9 compat. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
13 KiB
TypeScript
243 lines
13 KiB
TypeScript
'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',
|
|
}
|
|
|
|
const methodLabels: Record<string, string> = {
|
|
accept_all: 'Alle akzeptiert',
|
|
reject_all: 'Nur notwendige',
|
|
custom_selection: 'Individuelle Auswahl',
|
|
}
|
|
|
|
const methodColors: Record<string, string> = {
|
|
accept_all: 'bg-green-100 text-green-700',
|
|
reject_all: 'bg-red-100 text-red-700',
|
|
custom_selection: 'bg-yellow-100 text-yellow-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">Methode</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.consent_method ? (
|
|
<span className={`px-2 py-0.5 rounded-full ${methodColors[record.consent_method] || 'bg-gray-100 text-gray-600'}`}>
|
|
{methodLabels[record.consent_method] || record.consent_method}
|
|
</span>
|
|
) : <span className="text-gray-400">—</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">Methode</span>
|
|
<span>{detail.consent_method ? (
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${methodColors[detail.consent_method] || 'bg-gray-100'}`}>
|
|
{methodLabels[detail.consent_method] || detail.consent_method}
|
|
</span>
|
|
) : '—'}</span>
|
|
</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="flex justify-between"><span className="text-gray-500">Geltungsbereich</span><span>{detail.consent_scope || '—'}</span></div>
|
|
{detail.banner_version && (
|
|
<div className="flex justify-between"><span className="text-gray-500">Banner-Version</span><span>{detail.banner_version}</span></div>
|
|
)}
|
|
|
|
{/* Tracking-Kontext */}
|
|
<div className="border-t border-gray-100 pt-3">
|
|
<p className="text-xs font-semibold text-gray-700 mb-2">Tracking-Kontext</p>
|
|
{detail.page_url && <div className="flex justify-between"><span className="text-gray-500 text-xs">Seite</span><span className="text-xs text-gray-600 truncate max-w-[250px]">{detail.page_url}</span></div>}
|
|
{detail.referrer && <div className="flex justify-between mt-1"><span className="text-gray-500 text-xs">Referrer</span><span className="text-xs text-gray-600 truncate max-w-[250px]">{detail.referrer}</span></div>}
|
|
{detail.geo_country && <div className="flex justify-between mt-1"><span className="text-gray-500 text-xs">Land</span><span className="text-xs text-gray-600">{detail.geo_country}{detail.geo_region ? ` / ${detail.geo_region}` : ''}</span></div>}
|
|
</div>
|
|
|
|
{/* Device-Informationen */}
|
|
<div className="border-t border-gray-100 pt-3">
|
|
<p className="text-xs font-semibold text-gray-700 mb-2">Device</p>
|
|
<div className="grid grid-cols-2 gap-1 text-xs">
|
|
<span className="text-gray-500">Typ</span><span className="text-gray-600">{detail.device_type || '—'}</span>
|
|
<span className="text-gray-500">Browser</span><span className="text-gray-600">{detail.browser || shortenUA(detail.user_agent)}</span>
|
|
<span className="text-gray-500">OS</span><span className="text-gray-600">{detail.os || '—'}</span>
|
|
<span className="text-gray-500">Auflösung</span><span className="text-gray-600">{detail.screen_resolution || '—'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Technische Details */}
|
|
<div className="border-t border-gray-100 pt-3">
|
|
<p className="text-xs font-semibold text-gray-700 mb-2">Technisch</p>
|
|
<div className="space-y-1">
|
|
<div><span className="text-gray-500 text-xs">User-Agent</span><p className="text-xs text-gray-600 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 font-mono">{detail.ip_hash}</p></div>}
|
|
{detail.session_id && <div><span className="text-gray-500 text-xs">Session</span><p className="text-xs text-gray-600 font-mono">{detail.session_id}</p></div>}
|
|
{detail.banner_config_hash && <div><span className="text-gray-500 text-xs">Config-Hash</span><p className="text-xs text-gray-600 font-mono">{detail.banner_config_hash}</p></div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|