Merge feat/zeroclaw-compliance-agent into main
Brings all compliance doc-check features: - 162 regex checks + 1874 Master Controls - LLM-agnostic agent with tool calling - Banner check (46 checks, 30 CMPs, stealth, Shadow DOM) - Impressum check (24 checks) - Deep consent verification (DataLayer, GCM, TCF) - CMP E2E tests (39 tests) - HTML email reports, FAQ, persistent history Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface AuthCheck {
|
||||
found: boolean
|
||||
text: string
|
||||
legal_ref: string
|
||||
}
|
||||
|
||||
interface AuthData {
|
||||
url: string
|
||||
authenticated: boolean
|
||||
login_error: string
|
||||
checks: Record<string, AuthCheck>
|
||||
findings_count: number
|
||||
}
|
||||
|
||||
const CHECK_LABELS: Record<string, { label: string; icon: string }> = {
|
||||
cancel_subscription: { label: 'Kuendigungsbutton (2 Klicks)', icon: '🚫' },
|
||||
delete_account: { label: 'Konto loeschen', icon: '🗑️' },
|
||||
export_data: { label: 'Daten exportieren', icon: '📥' },
|
||||
consent_settings: { label: 'Einwilligungen widerrufen', icon: '⚙️' },
|
||||
profile_visible: { label: 'Profildaten einsehen', icon: '👤' },
|
||||
}
|
||||
|
||||
export function AuthTestResult({ data }: { data: AuthData }) {
|
||||
if (!data.authenticated) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm font-medium text-red-800">Login fehlgeschlagen</p>
|
||||
<p className="text-xs text-red-600 mt-1">{data.login_error || 'Credentials oder Formular nicht erkannt'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<span className="text-sm font-medium text-gray-900">Erfolgreich eingeloggt</span>
|
||||
<span className={`ml-auto text-xs px-2 py-1 rounded font-medium ${data.findings_count > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{data.findings_count} fehlende Funktionen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{Object.entries(data.checks).map(([key, check]) => {
|
||||
const info = CHECK_LABELS[key] || { label: key, icon: '❓' }
|
||||
return (
|
||||
<div key={key} className={`flex items-center gap-3 p-3 rounded-lg border ${check.found ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<span className="text-lg">{info.icon}</span>
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${check.found ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{check.found ? '✓' : '✗'} {info.label}
|
||||
</p>
|
||||
{check.text && <p className="text-xs text-gray-500 mt-0.5">{check.text}</p>}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400">{check.legal_ref}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data.findings_count > 0 && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-3 text-xs text-red-700">
|
||||
<strong>{data.findings_count} Pflichtfunktion(en) fehlen.</strong> Der Nutzer kann seine Rechte
|
||||
nach DSGVO nicht vollstaendig ausueben.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface SiteResult {
|
||||
url: string
|
||||
domain: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
findings_count: number
|
||||
services_count: number
|
||||
has_impressum: boolean
|
||||
has_datenschutz: boolean
|
||||
has_cookie_banner: boolean
|
||||
has_google_fonts: boolean
|
||||
scan_status: string
|
||||
}
|
||||
|
||||
const RISK_COLOR: Record<string, string> = {
|
||||
MINIMAL: 'text-green-700 bg-green-50',
|
||||
LOW: 'text-yellow-700 bg-yellow-50',
|
||||
LIMITED: 'text-orange-700 bg-orange-50',
|
||||
HIGH: 'text-red-700 bg-red-50',
|
||||
UNACCEPTABLE: 'text-red-900 bg-red-100',
|
||||
}
|
||||
|
||||
export function CompareResult({ sites }: { sites: SiteResult[] }) {
|
||||
if (!sites.length) return null
|
||||
|
||||
const checks = [
|
||||
{ key: 'has_datenschutz', label: 'Datenschutzerklaerung' },
|
||||
{ key: 'has_impressum', label: 'Impressum' },
|
||||
{ key: 'has_cookie_banner', label: 'Cookie-Banner' },
|
||||
{ key: 'has_google_fonts', label: 'Google Fonts (lokal?)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500 w-44">Pruefung</th>
|
||||
{sites.map((s, i) => (
|
||||
<th key={i} className="text-center px-3 py-2 text-xs font-medium text-gray-700">
|
||||
{s.domain}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-gray-600">Risiko-Score</td>
|
||||
{sites.map((s, i) => (
|
||||
<td key={i} className="px-3 py-2 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${RISK_COLOR[s.risk_level] || 'text-gray-600 bg-gray-50'}`}>
|
||||
{s.risk_level || '?'} ({s.risk_score}/100)
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-gray-600">Findings</td>
|
||||
{sites.map((s, i) => (
|
||||
<td key={i} className={`px-3 py-2 text-center font-medium ${s.findings_count > 0 ? 'text-red-700' : 'text-green-700'}`}>
|
||||
{s.findings_count}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-gray-600">Dienste erkannt</td>
|
||||
{sites.map((s, i) => (
|
||||
<td key={i} className="px-3 py-2 text-center text-gray-700">{s.services_count}</td>
|
||||
))}
|
||||
</tr>
|
||||
{checks.map(check => (
|
||||
<tr key={check.key}>
|
||||
<td className="px-3 py-2 text-gray-600">{check.label}</td>
|
||||
{sites.map((s, i) => {
|
||||
const val = (s as any)[check.key]
|
||||
const isInverted = check.key === 'has_google_fonts'
|
||||
const good = isInverted ? !val : val
|
||||
return (
|
||||
<td key={i} className={`px-3 py-2 text-center font-medium ${good ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{good ? '✓' : '✗'}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface Violation {
|
||||
service: string
|
||||
severity: string
|
||||
text: string
|
||||
legal_ref: string
|
||||
}
|
||||
|
||||
interface PhaseData {
|
||||
scripts: string[]
|
||||
cookies: string[]
|
||||
tracking_services?: string[]
|
||||
new_tracking?: string[]
|
||||
violations?: Violation[]
|
||||
undocumented?: string[]
|
||||
}
|
||||
|
||||
interface ConsentData {
|
||||
banner_detected: boolean
|
||||
banner_provider: string
|
||||
phases: {
|
||||
before_consent: PhaseData
|
||||
after_reject: PhaseData
|
||||
after_accept: PhaseData
|
||||
}
|
||||
summary: {
|
||||
critical: number
|
||||
high: number
|
||||
undocumented: number
|
||||
total_violations: number
|
||||
category_violations?: number
|
||||
categories_tested?: number
|
||||
}
|
||||
banner_checks?: {
|
||||
has_impressum_link: boolean
|
||||
has_dse_link: boolean
|
||||
violations: { service: string; severity: string; text: string; legal_ref: string }[]
|
||||
}
|
||||
category_tests?: {
|
||||
category: string
|
||||
category_label: string
|
||||
tracking_services: string[]
|
||||
violations: { service: string; severity: string; text: string }[]
|
||||
}[]
|
||||
}
|
||||
|
||||
const SEV = {
|
||||
CRITICAL: { bg: 'bg-red-100 border-red-300', text: 'text-red-800', badge: 'bg-red-600' },
|
||||
HIGH: { bg: 'bg-orange-100 border-orange-300', text: 'text-orange-800', badge: 'bg-orange-500' },
|
||||
}
|
||||
|
||||
function PhaseCard({ title, icon, data, type }: {
|
||||
title: string; icon: string; data: PhaseData; type: 'before' | 'reject' | 'accept'
|
||||
}) {
|
||||
const violations = data.violations || []
|
||||
const tracking = data.tracking_services || data.new_tracking || []
|
||||
const undocumented = data.undocumented || []
|
||||
const hasProblem = violations.length > 0 || undocumented.length > 0
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 ${hasProblem ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50'}`}>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<span>{icon}</span> {title}
|
||||
</h4>
|
||||
|
||||
{/* Violations */}
|
||||
{violations.map((v, i) => (
|
||||
<div key={i} className={`mb-2 p-2 rounded border ${SEV[v.severity as keyof typeof SEV]?.bg || SEV.HIGH.bg}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded text-white ${SEV[v.severity as keyof typeof SEV]?.badge || SEV.HIGH.badge}`}>
|
||||
{v.severity}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${SEV[v.severity as keyof typeof SEV]?.text || SEV.HIGH.text}`}>
|
||||
{v.service}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 mt-1">{v.text}</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{v.legal_ref}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Undocumented (Phase C only) */}
|
||||
{undocumented.map((s, i) => (
|
||||
<div key={i} className="mb-2 p-2 rounded border border-yellow-300 bg-yellow-50">
|
||||
<span className="text-xs text-yellow-800">✗ {s} — nicht in Cookie-Policy dokumentiert</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Tracking services (no violations) */}
|
||||
{violations.length === 0 && undocumented.length === 0 && tracking.length > 0 && (
|
||||
<div className="text-xs text-green-700">
|
||||
{tracking.map((t, i) => <div key={i}>✓ {t} — {type === 'accept' ? 'mit Consent OK' : 'erkannt'}</div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{violations.length === 0 && undocumented.length === 0 && tracking.length === 0 && (
|
||||
<p className="text-xs text-green-700">✓ Keine Tracking-Dienste erkannt</p>
|
||||
)}
|
||||
|
||||
{/* Cookie/Script count */}
|
||||
<div className="flex gap-3 mt-2 text-[10px] text-gray-400">
|
||||
<span>{data.scripts?.length || 0} Scripts</span>
|
||||
<span>{data.cookies?.length || 0} Cookies</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConsentTestResult({ data }: { data: ConsentData }) {
|
||||
const s = data.summary
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-3 h-3 rounded-full ${data.banner_detected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Cookie-Banner: {data.banner_detected ? data.banner_provider : 'Nicht erkannt'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{s.critical > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-red-600 text-white font-medium">
|
||||
{s.critical} Kritisch
|
||||
</span>
|
||||
)}
|
||||
{s.high > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-orange-500 text-white font-medium">
|
||||
{s.high} Hoch
|
||||
</span>
|
||||
)}
|
||||
{s.total_violations === 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-green-500 text-white font-medium">
|
||||
Keine Verstoesse
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three Phases */}
|
||||
<div className="space-y-3">
|
||||
<PhaseCard
|
||||
title="Phase A: Vor Einwilligung"
|
||||
icon="🔍"
|
||||
data={data.phases.before_consent}
|
||||
type="before"
|
||||
/>
|
||||
{data.banner_detected && (
|
||||
<>
|
||||
<PhaseCard
|
||||
title="Phase B: Nach Ablehnung"
|
||||
icon="🚫"
|
||||
data={data.phases.after_reject}
|
||||
type="reject"
|
||||
/>
|
||||
<PhaseCard
|
||||
title="Phase C: Nach Zustimmung"
|
||||
icon="✅"
|
||||
data={data.phases.after_accept}
|
||||
type="accept"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Banner Text Checks */}
|
||||
{data.banner_checks && (data.banner_checks.violations?.length > 0 || data.banner_checks.has_impressum_link !== undefined) && (
|
||||
<div className="border rounded-lg p-4 border-gray-200 bg-gray-50">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<span>📝</span> Banner-Text Pruefung
|
||||
</h4>
|
||||
<div className="flex gap-3 mb-3 text-xs">
|
||||
<span className={data.banner_checks.has_impressum_link ? 'text-green-600' : 'text-red-600'}>
|
||||
{data.banner_checks.has_impressum_link ? '✓' : '✗'} Impressum-Link
|
||||
</span>
|
||||
<span className={data.banner_checks.has_dse_link ? 'text-green-600' : 'text-red-600'}>
|
||||
{data.banner_checks.has_dse_link ? '✓' : '✗'} DSE-Link
|
||||
</span>
|
||||
</div>
|
||||
{data.banner_checks.violations?.map((v: any, i: number) => {
|
||||
const isHigh = v.severity === 'HIGH' || v.severity === 'CRITICAL'
|
||||
return (
|
||||
<div key={i} className={`mb-2 p-2 rounded border ${isHigh ? 'border-red-300 bg-red-50' : 'border-yellow-300 bg-yellow-50'}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded text-white ${isHigh ? 'bg-red-600' : 'bg-yellow-600'}`}>
|
||||
{v.severity}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs text-gray-800">{v.text}</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{v.legal_ref}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{(!data.banner_checks.violations || data.banner_checks.violations.length === 0) && (
|
||||
<p className="text-xs text-green-700">✓ Keine Banner-Text-Verstoesse erkannt</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Tests (Phase D-F) */}
|
||||
{data.category_tests && data.category_tests.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mt-2">Kategorie-Tests ({data.category_tests.length})</h4>
|
||||
{data.category_tests.map((ct, i) => {
|
||||
const hasViolations = ct.violations.length > 0
|
||||
return (
|
||||
<div key={i} className={`border rounded-lg p-4 ${hasViolations ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50'}`}>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<span>🔀</span> Nur "{ct.category_label}"
|
||||
</h4>
|
||||
{ct.violations.length > 0 ? (
|
||||
ct.violations.map((v, vi) => (
|
||||
<div key={vi} className="mb-2 p-2 rounded border border-red-300 bg-red-100">
|
||||
<span className="text-xs font-bold text-red-800 px-1.5 py-0.5 rounded bg-red-200">FALSCH</span>
|
||||
<span className="text-xs text-red-700 ml-2">{v.text}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-green-700">
|
||||
{ct.tracking_services.length > 0 ? (
|
||||
ct.tracking_services.map((s, si) => <div key={si}>✓ {s} — korrekte Kategorie</div>)
|
||||
) : (
|
||||
<div>✓ Keine Tracking-Dienste geladen — korrekt</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No banner warning */}
|
||||
{!data.banner_detected && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-xs text-red-700">
|
||||
<strong>Kein Cookie-Banner erkannt.</strong> Alle erkannten Tracking-Dienste laden ohne
|
||||
Einwilligung — dies ist ein Verstoss gegen §25 TDDDG.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { TextReference } from './TextReference'
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string
|
||||
@@ -14,11 +15,27 @@ interface ServiceInfo {
|
||||
status: string
|
||||
}
|
||||
|
||||
interface TextRef {
|
||||
found: boolean
|
||||
source_url: string
|
||||
document_type: string
|
||||
section_heading: string
|
||||
section_number: string
|
||||
parent_section: string
|
||||
paragraph_index: number
|
||||
original_text: string
|
||||
issue: string
|
||||
correction_type: string
|
||||
correction_text: string
|
||||
insert_after: string
|
||||
}
|
||||
|
||||
interface ScanFinding {
|
||||
code: string
|
||||
severity: string
|
||||
text: string
|
||||
correction: string
|
||||
<<<<<<< HEAD
|
||||
doc_title: string
|
||||
}
|
||||
|
||||
@@ -30,6 +47,9 @@ interface DiscoveredDocument {
|
||||
word_count: number
|
||||
completeness_pct: number
|
||||
findings_count: number
|
||||
=======
|
||||
text_reference: TextRef | null
|
||||
>>>>>>> feat/zeroclaw-compliance-agent
|
||||
}
|
||||
|
||||
interface ScanData {
|
||||
@@ -249,7 +269,12 @@ export function ScanResult({ data }: { data: ScanData }) {
|
||||
</span>
|
||||
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
||||
</div>
|
||||
{f.correction && (
|
||||
{/* Text Reference (original text + position + correction) */}
|
||||
{f.text_reference && (
|
||||
<TextReference ref={f.text_reference} correction={f.correction} />
|
||||
)}
|
||||
{/* Fallback: correction without text reference */}
|
||||
{!f.text_reference && f.correction && (
|
||||
<div className="mt-2">
|
||||
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
||||
@@ -272,6 +297,7 @@ export function ScanResult({ data }: { data: ScanData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<<<<<<< HEAD
|
||||
|
||||
{/* Email Status */}
|
||||
{data.email_status && (
|
||||
@@ -280,6 +306,37 @@ export function ScanResult({ data }: { data: ScanData }) {
|
||||
E-Mail: {data.email_status === 'sent' ? 'Gesendet' : data.email_status}
|
||||
</div>
|
||||
)}
|
||||
=======
|
||||
{/* PDF Export Button */}
|
||||
<div className="pt-4 border-t flex gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/agent/scans/pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: '', scan_type: 'scan', analysis_mode: 'post_launch', result: data }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'compliance-report.pdf'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch (e) { console.error('PDF export failed:', e) }
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
PDF herunterladen
|
||||
</button>
|
||||
</div>
|
||||
>>>>>>> feat/zeroclaw-compliance-agent
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface TextRef {
|
||||
found: boolean
|
||||
source_url: string
|
||||
document_type: string
|
||||
section_heading: string
|
||||
section_number: string
|
||||
parent_section: string
|
||||
paragraph_index: number
|
||||
original_text: string
|
||||
issue: string
|
||||
correction_type: string
|
||||
correction_text: string
|
||||
insert_after: string
|
||||
}
|
||||
|
||||
const ISSUE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
missing: { label: 'Fehlt in der DSE', color: 'text-red-700 bg-red-50' },
|
||||
incomplete: { label: 'Unvollstaendig', color: 'text-yellow-700 bg-yellow-50' },
|
||||
incorrect: { label: 'Fehlerhaft', color: 'text-orange-700 bg-orange-50' },
|
||||
}
|
||||
|
||||
const CORRECTION_LABELS: Record<string, string> = {
|
||||
insert: 'Neuen Abschnitt einfuegen',
|
||||
append: 'Am Ende des Absatzes ergaenzen',
|
||||
replace: 'Absatz ersetzen',
|
||||
}
|
||||
|
||||
export function TextReference({ ref, correction }: { ref: TextRef; correction?: string }) {
|
||||
const [showCorrection, setShowCorrection] = useState(false)
|
||||
const issue = ISSUE_LABELS[ref.issue] || null
|
||||
const correctionText = correction || ref.correction_text
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{/* Original Text Block */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
|
||||
<span>📄</span> Originaltextblock:
|
||||
</p>
|
||||
<div className={`rounded-lg p-3 border ${ref.found ? 'bg-gray-50 border-gray-200' : 'bg-red-50 border-red-200'}`}>
|
||||
{ref.found ? (
|
||||
<p className="text-gray-700 text-xs whitespace-pre-wrap">{ref.original_text || '(Textinhalt konnte nicht extrahiert werden)'}</p>
|
||||
) : (
|
||||
<p className="text-red-600 text-xs italic">Nicht vorhanden — Eintrag fehlt in der {ref.document_type}.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
|
||||
<span>📍</span> Position:
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 text-xs text-blue-800">
|
||||
{ref.found ? (
|
||||
<>
|
||||
<span className="font-semibold">{ref.section_heading || 'Abschnitt unbekannt'}</span>
|
||||
{ref.section_number && <span className="text-blue-600 ml-1">(Nr. {ref.section_number})</span>}
|
||||
{ref.parent_section && <span className="text-blue-500 ml-1">in: {ref.parent_section}</span>}
|
||||
{ref.paragraph_index > 0 && <span className="text-blue-500 ml-1">| Absatz {ref.paragraph_index}</span>}
|
||||
</>
|
||||
) : ref.insert_after ? (
|
||||
<span><strong>{CORRECTION_LABELS[ref.correction_type] || 'Einfuegen'}</strong> nach Abschnitt "{ref.insert_after}"</span>
|
||||
) : (
|
||||
<span>Neuen Abschnitt in der {ref.document_type} anlegen</span>
|
||||
)}
|
||||
{ref.source_url && (
|
||||
<div className="text-blue-400 mt-1 truncate">in: {ref.source_url}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction */}
|
||||
{correctionText && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowCorrection(!showCorrection)}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
|
||||
>
|
||||
<span>{showCorrection ? '▼' : '▶'}</span>
|
||||
<span>✏️</span> Korrekturvorschlag {showCorrection ? 'ausblenden' : 'anzeigen'}
|
||||
</button>
|
||||
{showCorrection && (
|
||||
<div className="mt-2 bg-white border border-purple-200 rounded-lg p-3 relative">
|
||||
{issue && (
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium mb-2 inline-block ${issue.color}`}>
|
||||
{CORRECTION_LABELS[ref.correction_type] || issue.label}
|
||||
</span>
|
||||
)}
|
||||
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-sans mt-1">{correctionText}</pre>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(correctionText)}
|
||||
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded transition-colors"
|
||||
title="In Zwischenablage kopieren"
|
||||
>
|
||||
📋 Kopieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { ScanResult } from './_components/ScanResult'
|
||||
<<<<<<< HEAD
|
||||
import { DocCheckTab } from './_components/DocCheckTab'
|
||||
import { BannerCheckTab } from './_components/BannerCheckTab'
|
||||
import { ImpressumCheckTab } from './_components/ImpressumCheckTab'
|
||||
@@ -31,6 +32,43 @@ export default function AgentPage() {
|
||||
if (typeof window === 'undefined') return []
|
||||
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
|
||||
})
|
||||
=======
|
||||
import { ConsentTestResult } from './_components/ConsentTestResult'
|
||||
import { CompareResult } from './_components/CompareResult'
|
||||
import { AuthTestResult } from './_components/AuthTestResult'
|
||||
|
||||
type Mode = 'pre_launch' | 'post_launch'
|
||||
type Tab = 'quick' | 'scan' | 'consent' | 'compare' | 'auth'
|
||||
|
||||
const MODES = [
|
||||
{ id: 'pre_launch' as Mode, label: 'Internes Dokument', desc: 'Vor Veroeffentlichung', icon: '📋' },
|
||||
{ id: 'post_launch' as Mode, label: 'Live-Website', desc: 'Bereits online', icon: '🌐' },
|
||||
]
|
||||
|
||||
const TABS = [
|
||||
{ id: 'quick' as Tab, label: 'Schnellanalyse', info: 'Einzelne URL klassifizieren und bewerten.' },
|
||||
{ id: 'scan' as Tab, label: 'Website-Scan', info: '5-10 Seiten scannen, Dienstleister abgleichen, Pflichtinhalte pruefen.' },
|
||||
{ id: 'consent' as Tab, label: 'Cookie-Test', info: 'Testet mit Browser was VOR und NACH Cookie-Einwilligung geladen wird.' },
|
||||
{ id: 'compare' as Tab, label: 'Vergleich', info: '2-5 Websites parallel scannen und Compliance vergleichen.' },
|
||||
{ id: 'auth' as Tab, label: 'Login-Test', info: 'Nach Login pruefen: Kuendigung, Daten loeschen, Export, Einwilligungen.' },
|
||||
]
|
||||
|
||||
export default function AgentPage() {
|
||||
const [url, setUrl] = useState('')
|
||||
const [urls, setUrls] = useState('')
|
||||
const [mode, setMode] = useState<Mode>('post_launch')
|
||||
const [tab, setTab] = useState<Tab>('quick')
|
||||
const [scanLoading, setScanLoading] = useState(false)
|
||||
const [scanError, setScanError] = useState<string | null>(null)
|
||||
const [scanData, setScanData] = useState<any>(null)
|
||||
const [scanHistory, setScanHistory] = useState<any[]>([])
|
||||
const [consentData, setConsentData] = useState<any>(null)
|
||||
const [compareData, setCompareData] = useState<any>(null)
|
||||
const [authData, setAuthData] = useState<any>(null)
|
||||
const [authUser, setAuthUser] = useState('')
|
||||
const [authPass, setAuthPass] = useState('')
|
||||
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
|
||||
>>>>>>> feat/zeroclaw-compliance-agent
|
||||
|
||||
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
|
||||
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
|
||||
@@ -91,6 +129,7 @@ export default function AgentPage() {
|
||||
|
||||
const handleScan = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
<<<<<<< HEAD
|
||||
if (!url.trim()) return
|
||||
setScanLoading(true)
|
||||
setScanError(null)
|
||||
@@ -131,6 +170,51 @@ export default function AgentPage() {
|
||||
} catch (e) {
|
||||
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
setScanProgress('')
|
||||
=======
|
||||
setScanLoading(true)
|
||||
setScanError(null)
|
||||
|
||||
try {
|
||||
if (tab === 'quick') {
|
||||
setScanLoading(false)
|
||||
analyze(url.trim(), mode)
|
||||
return
|
||||
}
|
||||
|
||||
let endpoint = ''
|
||||
let body: any = {}
|
||||
|
||||
if (tab === 'scan') {
|
||||
endpoint = '/api/sdk/v1/agent/scan'
|
||||
body = { url: url.trim(), mode }
|
||||
} else if (tab === 'consent') {
|
||||
endpoint = '/api/sdk/v1/agent/consent-test'
|
||||
body = { url: url.trim() }
|
||||
} else if (tab === 'compare') {
|
||||
endpoint = '/api/sdk/v1/agent/compare'
|
||||
body = { urls: urls.split('\n').map(u => u.trim()).filter(Boolean), mode }
|
||||
} else if (tab === 'auth') {
|
||||
endpoint = '/api/sdk/v1/agent/authenticated-scan'
|
||||
body = { url: url.trim(), username: authUser, password: authPass }
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Fehlgeschlagen: ${res.status}`)
|
||||
const data = await res.json()
|
||||
|
||||
if (tab === 'scan') {
|
||||
setScanData(data)
|
||||
setScanHistory(prev => [{ url: url.trim(), ...data, scanned_at: new Date().toISOString() }, ...prev].slice(0, 20))
|
||||
} else if (tab === 'consent') setConsentData(data)
|
||||
else if (tab === 'compare') setCompareData(data)
|
||||
else if (tab === 'auth') setAuthData(data)
|
||||
} catch (e) {
|
||||
setScanError(e instanceof Error ? e.message : 'Fehler')
|
||||
>>>>>>> feat/zeroclaw-compliance-agent
|
||||
} finally {
|
||||
setScanLoading(false)
|
||||
}
|
||||
@@ -158,6 +242,7 @@ export default function AgentPage() {
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
|
||||
<<<<<<< HEAD
|
||||
<p className="text-gray-500 mt-1">Analysiere Webseiten und Dokumente auf DSGVO-Konformitaet.</p>
|
||||
</div>
|
||||
|
||||
@@ -281,10 +366,93 @@ export default function AgentPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
=======
|
||||
<p className="text-gray-500 mt-1">Analysiere Dokumente und Webseiten auf DSGVO-Konformitaet.</p>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{MODES.map(m => (
|
||||
<button key={m.id} onClick={() => setMode(m.id)}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${mode === m.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{m.icon}</span>
|
||||
<div>
|
||||
<p className={`text-sm font-semibold ${mode === m.id ? 'text-purple-900' : 'text-gray-900'}`}>{m.label}</p>
|
||||
<p className="text-xs text-gray-500">{m.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div>
|
||||
<div className="flex border-b border-gray-200 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap transition-colors ${tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2 px-1">{TABS.find(t => t.id === tab)?.info}</p>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{tab === 'compare' ? (
|
||||
<textarea value={urls} onChange={e => setUrls(e.target.value)}
|
||||
placeholder="https://www.opodo.de https://www.booking.com https://www.expedia.de"
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 text-sm"
|
||||
disabled={isLoading} />
|
||||
) : (
|
||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||
placeholder={tab === 'auth' ? 'https://www.example.com/login' : 'https://www.example.com/'}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 text-sm"
|
||||
disabled={isLoading} required />
|
||||
)}
|
||||
{tab === 'auth' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={authUser} onChange={e => setAuthUser(e.target.value)}
|
||||
placeholder="Email / Benutzername" autoComplete="off"
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm" />
|
||||
<input type="password" value={authPass} onChange={e => setAuthPass(e.target.value)}
|
||||
placeholder="Passwort" autoComplete="off"
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm" />
|
||||
<p className="col-span-2 text-[10px] text-gray-400">Credentials werden NICHT gespeichert — nur fuer diesen Test im Browser-Kontext.</p>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" disabled={isLoading || (!url.trim() && tab !== 'compare') || (tab === 'compare' && !urls.trim())}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium">
|
||||
{isLoading ? (
|
||||
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>Analysiere...</>
|
||||
) : TABS.find(t => t.id === tab)?.label || 'Starten'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{currentError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{currentError}</div>}
|
||||
|
||||
{/* Results */}
|
||||
{tab === 'quick' && result && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-6">
|
||||
<AnalysisResult result={result} />
|
||||
{result.follow_up_questions.length > 0 && (
|
||||
<div className="border-t pt-4"><FollowUpQuestions questions={result.follow_up_questions} answers={result.follow_up_answers} onAnswer={answerFollowUp} /></div>
|
||||
>>>>>>> feat/zeroclaw-compliance-agent
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'scan' && scanData && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
|
||||
{tab === 'consent' && consentData && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ConsentTestResult data={consentData} /></div>}
|
||||
{tab === 'compare' && compareData?.sites && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><CompareResult sites={compareData.sites} /></div>}
|
||||
{tab === 'auth' && authData && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><AuthTestResult data={authData} /></div>}
|
||||
|
||||
<<<<<<< HEAD
|
||||
{/* Specialized Tabs */}
|
||||
{tab === 'doc-check' && <DocCheckTab />}
|
||||
{tab === 'banner-check' && <BannerCheckTab />}
|
||||
@@ -292,6 +460,27 @@ export default function AgentPage() {
|
||||
|
||||
{/* FAQ */}
|
||||
<ComplianceFAQ />
|
||||
=======
|
||||
{/* History */}
|
||||
{tab === 'quick' && <AnalysisHistory history={history} onSelect={r => { setUrl(r.url); analyze(r.url, mode) }} />}
|
||||
{tab === 'scan' && scanHistory.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h3>
|
||||
<div className="space-y-2">
|
||||
{scanHistory.map((item, i) => (
|
||||
<button key={i} onClick={() => setUrl(item.url)}
|
||||
className="w-full text-left p-3 bg-white border border-gray-200 rounded-lg hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-8">{item.pages_scanned}p</span>
|
||||
<span className="text-sm text-gray-700 truncate flex-1">{item.url}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${item.findings?.length > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>{item.findings?.length || 0}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>>>>>>> feat/zeroclaw-compliance-agent
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user