a127dd971b
Build + Deploy / build-admin-compliance (push) Successful in 2m16s
Build + Deploy / build-backend-compliance (push) Successful in 12s
Build + Deploy / build-ai-sdk (push) Successful in 12s
Build + Deploy / build-developer-portal (push) Successful in 12s
Build + Deploy / build-tts (push) Successful in 15s
Build + Deploy / build-document-crawler (push) Successful in 13s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / build-dsms-node (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 18s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m38s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 21s
CI / validate-canonical-controls (push) Successful in 13s
Build + Deploy / trigger-orca (push) Successful in 2m32s
Save active check_id to localStorage so polling resumes when the user navigates away via sidebar and comes back. Same pattern as scan tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useCallback } from 'react'
|
|
import { ChecklistView } from './ChecklistView'
|
|
import { DocumentRow } from './DocumentRow'
|
|
|
|
const DOCUMENT_TYPES = [
|
|
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
|
{ id: 'impressum', label: 'Impressum', required: true },
|
|
{ id: 'social_media', label: 'Social Media DSE', required: false },
|
|
{ id: 'cookie', label: 'Cookie-Richtlinie', required: false },
|
|
{ id: 'agb', label: 'AGB', required: false },
|
|
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen', required: false },
|
|
{ id: 'widerruf', label: 'Widerrufsbelehrung', required: false },
|
|
{ id: 'dsb', label: 'DSB-Kontakt', required: false },
|
|
] as const
|
|
|
|
type DocTypeId = typeof DOCUMENT_TYPES[number]['id']
|
|
|
|
interface DocState {
|
|
url: string
|
|
text: string
|
|
loading: boolean
|
|
error: string | null
|
|
}
|
|
|
|
type DocsState = Record<DocTypeId, DocState>
|
|
|
|
const STORAGE_KEY_STATE = 'compliance-check-state'
|
|
const STORAGE_KEY_RESULTS = 'compliance-check-results'
|
|
const STORAGE_KEY_HISTORY = 'compliance-check-history'
|
|
const STORAGE_KEY_CHECK_ID = 'compliance-check-active-id'
|
|
|
|
function emptyDocState(): DocState {
|
|
return { url: '', text: '', loading: false, error: null }
|
|
}
|
|
|
|
function initState(): DocsState {
|
|
if (typeof window === 'undefined') {
|
|
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
|
|
}
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY_STATE)
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved) as Record<string, { url?: string; text?: string }>
|
|
return Object.fromEntries(
|
|
DOCUMENT_TYPES.map(d => [d.id, {
|
|
url: parsed[d.id]?.url || '',
|
|
text: parsed[d.id]?.text || '',
|
|
loading: false,
|
|
error: null,
|
|
}])
|
|
) as DocsState
|
|
}
|
|
} catch { /* ignore */ }
|
|
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
|
|
}
|
|
|
|
function countWords(text: string): number {
|
|
if (!text.trim()) return 0
|
|
return text.trim().split(/\s+/).length
|
|
}
|
|
|
|
interface HistoryEntry {
|
|
date: string
|
|
docCount: number
|
|
findings: number
|
|
resultKey: string
|
|
}
|
|
|
|
export function ComplianceCheckTab() {
|
|
const [docs, setDocs] = useState<DocsState>(initState)
|
|
const [useAgent, setUseAgent] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [progress, setProgress] = useState('')
|
|
const [results, setResults] = useState<any>(() => {
|
|
if (typeof window === 'undefined') return null
|
|
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
|
|
})
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [activeCheckId, setActiveCheckId] = useState<string>(() =>
|
|
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY_CHECK_ID) || '' : ''
|
|
)
|
|
const [history, setHistory] = useState<HistoryEntry[]>(() => {
|
|
if (typeof window === 'undefined') return []
|
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
|
|
})
|
|
|
|
// Persist URLs and texts (not loading/error state)
|
|
React.useEffect(() => {
|
|
const toSave: Record<string, { url: string; text: string }> = {}
|
|
for (const [key, val] of Object.entries(docs)) {
|
|
toSave[key] = { url: val.url, text: val.text }
|
|
}
|
|
try { localStorage.setItem(STORAGE_KEY_STATE, JSON.stringify(toSave)) } catch { /* quota */ }
|
|
}, [docs])
|
|
|
|
// Resume polling if check was in progress when navigating away
|
|
React.useEffect(() => {
|
|
if (!activeCheckId || results) return
|
|
let cancelled = false
|
|
setLoading(true)
|
|
setProgress('Pruefung laeuft noch...')
|
|
const poll = async () => {
|
|
while (!cancelled) {
|
|
await new Promise(r => setTimeout(r, 3000))
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${activeCheckId}`)
|
|
if (!res.ok) continue
|
|
const data = await res.json()
|
|
if (data.progress) setProgress(data.progress)
|
|
if (data.status === 'completed' && data.result) {
|
|
setResults(data.result); setProgress(''); setLoading(false)
|
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
return
|
|
}
|
|
if (data.status === 'failed' || data.status === 'not_found') {
|
|
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
|
|
setProgress(''); setLoading(false)
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
return
|
|
}
|
|
} catch { /* retry */ }
|
|
}
|
|
}
|
|
poll()
|
|
return () => { cancelled = true }
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const updateDoc = useCallback((docType: DocTypeId, patch: Partial<DocState>) => {
|
|
setDocs(prev => ({ ...prev, [docType]: { ...prev[docType], ...patch } }))
|
|
}, [])
|
|
|
|
const handleFetchText = useCallback(async (docType: DocTypeId) => {
|
|
const url = docs[docType].url.trim()
|
|
if (!url) return
|
|
|
|
updateDoc(docType, { loading: true, error: null })
|
|
try {
|
|
const res = await fetch('/api/sdk/v1/agent/extract-text', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url }),
|
|
})
|
|
if (!res.ok) {
|
|
const msg = res.status === 404
|
|
? 'Seite nicht erreichbar'
|
|
: `Fehler beim Laden (${res.status})`
|
|
throw new Error(msg)
|
|
}
|
|
const data = await res.json()
|
|
updateDoc(docType, { text: data.text || '', loading: false })
|
|
} catch (e) {
|
|
updateDoc(docType, {
|
|
loading: false,
|
|
error: e instanceof Error ? e.message : 'Text konnte nicht geladen werden',
|
|
})
|
|
}
|
|
}, [docs, updateDoc])
|
|
|
|
const handleFileUpload = useCallback(async (docType: DocTypeId, file: File) => {
|
|
// For now, read as text. PDF/DOCX parsing can be added server-side later.
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
updateDoc(docType, { text: reader.result as string })
|
|
}
|
|
reader.readAsText(file)
|
|
}, [updateDoc])
|
|
|
|
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
|
|
|
|
const handleSubmit = async () => {
|
|
if (filledCount === 0) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
setResults(null)
|
|
setProgress('Compliance-Check wird gestartet...')
|
|
|
|
try {
|
|
const entries = DOCUMENT_TYPES
|
|
.filter(dt => docs[dt.id].url.trim() || docs[dt.id].text.trim())
|
|
.map(dt => ({
|
|
doc_type: dt.id,
|
|
label: dt.label,
|
|
url: docs[dt.id].url.trim(),
|
|
text: docs[dt.id].text.trim() || undefined,
|
|
}))
|
|
|
|
const startRes = await fetch('/api/sdk/v1/agent/compliance-check', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
documents: entries,
|
|
use_agent: useAgent,
|
|
}),
|
|
})
|
|
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
|
const { check_id } = await startRes.json()
|
|
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
|
setActiveCheckId(check_id)
|
|
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
|
|
|
// Poll for results
|
|
let attempts = 0
|
|
while (attempts < 120) {
|
|
await new Promise(r => setTimeout(r, 3000))
|
|
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
|
|
if (!pollRes.ok) { attempts++; continue }
|
|
const pollData = await pollRes.json()
|
|
if (pollData.progress) setProgress(pollData.progress)
|
|
if (pollData.status === 'completed' && pollData.result) {
|
|
setResults(pollData.result)
|
|
setProgress('')
|
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
|
|
const resultKey = `compliance-check-result-${Date.now()}`
|
|
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
|
|
const entry: HistoryEntry = {
|
|
date: new Date().toISOString(),
|
|
docCount: entries.length,
|
|
findings: pollData.result.total_findings || 0,
|
|
resultKey,
|
|
}
|
|
const updated = [entry, ...history].slice(0, 30)
|
|
setHistory(updated)
|
|
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
|
break
|
|
}
|
|
if (pollData.status === 'failed') {
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
|
}
|
|
attempts++
|
|
}
|
|
if (attempts >= 120) {
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
throw new Error('Zeitlimit ueberschritten')
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
setProgress('')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadFromHistory = (entry: HistoryEntry) => {
|
|
if (entry.resultKey) {
|
|
try {
|
|
const saved = localStorage.getItem(entry.resultKey)
|
|
if (saved) { setResults(JSON.parse(saved)); return }
|
|
} catch { /* ignore */ }
|
|
}
|
|
try {
|
|
const last = localStorage.getItem(STORAGE_KEY_RESULTS)
|
|
if (last) setResults(JSON.parse(last))
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Info box */}
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
<h3 className="text-sm font-semibold text-purple-900">Compliance-Check (Alle Dokumente)</h3>
|
|
<p className="text-xs text-purple-700 mt-1">
|
|
Geben Sie die URLs Ihrer Rechtstexte ein oder laden Sie die Dokumente hoch.
|
|
Das System prueft alle Pflichtangaben nach DSGVO, TDDDG, TMG und UWG.
|
|
Pflichtdokumente sind mit * markiert.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Document rows */}
|
|
<div className="space-y-2">
|
|
{DOCUMENT_TYPES.map(dt => (
|
|
<DocumentRow
|
|
key={dt.id}
|
|
label={dt.label}
|
|
docType={dt.id}
|
|
required={dt.required}
|
|
url={docs[dt.id].url}
|
|
text={docs[dt.id].text}
|
|
loading={docs[dt.id].loading}
|
|
error={docs[dt.id].error}
|
|
wordCount={countWords(docs[dt.id].text)}
|
|
onUrlChange={url => updateDoc(dt.id, { url })}
|
|
onFetchText={() => handleFetchText(dt.id)}
|
|
onTextChange={text => updateDoc(dt.id, { text })}
|
|
onFileUpload={file => handleFileUpload(dt.id, file)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Agent toggle + submit */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={() => setUseAgent(!useAgent)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
|
useAgent
|
|
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
|
|
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
|
{useAgent ? 'KI-Agent aktiv (alle MCs)' : 'KI-Agent aus'}
|
|
</button>
|
|
|
|
<span className="text-xs text-gray-500">
|
|
{filledCount} von {DOCUMENT_TYPES.length} Dokumenten ausgefuellt
|
|
</span>
|
|
</div>
|
|
|
|
{/* Submit button */}
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={loading || filledCount === 0}
|
|
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<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>
|
|
Pruefe...
|
|
</>
|
|
) : (
|
|
`Compliance-Check starten (${filledCount} Dokument${filledCount !== 1 ? 'e' : ''})`
|
|
)}
|
|
</button>
|
|
|
|
{/* Progress */}
|
|
{progress && (
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
|
|
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" 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>
|
|
{progress}
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
{results && results.results && (
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
|
{/* Business Profile */}
|
|
{results.business_profile && (
|
|
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
|
|
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
|
|
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
|
|
<span>Branche: {results.business_profile.industry}</span>
|
|
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
|
|
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Banner Check Result */}
|
|
{results.banner_result && (
|
|
<div className={`mb-4 p-3 rounded-lg border text-xs ${
|
|
results.banner_result.violations > 0
|
|
? 'bg-amber-50 border-amber-200'
|
|
: results.banner_result.detected
|
|
? 'bg-green-50 border-green-200'
|
|
: 'bg-gray-50 border-gray-200'
|
|
}`}>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${
|
|
results.banner_result.violations > 0 ? 'bg-amber-500'
|
|
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
|
|
}`} />
|
|
<span className="font-semibold text-gray-900">
|
|
Cookie-Banner-Check (automatisch)
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-gray-600 ml-4">
|
|
{results.banner_result.detected ? (
|
|
<>
|
|
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
|
|
{results.banner_result.violations > 0
|
|
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden — Cross-Check-Ergebnisse sind in der Cookie-Richtlinie-Checkliste enthalten.`
|
|
: ' Keine Auffaelligkeiten beim Banner-Cookie-Abgleich.'}
|
|
</>
|
|
) : (
|
|
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ChecklistView results={results.results} />
|
|
|
|
{/* Email status */}
|
|
{results.email_status && (
|
|
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
|
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
|
</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 Compliance-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 text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left"
|
|
>
|
|
<span className="text-gray-600">
|
|
{new Date(h.date).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})}
|
|
</span>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-gray-500">{h.docCount} Dok.</span>
|
|
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
|
{h.findings} Findings
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|