3f90e40807
Korrektheit (§ 25 TDDDG): "Cookies vor Consent" ist KEIN Verstoss per se — technisch notwendige Cookies inkl. des Consent-Cookies (speichert die Ablehnung) sind nach Abs. 2 erlaubt. Verstoss ist nur nicht-essentielles TRACKING vor Consent. - browser_cross_finding: Befund haengt jetzt an violations.before_consent (Tracking), nicht an der Cookie-Rohzahl; § 25 Abs. 2-Hinweis im Detail. Regressionstest: Cookies-ohne-Tracking → KEIN Befund. - multi_browser_scanner._extract_dimensions: Score nutzt Tracking-Violations + reject_respected-Verdikt statt Rohzahl (Fallback erhalten). - BrowserBehaviorView: "Cookies vor Consent" nur rot/⚠ bei Tracking, "nach Ablehnen" neutral (Verdikt = reject-Spalte); erklaerende Zeile. Speed: run_consent_test ueberspringt im Matrix-Modus (browser_profile gesetzt) die teuren Phasen C/D-F/G — nur A+B noetig. Verhindert das 504 beim Multi-Engine-Scan (BMW 4 Engines lief sonst in den 338s-Gateway-Timeout). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
248 lines
12 KiB
TypeScript
248 lines
12 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* BrowserBehaviorView — On-demand-Browser-Verhaltens-Matrix für einen Snapshot.
|
||
* Lädt das gespeicherte Ergebnis (GET, kein Re-Crawl); ohne Ergebnis ein
|
||
* „Browser-Test starten"-Button (POST run → Live-Lauf je Engine). Zeigt je
|
||
* Browser: Cookies vor Consent / nach Ablehnen / Ablehnen respektiert + Score,
|
||
* darunter Engine-Detail mit Banner-Screenshot + Oberflächen-Befunden.
|
||
* Aggregierte Maßnahmen + Cross-Finding folgen separat (Phase 4).
|
||
*/
|
||
|
||
import React, { useEffect, useState } from 'react'
|
||
|
||
type Finding = { text: string; severity: string; legal_ref?: string; service?: string }
|
||
type Surface = { has_impressum_link?: boolean; has_dse_link?: boolean; banner_text_issues?: number }
|
||
type Violations = { before_consent?: number; after_reject?: number; banner_text?: number }
|
||
type Summary = {
|
||
cookies_before_consent?: number; cookies_after_reject?: number
|
||
reject_respected?: boolean; banner_detected?: boolean; banner_provider?: string
|
||
banner_screenshot_b64?: string; surface?: Surface; banner_findings?: Finding[]
|
||
violations?: Violations
|
||
}
|
||
type Row = {
|
||
profile_id: string; label: string; engine?: string; is_mobile?: boolean
|
||
score?: number; verbal?: string; summary?: Summary | null; error?: string
|
||
}
|
||
type CrossFinding = { title: string; detail?: string; severity: string; affected?: string[]; measure?: string }
|
||
type Matrix = {
|
||
browser_matrix?: Row[]; aggregate?: Record<string, unknown>
|
||
url?: string; scanned_at?: string; cross_findings?: CrossFinding[]
|
||
}
|
||
|
||
const sevCls = (s: string) => {
|
||
const u = (s || '').toUpperCase()
|
||
if (u === 'CRITICAL' || u === 'HIGH') return 'bg-red-100 text-red-700'
|
||
if (u === 'MEDIUM') return 'bg-amber-100 text-amber-700'
|
||
return 'bg-gray-100 text-gray-600'
|
||
}
|
||
const scoreCls = (n?: number) =>
|
||
n == null ? 'text-gray-400' : n >= 80 ? 'text-green-700' : n >= 60 ? 'text-amber-700' : 'text-red-700'
|
||
|
||
export function BrowserBehaviorView({ snapshotId }: { snapshotId: string }) {
|
||
const [matrix, setMatrix] = useState<Matrix | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [running, setRunning] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [sel, setSel] = useState<string>('')
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/browser-behavior`)
|
||
.then(r => r.json())
|
||
.then(d => { if (!cancelled) setMatrix(d?.browser_matrix || null) })
|
||
.catch(() => { if (!cancelled) setMatrix(null) })
|
||
.finally(() => { if (!cancelled) setLoading(false) })
|
||
return () => { cancelled = true }
|
||
}, [snapshotId])
|
||
|
||
const rows = matrix?.browser_matrix || []
|
||
|
||
useEffect(() => {
|
||
if (!sel && rows.length) {
|
||
const withData = rows.filter(r => r.summary)
|
||
const worst = [...(withData.length ? withData : rows)]
|
||
.sort((a, b) => (a.score ?? 999) - (b.score ?? 999))[0]
|
||
if (worst) setSel(worst.profile_id)
|
||
}
|
||
}, [rows, sel])
|
||
|
||
const run = async () => {
|
||
setRunning(true); setError(null)
|
||
try {
|
||
const r = await fetch(
|
||
`/api/sdk/v1/agent/snapshots/${snapshotId}/browser-behavior/run`,
|
||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||
const d = await r.json()
|
||
if (!r.ok || d?.error) setError(d?.error || `Fehler ${r.status}`)
|
||
else { setMatrix(d); setSel('') }
|
||
} catch (e) { setError(String(e)) } finally { setRunning(false) }
|
||
}
|
||
|
||
if (loading) return <div className="text-sm text-gray-500">Lade Browser-Verhalten…</div>
|
||
|
||
if (!matrix || !rows.length) {
|
||
return (
|
||
<div className="border border-gray-200 rounded-xl p-5 space-y-3 bg-gray-50">
|
||
<h3 className="font-semibold text-gray-900">Browser-Verhalten testen</h3>
|
||
<p className="text-sm text-gray-600 max-w-2xl">
|
||
Prüft das Cookie-Banner live in mehreren Browser-Engines (Chromium,
|
||
Firefox/Gecko, Safari/WebKit) sowie – sofern verfügbar – in echtem
|
||
Chrome, Edge, Brave und mobil. Gemessen wird je Browser: werden
|
||
Cookies <strong>vor</strong> der Einwilligung gesetzt, und werden sie
|
||
nach <strong>„Ablehnen"</strong> wirklich entfernt? Dazu eine
|
||
Oberflächenanalyse (Impressum-/DSE-Links, Banner-Auffälligkeiten) mit
|
||
Screenshot je Engine.
|
||
</p>
|
||
<p className="text-xs text-gray-400">
|
||
Der Test crawlt die Seite live und dauert je nach Browser-Anzahl
|
||
einige Minuten.
|
||
</p>
|
||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||
<button onClick={run} disabled={running}
|
||
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
|
||
{running ? 'Test läuft… (bitte warten)' : 'Browser-Test starten'}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const selRow = rows.find(r => r.profile_id === sel) || rows[0]
|
||
const agg: Record<string, unknown> = matrix.aggregate || {}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||
<div className="text-xs text-gray-500">
|
||
{matrix.scanned_at ? `Test vom ${String(matrix.scanned_at).slice(0, 16).replace('T', ' ')}` : ''}
|
||
{agg.profiles_run ? ` · ${String(agg.profiles_run)} Browser` : ''}
|
||
{' · '}<span className="text-gray-400">Live-Messung, kann von der Snapshot-Zeit abweichen</span>
|
||
</div>
|
||
<button onClick={run} disabled={running}
|
||
className="px-3 py-1.5 text-sm rounded-lg border border-blue-200 text-blue-700 hover:bg-blue-50 disabled:opacity-50">
|
||
{running ? 'läuft…' : 'Erneut testen'}
|
||
</button>
|
||
</div>
|
||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||
|
||
{/* Cross-Browser-Befunde — der Mehrwert ggü. Einzel-Browser-Scan */}
|
||
{(matrix.cross_findings?.length ?? 0) > 0 && (
|
||
<div className="space-y-2">
|
||
<h3 className="text-sm font-semibold text-gray-900">Cross-Browser-Befunde</h3>
|
||
{matrix.cross_findings!.map((f, i) => (
|
||
<div key={i} className="border border-gray-200 rounded-xl p-3 space-y-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className={`text-[10px] px-1.5 py-0.5 rounded uppercase ${sevCls(f.severity)}`}>{f.severity}</span>
|
||
<span className="text-sm font-medium text-gray-900">{f.title}</span>
|
||
</div>
|
||
{f.detail && <p className="text-sm text-gray-600">{f.detail}</p>}
|
||
{(f.affected?.length ?? 0) > 0 && (
|
||
<div className="flex gap-1 flex-wrap">
|
||
{f.affected!.map((a, j) => (
|
||
<span key={j} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{a}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{f.measure && <p className="text-sm text-gray-700"><span className="text-gray-400">Maßnahme: </span>{f.measure}</p>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="overflow-x-auto border border-gray-200 rounded-xl">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 text-gray-500 text-xs">
|
||
<tr>
|
||
<th className="text-left px-3 py-2">Browser</th>
|
||
<th className="px-3 py-2">Cookies vor Consent</th>
|
||
<th className="px-3 py-2">Cookies nach Ablehnen</th>
|
||
<th className="px-3 py-2">Ablehnen respektiert</th>
|
||
<th className="px-3 py-2">Oberfläche</th>
|
||
<th className="px-3 py-2">Score</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map(r => {
|
||
const s = r.summary
|
||
const before = s?.cookies_before_consent ?? null
|
||
const after = s?.cookies_after_reject ?? null
|
||
const trackBefore = s?.violations?.before_consent ?? 0
|
||
const sld = r.profile_id === sel
|
||
return (
|
||
<tr key={r.profile_id} onClick={() => setSel(r.profile_id)}
|
||
className={`border-t border-gray-100 cursor-pointer ${sld ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
|
||
<td className="px-3 py-2 text-left">
|
||
{r.label}
|
||
{r.is_mobile && <span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700">Mobil</span>}
|
||
</td>
|
||
{r.error || !s ? (
|
||
<td colSpan={4} className="px-3 py-2 text-center text-gray-400 text-xs">
|
||
nicht verfügbar{r.error ? ` (${r.error.slice(0, 40)})` : ''}
|
||
</td>
|
||
) : (
|
||
<>
|
||
<td className={`px-3 py-2 text-center ${trackBefore > 0 ? 'text-red-700 font-semibold' : 'text-gray-500'}`}
|
||
title={trackBefore > 0 ? `${trackBefore} davon Tracking (Verstoß)` : 'kein Tracking vor Consent'}>
|
||
{before}{trackBefore > 0 ? ` · ${trackBefore}⚠` : ''}
|
||
</td>
|
||
<td className="px-3 py-2 text-center text-gray-500">{after}</td>
|
||
<td className="px-3 py-2 text-center">
|
||
{s.reject_respected ? <span className="text-green-700">✓</span> : <span className="text-red-700 font-semibold">✗</span>}
|
||
</td>
|
||
<td className="px-3 py-2 text-center text-xs">
|
||
{!s.surface?.has_impressum_link && <span className="text-amber-700">Impressum fehlt </span>}
|
||
{!s.surface?.has_dse_link && <span className="text-amber-700">DSE fehlt </span>}
|
||
{(s.surface?.banner_text_issues ?? 0) > 0
|
||
? <span className="text-gray-600">{s.surface?.banner_text_issues} Hinweis(e)</span>
|
||
: (s.surface?.has_impressum_link && s.surface?.has_dse_link ? <span className="text-green-700">ok</span> : null)}
|
||
</td>
|
||
</>
|
||
)}
|
||
<td className={`px-3 py-2 text-center font-semibold ${scoreCls(r.score)}`}>{r.score ?? '–'}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<p className="text-xs text-gray-400">
|
||
„Cookies vor Consent" ist die Rohzahl — technisch notwendige Cookies
|
||
(inkl. des Consent-Cookies, das die Ablehnung speichert) sind nach
|
||
§ 25 Abs. 2 TDDDG erlaubt. Rot/⚠ markiert nur den einwilligungspflichtigen
|
||
Tracking-Anteil. Das Verdikt zu „Ablehnen" trägt die Spalte rechts.
|
||
</p>
|
||
|
||
{selRow && (
|
||
<div className="border border-gray-200 rounded-xl p-4 space-y-3">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<h3 className="font-semibold text-gray-900">{selRow.label}</h3>
|
||
{selRow.verbal && <span className="text-xs text-gray-500">· {selRow.verbal}</span>}
|
||
</div>
|
||
{selRow.summary?.banner_screenshot_b64 ? (
|
||
<img alt={`Banner ${selRow.label}`}
|
||
src={`data:image/png;base64,${selRow.summary.banner_screenshot_b64}`}
|
||
className="max-h-80 rounded-lg border border-gray-200" />
|
||
) : (
|
||
<div className="text-xs text-gray-400">Kein Banner-Screenshot erfasst.</div>
|
||
)}
|
||
{(selRow.summary?.banner_findings?.length ?? 0) > 0 ? (
|
||
<ul className="space-y-1.5">
|
||
{selRow.summary!.banner_findings!.map((f, i) => (
|
||
<li key={i} className="flex items-start gap-2 text-sm">
|
||
<span className={`text-[10px] px-1.5 py-0.5 rounded uppercase ${sevCls(f.severity)}`}>{f.severity || 'INFO'}</span>
|
||
<span className="text-gray-700">
|
||
{f.text}{f.legal_ref && <span className="text-gray-400"> · {f.legal_ref}</span>}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : selRow.summary ? (
|
||
<div className="text-sm text-green-700">Keine Oberflächen-Auffälligkeiten in dieser Engine.</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|