6f16507c5f
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m54s
CI / test-go (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P19 (consent-tester): - dp-cookieconsent (TYPO3, Safetykon-Pattern) als CMP-Profil hinzu — Selektoren #dp--cookie-statistics/marketing + a.cc-allow Save-Button - Neues Signal provider_details_visible: nach Kategorie-Toggle prueft Playwright ob im Banner sichtbare Provider-/Cookie-Detail-Elemente erscheinen. Bei dp-cookieconsent (Banner ohne Listing) immer False -> HIGH-Violation "Kategorie zeigt keine Provider-/Cookie-Details — Nutzer kann nicht informiert einwilligen (Art. 7 Abs. 1 DSGVO)" - main.py serialisiert provider_details_visible + cookies_set pro Kategorie P20 (Frontend-Drilldown): - Backend: check_payloads-Tabelle um Spalte 'banner' (JSON) — voller banner_result persistiert (vorher nur in-memory). ALTER TABLE Migration idempotent. - Neuer Endpoint GET /api/compliance/agent/banner/<check_id> — liefert Quality-Score, Phases, Category-Tests, Banner-Checks, alle 46 structured_checks. - Frontend: BannerTab im /sdk/agent/audit/<id> mit Quality-Cards, 3-Phasen-Cookie-Tabelle, Per-Category-Listing (mit P19-Signal rot/gruen), Banner-Verstoesse + Rechtsgrundlagen, 46-Check-Drilldown filterbar nach Severity. - Tab-Switcher in page.tsx um "Cookie-Banner-Analyse" erweitert. - Bonus: 2 alte route.ts auf Next.js 15 Promise-params umgestellt (Build-Fix). Plus: Critical-Findings-Block nutzt provider_details_visible als primaeres Signal statt nur tracking_services-Anzahl. Smoke-Test Safetykon: 4 Critical Findings im Mail, banner-Endpoint liefert 46 checks + 3 phases + 2 categories mit provider_details_visible=False. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import React, { useEffect, useState } from 'react'
|
||
|
||
type Phase = {
|
||
cookies?: string[]
|
||
scripts?: string[]
|
||
tracking_services?: (string | { name?: string })[]
|
||
new_tracking?: unknown[]
|
||
violations?: Array<{ severity?: string; text?: string }>
|
||
undocumented?: unknown[]
|
||
}
|
||
|
||
type CategoryTest = {
|
||
category: string
|
||
category_label: string
|
||
tracking_services?: (string | { name?: string })[]
|
||
cookies_set?: string[]
|
||
provider_details_visible?: boolean
|
||
violations?: Array<{ severity?: string; text?: string; legal_ref?: string }>
|
||
}
|
||
|
||
type BannerViolation = {
|
||
severity?: string
|
||
text?: string
|
||
legal_ref?: string
|
||
}
|
||
|
||
type StructuredCheck = {
|
||
id: string
|
||
label: string
|
||
passed: boolean
|
||
skipped?: boolean
|
||
severity: string
|
||
level?: number
|
||
hint?: string
|
||
}
|
||
|
||
type BannerResp = {
|
||
found: boolean
|
||
check_id: string
|
||
banner?: {
|
||
banner_provider?: string
|
||
banner_detected?: boolean
|
||
completeness_pct?: number
|
||
correctness_pct?: number
|
||
phases?: Record<string, Phase>
|
||
banner_checks?: { violations?: BannerViolation[] }
|
||
category_tests?: CategoryTest[]
|
||
structured_checks?: StructuredCheck[]
|
||
summary?: Record<string, number>
|
||
}
|
||
}
|
||
|
||
const PHASE_LABEL: Record<string, string> = {
|
||
before_consent: 'Vor Consent',
|
||
after_reject: 'Nach Ablehnung',
|
||
after_accept: 'Nach Annahme',
|
||
}
|
||
|
||
const SEV_BADGE: Record<string, string> = {
|
||
CRITICAL: 'bg-red-600 text-white',
|
||
HIGH: 'bg-red-100 text-red-800',
|
||
MEDIUM: 'bg-amber-100 text-amber-800',
|
||
LOW: 'bg-blue-100 text-blue-800',
|
||
INFO: 'bg-gray-100 text-gray-600',
|
||
}
|
||
|
||
function pctColor(pct?: number): string {
|
||
if (pct === undefined || pct === null) return 'text-gray-400'
|
||
return pct >= 80 ? 'text-green-700' : pct >= 50 ? 'text-amber-700' : 'text-red-700'
|
||
}
|
||
|
||
export default function BannerTab({ checkId }: { checkId: string }) {
|
||
const [data, setData] = useState<BannerResp | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [checkFilter, setCheckFilter] = useState<'all' | 'fail' | 'critical'>('fail')
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
setLoading(true)
|
||
fetch(`/api/sdk/v1/agent/banner/${checkId}`)
|
||
.then(r => r.json())
|
||
.then(d => { if (!cancelled) setData(d) })
|
||
.catch(e => { if (!cancelled) setError(String(e)) })
|
||
.finally(() => { if (!cancelled) setLoading(false) })
|
||
return () => { cancelled = true }
|
||
}, [checkId])
|
||
|
||
if (loading) return <div className="p-6 text-sm text-gray-500">Lade Banner-Daten…</div>
|
||
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
|
||
if (!data?.found || !data.banner) {
|
||
return <div className="p-6 text-sm text-gray-500">Keine Banner-Daten zu diesem Check.</div>
|
||
}
|
||
|
||
const b = data.banner
|
||
const phases = b.phases || {}
|
||
const cats = b.category_tests || []
|
||
const violations = b.banner_checks?.violations || []
|
||
const checks = b.structured_checks || []
|
||
const summary = b.summary || {}
|
||
|
||
const filteredChecks = checks.filter(c => {
|
||
if (checkFilter === 'all') return true
|
||
if (checkFilter === 'fail') return !c.passed && !c.skipped
|
||
return !c.passed && !c.skipped && ['CRITICAL', 'HIGH'].includes(c.severity)
|
||
})
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Quality Cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||
<div className="border rounded p-3">
|
||
<div className="text-[10px] uppercase text-gray-500">Vollstaendigkeit</div>
|
||
<div className={`text-2xl font-semibold ${pctColor(b.completeness_pct)}`}>
|
||
{b.completeness_pct ?? '–'}{b.completeness_pct !== undefined && '%'}
|
||
</div>
|
||
</div>
|
||
<div className="border rounded p-3">
|
||
<div className="text-[10px] uppercase text-gray-500">Korrektheit</div>
|
||
<div className={`text-2xl font-semibold ${pctColor(b.correctness_pct)}`}>
|
||
{b.correctness_pct ?? '–'}{b.correctness_pct !== undefined && '%'}
|
||
</div>
|
||
</div>
|
||
<div className="border rounded p-3">
|
||
<div className="text-[10px] uppercase text-gray-500">Verstoesse</div>
|
||
<div className="text-2xl font-semibold text-red-700">
|
||
{summary.total_violations ?? violations.length}
|
||
</div>
|
||
<div className="text-[10px] text-gray-500 mt-1">
|
||
crit:{summary.critical ?? 0} · high:{summary.high ?? 0}
|
||
</div>
|
||
</div>
|
||
<div className="border rounded p-3">
|
||
<div className="text-[10px] uppercase text-gray-500">CMP</div>
|
||
<div className="text-sm font-medium text-gray-800 truncate">
|
||
{b.banner_provider || 'unbekannt'}
|
||
</div>
|
||
<div className="text-[10px] text-gray-500 mt-1">
|
||
{b.banner_detected ? 'Banner erkannt' : 'kein Banner'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Phases */}
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||
Cookie-Setzungen pro Phase (echter Browser-Test)
|
||
</div>
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-gray-50 text-gray-600">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left">Phase</th>
|
||
<th className="px-3 py-2 text-center">Cookies</th>
|
||
<th className="px-3 py-2 text-center">Tracker</th>
|
||
<th className="px-3 py-2 text-left">Auffaelligkeiten</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(['before_consent', 'after_reject', 'after_accept'] as const).map(key => {
|
||
const p = phases[key] || {}
|
||
const nc = (p.cookies || []).length
|
||
const nt = (p.tracking_services || []).length
|
||
const issues: string[] = []
|
||
if (p.violations?.length) issues.push(`${p.violations.length} Verstoss`)
|
||
if (p.new_tracking?.length) issues.push(`${p.new_tracking.length} neue Tracker`)
|
||
if (p.undocumented?.length) issues.push(`${p.undocumented.length} undokumentiert`)
|
||
const color = key === 'before_consent'
|
||
? (nc === 0 ? 'text-green-600' : 'text-red-600')
|
||
: key === 'after_reject'
|
||
? (nc <= 1 ? 'text-green-600' : 'text-amber-600')
|
||
: 'text-gray-700'
|
||
return (
|
||
<tr key={key} className="border-t">
|
||
<td className="px-3 py-2 font-medium">{PHASE_LABEL[key]}</td>
|
||
<td className={`px-3 py-2 text-center font-semibold ${color}`}>{nc}</td>
|
||
<td className="px-3 py-2 text-center">{nt}</td>
|
||
<td className="px-3 py-2 text-gray-500">{issues.join(', ') || '—'}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Per-Category */}
|
||
{cats.length > 0 && (
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||
Provider-Listing pro Kategorie (P19 Click-Through-Test)
|
||
</div>
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-gray-50 text-gray-600">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left">Kategorie</th>
|
||
<th className="px-3 py-2 text-center">Anbieter sichtbar</th>
|
||
<th className="px-3 py-2 text-center">Tracker erkannt</th>
|
||
<th className="px-3 py-2 text-left">Violations</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{cats.map(c => {
|
||
const pdv = c.provider_details_visible
|
||
const pdv_label = pdv === true ? 'Ja' : pdv === false ? 'Nein' : '–'
|
||
const pdv_color = pdv === false ? 'text-red-700' : pdv === true ? 'text-green-700' : 'text-gray-400'
|
||
return (
|
||
<tr key={c.category} className="border-t">
|
||
<td className="px-3 py-2">{c.category_label}</td>
|
||
<td className={`px-3 py-2 text-center font-semibold ${pdv_color}`}>{pdv_label}</td>
|
||
<td className="px-3 py-2 text-center">{(c.tracking_services || []).length}</td>
|
||
<td className="px-3 py-2 text-red-700 text-[10px]">
|
||
{(c.violations || []).map(v => v.text?.slice(0, 80)).join('; ') || '—'}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Banner-Checks Violations */}
|
||
{violations.length > 0 && (
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
|
||
Banner-Verstoesse ({violations.length})
|
||
</div>
|
||
<ul className="text-xs divide-y">
|
||
{violations.map((v, i) => {
|
||
const sev = (v.severity || 'MEDIUM').toUpperCase()
|
||
return (
|
||
<li key={i} className="px-3 py-2">
|
||
<div className="flex items-start gap-2">
|
||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[sev] || 'bg-gray-100'}`}>{sev}</span>
|
||
<div>
|
||
<div className="text-gray-900">{v.text}</div>
|
||
{v.legal_ref && <div className="text-[10px] text-gray-400 italic mt-1">Quelle: {v.legal_ref}</div>}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* 46 structured_checks Drilldown */}
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700 flex items-center gap-3">
|
||
<span>Banner-Checks ({checks.length})</span>
|
||
<div className="ml-auto flex gap-1">
|
||
{(['all', 'fail', 'critical'] as const).map(f => (
|
||
<button key={f}
|
||
onClick={() => setCheckFilter(f)}
|
||
className={`px-2 py-1 rounded text-[10px] border ${
|
||
checkFilter === f ? 'bg-blue-600 text-white border-blue-600'
|
||
: 'bg-white text-gray-600 border-gray-200'
|
||
}`}>
|
||
{f === 'all' ? 'Alle' : f === 'fail' ? 'Nur Fail' : 'Nur CRIT/HIGH'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-gray-50 text-gray-600">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left">Status</th>
|
||
<th className="px-3 py-2 text-left">Sev</th>
|
||
<th className="px-3 py-2 text-left">Check</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredChecks.map(c => (
|
||
<tr key={c.id} className="border-t">
|
||
<td className="px-3 py-2">
|
||
{c.passed ? <span className="text-green-600">✓</span>
|
||
: c.skipped ? <span className="text-gray-400">—</span>
|
||
: <span className="text-red-600">✗</span>}
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[c.severity] || 'bg-gray-100'}`}>
|
||
{c.severity}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<div className="text-gray-900">{c.label}</div>
|
||
{c.hint && !c.passed && (
|
||
<div className="text-[10px] text-gray-500 mt-1">{c.hint.slice(0, 200)}</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{filteredChecks.length === 0 && (
|
||
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400">Keine Checks fuer den Filter.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|