feat: Banner-Check — Historie, persistentes Ergebnis, E-Mail-Report
1. localStorage Persistenz: URL, letztes Ergebnis, Historie (30 Eintraege) 2. Historie: Zeigt URL, Datum, Provider, Violations, Prozent 3. Letztes Ergebnis bleibt nach Tab-Wechsel/Reload sichtbar 4. E-Mail-Report: HTML-formatiert mit Violations + Hints an mailpit 5. Email-Status Anzeige im Frontend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ interface BannerResult {
|
|||||||
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
|
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
|
||||||
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
|
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
|
||||||
}
|
}
|
||||||
|
email_status?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
@@ -43,12 +44,24 @@ const CATEGORIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function BannerCheckTab() {
|
export function BannerCheckTab() {
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? localStorage.getItem('banner-check-url') || '' : ''
|
||||||
|
)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [progress, setProgress] = useState('')
|
const [progress, setProgress] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<BannerResult | null>(null)
|
const [result, setResult] = useState<BannerResult | null>(() => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try { const s = localStorage.getItem('banner-check-result'); return s ? JSON.parse(s) : null } catch { return null }
|
||||||
|
})
|
||||||
const [categories, setCategories] = useState<string[]>(['all'])
|
const [categories, setCategories] = useState<string[]>(['all'])
|
||||||
|
const [history, setHistory] = useState<{ url: string; date: string; provider: string; violations: number; pct: number }[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist URL
|
||||||
|
React.useEffect(() => { localStorage.setItem('banner-check-url', url) }, [url])
|
||||||
|
|
||||||
const toggleCategory = (id: string) => {
|
const toggleCategory = (id: string) => {
|
||||||
if (id === 'all') {
|
if (id === 'all') {
|
||||||
@@ -71,10 +84,7 @@ export function BannerCheckTab() {
|
|||||||
setResult(null)
|
setResult(null)
|
||||||
setProgress('Cookie-Banner wird analysiert...')
|
setProgress('Cookie-Banner wird analysiert...')
|
||||||
|
|
||||||
// 'all' selected = empty array (test everything)
|
const selectedCategories = categories.includes('all') ? [] : categories
|
||||||
const selectedCategories = categories.includes('all')
|
|
||||||
? []
|
|
||||||
: categories
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sdk/v1/agent/banner-check', {
|
const res = await fetch('/api/sdk/v1/agent/banner-check', {
|
||||||
@@ -85,6 +95,20 @@ export function BannerCheckTab() {
|
|||||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setResult(data)
|
setResult(data)
|
||||||
|
localStorage.setItem('banner-check-result', JSON.stringify(data))
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
|
||||||
|
const entry = {
|
||||||
|
url: url.trim(),
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
provider: data.banner_provider || 'Unbekannt',
|
||||||
|
violations,
|
||||||
|
pct: data.completeness_pct ?? 0,
|
||||||
|
}
|
||||||
|
const updated = [entry, ...history].slice(0, 30)
|
||||||
|
setHistory(updated)
|
||||||
|
localStorage.setItem('banner-check-history', JSON.stringify(updated))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -93,12 +117,15 @@ export function BannerCheckTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadFromHistory = (entry: { url: string }) => {
|
||||||
|
setUrl(entry.url)
|
||||||
|
}
|
||||||
|
|
||||||
const structuredChecks = result?.structured_checks || []
|
const structuredChecks = result?.structured_checks || []
|
||||||
const hasStructured = structuredChecks.length > 0
|
const hasStructured = structuredChecks.length > 0
|
||||||
const compPct = result?.completeness_pct ?? 0
|
const compPct = result?.completeness_pct ?? 0
|
||||||
const corrPct = result?.correctness_pct ?? 0
|
const corrPct = result?.correctness_pct ?? 0
|
||||||
|
|
||||||
// Build ChecklistView-compatible result for structured checks
|
|
||||||
const checklistResults = hasStructured ? [{
|
const checklistResults = hasStructured ? [{
|
||||||
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
||||||
url: url,
|
url: url,
|
||||||
@@ -149,16 +176,10 @@ export function BannerCheckTab() {
|
|||||||
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
|
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input type="checkbox" checked={categories.includes(cat.id)}
|
||||||
type="checkbox"
|
onChange={() => toggleCategory(cat.id)} className="sr-only" />
|
||||||
checked={categories.includes(cat.id)}
|
|
||||||
onChange={() => toggleCategory(cat.id)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${
|
<span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${
|
||||||
categories.includes(cat.id)
|
categories.includes(cat.id) ? 'bg-purple-600 border-purple-600' : 'border-gray-400'
|
||||||
? 'bg-purple-600 border-purple-600'
|
|
||||||
: 'border-gray-400'
|
|
||||||
}`}>
|
}`}>
|
||||||
{categories.includes(cat.id) && (
|
{categories.includes(cat.id) && (
|
||||||
<svg className="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 12 12">
|
<svg className="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 12 12">
|
||||||
@@ -188,68 +209,89 @@ export function BannerCheckTab() {
|
|||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 3-Phase Summary Card */}
|
|
||||||
{result.phases && (
|
{result.phases && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">
|
<span className="text-2xl">{result.banner_detected ? '\u{1F6E1}\u{FE0F}' : '\u26A0\u{FE0F}'}</span>
|
||||||
{result.banner_detected ? '\u{1F6E1}\u{FE0F}' : '\u26A0\u{FE0F}'}
|
|
||||||
</span>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
{result.banner_detected
|
{result.banner_detected
|
||||||
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
||||||
: 'Kein Cookie-Banner erkannt'}
|
: 'Kein Cookie-Banner erkannt'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion</p>
|
||||||
3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
||||||
<PhaseBox
|
<PhaseBox label="Vor Consent" icon="\uD83D\uDD12"
|
||||||
label="Vor Consent"
|
|
||||||
icon="\uD83D\uDD12"
|
|
||||||
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
||||||
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
||||||
violations={result.phases.before_consent.violations?.length ?? 0}
|
violations={result.phases.before_consent.violations?.length ?? 0} />
|
||||||
/>
|
<PhaseBox label="Nach Ablehnen" icon="\uD83D\uDEAB"
|
||||||
<PhaseBox
|
|
||||||
label="Nach Ablehnen"
|
|
||||||
icon="\uD83D\uDEAB"
|
|
||||||
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
||||||
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
||||||
violations={result.phases.after_reject.violations?.length ?? 0}
|
violations={result.phases.after_reject.violations?.length ?? 0} />
|
||||||
/>
|
<PhaseBox label="Nach Akzeptieren" icon="\u2705"
|
||||||
<PhaseBox
|
|
||||||
label="Nach Akzeptieren"
|
|
||||||
icon="\u2705"
|
|
||||||
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
||||||
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
||||||
violations={0}
|
violations={0} />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Structured L1/L2 Checklist */}
|
|
||||||
{hasStructured && (
|
{hasStructured && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
<ChecklistView results={checklistResults} />
|
<ChecklistView results={checklistResults} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{result.email_status && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${result.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||||
|
E-Mail: {result.email_status === 'sent' ? 'Gesendet' : result.email_status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!result.banner_detected && !hasStructured && (
|
{!result.banner_detected && !hasStructured && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach ss25 TDDDG Pflicht.
|
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach §25 TDDDG Pflicht.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Banner-Checks</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{history.map((h, i) => (
|
||||||
|
<button key={i} onClick={() => loadFromHistory(h)}
|
||||||
|
className="w-full flex items-center justify-between p-2.5 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{' · '}{h.provider}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||||
|
<span className={`text-xs font-medium ${h.violations > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{h.violations} Findings
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-medium ${h.pct === 100 ? 'text-green-700' : h.pct >= 50 ? 'text-yellow-700' : 'text-red-700'}`}>
|
||||||
|
{h.pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -261,14 +303,8 @@ function PhaseBox({ label, icon, cookies, scripts, violations }: {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg">{icon}</div>
|
<div className="text-lg">{icon}</div>
|
||||||
<div className="text-xs font-medium text-gray-700">{label}</div>
|
<div className="text-xs font-medium text-gray-700">{label}</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">{cookies} Cookies, {scripts} Scripts</div>
|
||||||
{cookies} Cookies, {scripts} Scripts
|
{violations > 0 && <div className="text-xs text-red-600 font-medium">{violations} Verstoesse</div>}
|
||||||
</div>
|
|
||||||
{violations > 0 && (
|
|
||||||
<div className="text-xs text-red-600 font-medium">
|
|
||||||
{violations} Verstoesse
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,9 +110,52 @@ async def run_banner_check(req: BannerCheckRequest):
|
|||||||
"categories": req.categories,
|
"categories": req.categories,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code != 200:
|
||||||
return resp.json()
|
return {"error": f"Consent-Tester: HTTP {resp.status_code}"}
|
||||||
return {"error": f"Consent-Tester: HTTP {resp.status_code}"}
|
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
# Send email report
|
||||||
|
checks = result.get("structured_checks", [])
|
||||||
|
violations = [c for c in checks if not c.get("passed") and not c.get("skipped")]
|
||||||
|
passes = [c for c in checks if c.get("passed")]
|
||||||
|
provider = result.get("banner_provider", "Unbekannt")
|
||||||
|
comp_pct = result.get("completeness_pct", 0)
|
||||||
|
|
||||||
|
html = [
|
||||||
|
'<div style="font-family:-apple-system,sans-serif;max-width:700px;margin:0 auto">',
|
||||||
|
f'<h2>Banner-Check: {req.url}</h2>',
|
||||||
|
f'<p>Banner: {provider} | Vollstaendigkeit: {comp_pct}%</p>',
|
||||||
|
]
|
||||||
|
if violations:
|
||||||
|
html.append(f'<h3 style="color:#dc2626">{len(violations)} Verstoesse</h3>')
|
||||||
|
for v in violations:
|
||||||
|
html.append(
|
||||||
|
f'<div style="padding:4px 0">'
|
||||||
|
f'<span style="color:#dc2626;font-weight:bold">✗</span> '
|
||||||
|
f'{v.get("label","")}'
|
||||||
|
)
|
||||||
|
if v.get("hint"):
|
||||||
|
html.append(
|
||||||
|
f'<div style="font-size:11px;color:#dc2626;margin:2px 0 4px 20px;'
|
||||||
|
f'padding:4px 8px;background:#fef2f2;border-radius:4px;'
|
||||||
|
f'border-left:3px solid #fca5a5">{v["hint"]}</div>'
|
||||||
|
)
|
||||||
|
html.append('</div>')
|
||||||
|
if passes:
|
||||||
|
html.append(f'<h3 style="color:#22c55e">{len(passes)} Bestanden</h3>')
|
||||||
|
for p in passes:
|
||||||
|
html.append(f'<div style="padding:2px 0;color:#6b7280">'
|
||||||
|
f'<span style="color:#22c55e">✓</span> {p.get("label","")}</div>')
|
||||||
|
html.append('</div>')
|
||||||
|
|
||||||
|
email_result = send_email(
|
||||||
|
recipient="dsb@breakpilot.local",
|
||||||
|
subject=f"[BANNER-CHECK] {provider} — {req.url}",
|
||||||
|
body_html="\n".join(html),
|
||||||
|
)
|
||||||
|
result["email_status"] = email_result.get("status", "failed")
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)[:200]}
|
return {"error": str(e)[:200]}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user