Files
breakpilot-compliance/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx
T
Benjamin Admin d18ef79f18 feat(cookie): Pro-Cookie-Library-Abgleich (2287er OCD + 35er rich) + Panel
- analyze_cookies gleicht Cookies gegen BEIDE Libraries ab: compliance.cookie_library
  (2287, OCD/CC0 — Kategorie/Retention) + 35er rich-DB (technical_necessity/reid/
  schrems/eu_alternative). 5 Befund-Typen: tracker_as_necessary, missing_purpose,
  excessive_lifetime (Art.5), third_country (Art.44), eu_alternative (kommerziell).
- Endpoint GET /snapshots/{id}/cookie-check (load_big_library batch + analyze).
- Frontend CookieLibraryPanel im Snapshot-Detail.
- Fix CookieResultView: Zweck nicht mehr auf 60 Zeichen gekuerzt; Rolle 'unknown'
  als Strich statt 'Unbekannt'.

Tests: 7 backend + frontend vitest gruen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 08:18:25 +02:00

186 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* CookieResultView — strukturierte Cookie-/Vendor-Auswertung aus einem
* gespeicherten Snapshot (cmp_vendors), OHNE Re-Crawl.
*
* KPIs + Empfänger-Gruppen (Eigene / Auftragsverarbeiter / Joint Controller —
* wie im Audit-Mail-VVT) + aufklappbare Vendor→Cookie-Tabelle. Verarbeitet
* Mengen (780 Cookies bei BMW): Vendors gruppiert, Cookies on-demand.
*/
import React, { useMemo, useState } from 'react'
export interface SnapshotCookie {
name: string
expiry?: string
purpose?: string
is_third_party?: boolean
functional_role?: string
}
export interface SnapshotVendor {
name: string
cookies?: SnapshotCookie[]
category?: string
country?: string
recipient_type?: string
compliance_score?: number
compliance_flags?: string[]
opt_out_ok?: boolean
}
interface Snapshot {
id: string
site_domain?: string
created_at?: string
cmp_vendors?: SnapshotVendor[]
}
const ROLE_LABEL: Record<string, string> = {
unknown: 'Unbekannt', ad_pixel: 'Werbe-Pixel', auth_token: 'Auth-Token',
preference: 'Präferenz', visitor_id: 'Besucher-ID', consent_state: 'Consent',
tracking: 'Tracking',
}
const CAT_COLOR: Record<string, string> = {
necessary: 'bg-green-100 text-green-700', functional: 'bg-blue-100 text-blue-700',
statistics: 'bg-amber-100 text-amber-700', marketing: 'bg-red-100 text-red-700',
}
const EEA = new Set([
'DE','FR','IE','NL','AT','BE','BG','HR','CY','CZ','DK','EE','FI','GR','HU',
'IT','LV','LT','LU','MT','PL','PT','RO','SK','SI','ES','SE','IS','LI','NO',
])
const GROUPS = [
{ key: 'own', label: 'Eigene Verarbeitungen (VVT, Art. 30)', test: (r: string) => !r || r === 'INTERNAL' || r === 'GROUP' },
{ key: 'proc', label: 'Auftragsverarbeiter (AVV, Art. 28)', test: (r: string) => r === 'PROCESSOR' },
{ key: 'joint', label: 'Eigenverantwortliche Dritte / Joint Controller (Art. 26)', test: (r: string) => r === 'JOINT_CONTROLLER' || r === 'CONTROLLER' },
{ key: 'other', label: 'Sonstige Empfänger', test: () => true },
]
function scoreColor(s?: number): string {
if (s == null) return 'text-gray-400'
return s >= 80 ? 'text-green-700' : s >= 50 ? 'text-amber-700' : 'text-red-700'
}
function Tile({ label, value, tone }: { label: string; value: React.ReactNode; tone: string }) {
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className={`text-2xl font-semibold leading-none ${tone}`}>{value}</div>
<div className="text-xs text-gray-500 mt-1.5">{label}</div>
</div>
)
}
function VendorRow({ v }: { v: SnapshotVendor }) {
const [open, setOpen] = useState(false)
const cookies = v.cookies || []
const cat = (v.category || '').toLowerCase()
const drittland = !!v.country && !EEA.has((v.country || '').toUpperCase())
return (
<div>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 text-xs"
>
<span className={`text-gray-400 transition-transform ${open ? 'rotate-90' : ''}`}></span>
<span className="font-medium text-gray-800 flex-1 min-w-0 truncate">{v.name}</span>
{cat && (
<span className={`px-1.5 py-0.5 rounded text-[10px] ${CAT_COLOR[cat] || 'bg-gray-100 text-gray-600'}`}>
{v.category}
</span>
)}
{drittland && (
<span className="px-1.5 py-0.5 rounded text-[10px] bg-red-50 text-red-600" title="außerhalb EWR">
{v.country}
</span>
)}
<span className="text-gray-500 w-16 text-right">{cookies.length} Cookies</span>
<span className={`w-10 text-right font-semibold ${scoreColor(v.compliance_score)}`}>
{v.compliance_score != null ? `${v.compliance_score}%` : '—'}
</span>
</button>
{open && cookies.length > 0 && (
<div className="ml-6 mb-1 border-l-2 border-gray-200">
<table className="w-full text-[11px]">
<thead className="text-gray-400">
<tr>
<th className="px-2 py-1 text-left font-normal">Cookie</th>
<th className="px-2 py-1 text-left font-normal">Rolle</th>
<th className="px-2 py-1 text-left font-normal">Zweck</th>
<th className="px-2 py-1 text-left font-normal">Laufzeit</th>
</tr>
</thead>
<tbody>
{cookies.map((c, i) => (
<tr key={i} className="border-t border-gray-100 align-top">
<td className="px-2 py-1 font-mono text-gray-700 break-all w-40">{c.name}</td>
<td className="px-2 py-1 text-gray-500 w-24">
{c.functional_role && c.functional_role !== 'unknown'
? (ROLE_LABEL[c.functional_role] || c.functional_role)
: <span className="text-gray-300"></span>}
</td>
<td className="px-2 py-1 text-gray-500 break-words">
{c.purpose
? c.purpose
: <span className="text-amber-600 italic">kein Zweck</span>}
</td>
<td className="px-2 py-1 text-gray-400 w-24 whitespace-nowrap">{c.expiry || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
export function CookieResultView({ snapshot }: { snapshot: Snapshot }) {
const vendors = snapshot.cmp_vendors || []
const stats = useMemo(() => {
const cookies = vendors.reduce((n, v) => n + (v.cookies?.length || 0), 0)
const marketing = vendors.filter(v => (v.category || '').toLowerCase() === 'marketing').length
const drittland = vendors.filter(v => v.country && !EEA.has(v.country.toUpperCase())).length
return { cookies, marketing, drittland }
}, [vendors])
const grouped = useMemo(() => GROUPS.map(g => ({
...g,
vendors: vendors
.filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key)
.sort((a, b) => (a.compliance_score ?? 100) - (b.compliance_score ?? 100)),
})).filter(g => g.vendors.length > 0), [vendors])
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Cookie-Auswertung {snapshot.site_domain || 'Snapshot'}
</h2>
<p className="text-xs text-gray-500 mt-0.5">
aus gespeichertem Snapshot (kein Re-Crawl) ·{' '}
{snapshot.created_at ? snapshot.created_at.slice(0, 19).replace('T', ' ') : ''}
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Tile label="Anbieter" value={vendors.length} tone="text-gray-800" />
<Tile label="Cookies gesamt" value={stats.cookies} tone="text-gray-800" />
<Tile label="Marketing-Anbieter" value={stats.marketing} tone={stats.marketing > 0 ? 'text-red-700' : 'text-gray-800'} />
<Tile label="Drittland (außerhalb EWR)" value={stats.drittland} tone={stats.drittland > 0 ? 'text-amber-700' : 'text-gray-800'} />
</div>
{grouped.map(g => (
<div key={g.key} className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b text-xs font-semibold text-gray-700">
{g.label} <span className="text-gray-400 font-normal">({g.vendors.length})</span>
</div>
<div className="divide-y divide-gray-100">
{g.vendors.map((v, i) => <VendorRow key={i} v={v} />)}
</div>
</div>
))}
</div>
)
}