Files
breakpilot-compliance/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx
T
Benjamin Admin 85a8a1d545 feat(browser-matrix): Cross-Browser-Befunde + Browser-Default-Einordnung (Phase 4)
- browser_cross_finding: deterministische Sicht ueber die Matrix (keine 2.
  Engine, kein LLM). Findet Inkonsistenzen ZWISCHEN Browsern (Cookies vor
  Consent / Ablehnen nicht universell respektiert / Banner-Links fehlend) und
  ordnet ein: Safari-ITP / Brave-Shields / Firefox-ETP maskieren Verstoesse
  clientseitig → strenge Engine "sauber" ist KEIN Compliance-Beleg, massgeblich
  sind die nachgiebigen (Chrome/Edge). Coverage-Hinweis fuer nicht verfuegbare
  Browser. Je Befund Titel/Detail/Severity/affected/Massnahme.
- snapshot_check_routes: cross_findings frisch in run + GET (nicht persistiert).
- BrowserBehaviorView: "Cross-Browser-Befunde"-Block ueber der Tabelle.
- Tests: test_browser_cross_finding (6).

Offen (Folge-Task): Borlabs-Consent-Historie-Live-Erkennung (braucht
consent-tester-Storage-Scan).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 23:22:57 +02:00

235 lines
11 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'
/**
* 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 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[]
}
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 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 ${(before ?? 0) > 0 ? 'text-red-700 font-semibold' : 'text-green-700'}`}>{before}</td>
<td className={`px-3 py-2 text-center ${(after ?? 0) > 0 ? 'text-amber-700' : 'text-green-700'}`}>{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>
{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>
)
}