Files
breakpilot-compliance/admin-compliance/app/sdk/agent/_components/CookieFindings.tsx
T
Benjamin Admin 877d540ce1 fix(cookie+impressum): Drittland-FP, Impressum-Beleg, neuer Opt-Out-Finding
- Drittland: unbekannte Herkunft ('N/A') + Self-Hosting feuern nicht mehr —
  First-Party-Session-Cookies (PHPSESSID/JSESSIONID) waren False Positives.
- Impressum _line_of: enges Fenster um den Treffer bei Texten ohne Umbrüche
  (BMW = ein Block) → jede Pflichtangabe zeigt IHREN Beleg statt denselben Satz.
- Neuer Finding-Typ missing_opt_out: einwilligungspflichtiger Anbieter mit
  Cookies ohne Opt-Out-/Widerspruchs-Link (Art. 7 Abs. 3 + Art. 21).

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

237 lines
9.5 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'
/**
* CookieFindings — bereitet die Library-Befunde bearbeitbar auf, statt als
* Fließtext-Liste. Zwei Sichten (Umschalter):
* - Nach Fehlertyp: je Typ eine Maßnahme + betroffene Cookies + Ticket-Text
* (= eine Ticket-Einheit). Getrennt in FINDINGS (zu beheben) und HINWEISE
* (neutral, gegen DSE zu prüfen: Drittland, EU-Alternative).
* - Matrix: Zeilen = Cookies, Spalten = Fehlertypen, Markierung wo nachzubessern
* ist (ein Cookie, alle Probleme auf einen Blick).
*/
import React, { useMemo, useState } from 'react'
import type { CookieFinding } from './CookieLibraryPanel'
const TYPE_LABEL: Record<string, string> = {
tracker_as_necessary: 'Tracker als „notwendig" deklariert',
missing_purpose: 'Zweck fehlt',
excessive_lifetime: 'Speicherdauer zu lang',
vague_duration: 'Speicherdauer nicht konkret',
missing_retention: 'Keine Speicherdauer/Löschfrist',
missing_opt_out: 'Opt-Out-/Widerspruchs-Link fehlt',
storage_transparency: 'Speichertyp nicht transparent',
third_country: 'Drittland-Transfer',
eu_alternative: 'EU-Alternative verfügbar',
}
const TYPE_MEASURE: Record<string, string> = {
tracker_as_necessary: 'Als einwilligungspflichtig einstufen (§ 25 Abs. 1 TDDDG).',
missing_purpose: 'Zweck je Cookie ergänzen (Art. 13 DSGVO).',
vague_duration: 'Konkrete Speicherdauer oder Löschkriterium angeben (Art. 5 Abs. 1 lit. e).',
missing_retention: 'Speicherdauer/Löschfrist je Verarbeiter festlegen (Art. 5 Abs. 1 lit. e).',
missing_opt_out: 'Opt-Out-/Widerspruchs-Link je Anbieter angeben (Art. 7 Abs. 3 + Art. 21).',
excessive_lifetime: 'Speicherdauer auf das Erforderliche reduzieren (Art. 5 Abs. 1 lit. e).',
storage_transparency: 'Speichertyp + -dauer je Objekt transparent ausweisen (§ 25 TDDDG).',
third_country: 'Geeignete Garantien je Verarbeiter prüfen (SCC Art. 46 / Art. 49).',
eu_alternative: 'EU-Alternative prüfen (kommerziell, kein Drittland-Transfer).',
}
const TYPE_ORDER = [
'tracker_as_necessary', 'missing_purpose', 'vague_duration', 'missing_retention',
'missing_opt_out', 'excessive_lifetime', 'storage_transparency',
'third_country', 'eu_alternative',
]
const SEV_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const SEV_COLOR: Record<string, string> = {
HIGH: 'bg-red-100 text-red-700',
MEDIUM: 'bg-amber-100 text-amber-700',
LOW: 'bg-blue-100 text-blue-700',
}
interface Group { type: string; items: CookieFinding[]; severity: string }
function groupByType(findings: CookieFinding[]): Group[] {
const m = new Map<string, CookieFinding[]>()
for (const f of findings) {
if (!m.has(f.type)) m.set(f.type, [])
m.get(f.type)!.push(f)
}
const groups = [...m.entries()].map(([type, items]) => ({
type, items,
severity: items.reduce(
(s, f) => (SEV_ORDER[f.severity] ?? 3) < (SEV_ORDER[s] ?? 3) ? f.severity : s, 'LOW'),
}))
groups.sort((a, b) =>
(TYPE_ORDER.indexOf(a.type) + 99) % 100 - (TYPE_ORDER.indexOf(b.type) + 99) % 100)
return groups
}
function cookieLabel(f: CookieFinding): string {
const v = f.vendor && f.vendor !== '—' ? ` (${f.vendor})` : ''
const d = f.declared ? `${f.declared}` : ''
return `${f.cookie}${v}${d}`
}
function ticketText(g: Group): string {
return [
`${TYPE_LABEL[g.type] || g.type}${g.items.length} betroffen`,
`Maßnahme: ${TYPE_MEASURE[g.type] || ''}`,
'',
...g.items.map(f => `- ${cookieLabel(f)}`),
].join('\n')
}
function GroupCard({ g }: { g: Group }) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard?.writeText(ticketText(g)).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1500)
}).catch(() => {})
}
return (
<div className="border-b last:border-b-0">
<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={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${SEV_COLOR[g.severity] || 'bg-gray-100'}`}>
{g.severity}
</span>
<span className="font-medium text-gray-800 flex-1 min-w-0 truncate">
{TYPE_LABEL[g.type] || g.type}
</span>
<span className="text-gray-500">{g.items.length}</span>
</button>
{open && (
<div className="px-4 pb-3 space-y-2">
<div className="text-xs text-gray-700 bg-blue-50 rounded px-2 py-1.5">
<span className="font-semibold">Maßnahme:</span> {TYPE_MEASURE[g.type] || '—'}
</div>
<table className="w-full text-[11px]">
<tbody>
{g.items.map((f, 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">{f.cookie}</td>
<td className="px-2 py-1 text-gray-400 w-32 truncate">{f.vendor}</td>
<td className="px-2 py-1 text-gray-500">{f.declared || ''}</td>
</tr>
))}
</tbody>
</table>
<button onClick={copy}
className="text-[11px] px-2 py-1 rounded bg-gray-100 text-gray-700 hover:bg-gray-200">
{copied ? '✓ Ticket-Text kopiert' : 'Ticket-Text kopieren'}
</button>
</div>
)}
</div>
)
}
function Section({ title, hint, groups }: { title: string; hint?: string; groups: Group[] }) {
if (!groups.length) return null
return (
<div className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b">
<span className="text-xs font-semibold text-gray-700">{title}</span>
{hint && <span className="text-[10px] text-gray-400 ml-2">{hint}</span>}
</div>
{groups.map(g => <GroupCard key={g.type} g={g} />)}
</div>
)
}
function Matrix({ findings }: { findings: CookieFinding[] }) {
const { rows, cols } = useMemo(() => {
const colSet = new Set(findings.map(f => f.type))
const cols = TYPE_ORDER.filter(t => colSet.has(t))
const rowMap = new Map<string, { label: string; vendor: string; hits: Record<string, string> }>()
for (const f of findings) {
const key = `${f.cookie}@@${f.vendor}`
if (!rowMap.has(key)) rowMap.set(key, { label: f.cookie, vendor: f.vendor, hits: {} })
rowMap.get(key)!.hits[f.type] = (f.kind === 'hinweis') ? '⚠' : '✗'
}
return { rows: [...rowMap.values()], cols }
}, [findings])
return (
<div className="border rounded-lg overflow-auto max-h-[32rem]">
<table className="w-full text-[11px]">
<thead className="bg-slate-50 sticky top-0">
<tr>
<th className="px-2 py-1.5 text-left font-semibold text-gray-600">Cookie</th>
{cols.map(c => (
<th key={c} className="px-1 py-1.5 text-center font-normal text-gray-500" title={TYPE_LABEL[c]}>
{(TYPE_LABEL[c] || c).split(' ')[0]}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-gray-100">
<td className="px-2 py-1 font-mono text-gray-700 break-all">
{r.label}
{r.vendor && r.vendor !== '—' && <span className="text-gray-400 ml-1">· {r.vendor}</span>}
</td>
{cols.map(c => (
<td key={c} className={`px-1 py-1 text-center ${r.hits[c] === '✗' ? 'text-red-600' : r.hits[c] === '⚠' ? 'text-amber-600' : 'text-gray-200'}`}>
{r.hits[c] || '·'}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="px-2 py-1.5 text-[10px] text-gray-400 border-t">
= Handlung nötig · = Hinweis (zu prüfen) · Spalte = Fehlertyp (Tooltip)
</div>
</div>
)
}
export function CookieFindings({ findings }: { findings: CookieFinding[] }) {
const [mode, setMode] = useState<'type' | 'matrix'>('type')
const real = findings.filter(f => (f.kind ?? 'finding') !== 'hinweis')
const hints = findings.filter(f => (f.kind ?? 'finding') === 'hinweis')
if (!findings.length) {
return <div className="px-4 py-3 text-sm text-green-700 border rounded-lg">Keine Abweichungen gegen die Library.</div>
}
const btn = (m: 'type' | 'matrix', label: string) => (
<button onClick={() => setMode(m)}
className={`px-2.5 py-1 rounded text-xs ${mode === m ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{label}
</button>
)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">
{findings.length} Befund{findings.length !== 1 ? 'e' : ''}
<span className="text-xs font-normal text-gray-400 ml-2">
{real.length} zu beheben · {hints.length} Hinweise
</span>
</span>
<div className="flex items-center gap-1">
{btn('type', 'Nach Fehlertyp')}
{btn('matrix', 'Matrix')}
</div>
</div>
{mode === 'matrix' ? (
<Matrix findings={findings} />
) : (
<div className="space-y-3">
<Section title="Findings — zu beheben" groups={groupByType(real)} />
<Section title="Hinweise — neutral, gegen DSE/Doku zu prüfen"
hint="z.B. Drittland: interne Verträge können wir nicht einsehen"
groups={groupByType(hints)} />
</div>
)}
</div>
)
}