39cb6afc23
CookieFindings: Umschalter [Nach Fehlertyp] (je Typ: Maßnahme + betroffene Cookies + Ticket-Text) ↔ [Matrix] (Cookie×Typ, ✗ Handlung / ⚠ Hinweis). Trennung FINDINGS (zu beheben) vs HINWEISE (neutral, gegen DSE zu prüfen). Backend: kind-Klassifikation (third_country/eu_alternative=hinweis); Drittland- Remediation neutral formuliert (pro Verarbeiter prüfen, keine 'in DSE benennen'- Befehle, da DSE-Abdeckung wie BMWs 'in der Regel SCC' oft unzureichend). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
234 lines
9.4 KiB
TypeScript
234 lines
9.4 KiB
TypeScript
'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',
|
||
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).',
|
||
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',
|
||
'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>
|
||
)
|
||
}
|