45446aef16
1. Cookie 'Zwecke' false positive: added 'um...zu', 'dienen', 'helfen', 'ermöglichen' patterns — catches purpose descriptions without 'Zweck' 2. Kurzhinweis: added empty all_checks for short documents (<200 words) 3. Bezeichnungsfeld: placeholder shows 'Version / Stand' for typed docs, 'Dokumentname' for 'Sonstiges' 4. DocCheckTab state persistence: entries + results survive navigation 5. DocCheck history: saves each check with date, doc count, findings 6. History display: 'Letzte Pruefungen' section at bottom of tab 7. ChecklistView: shows 'X von Y Pruefpunkten bestanden' per document 8. Results persist in localStorage across page navigation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState } from 'react'
|
|
import { ChecklistView } from './ChecklistView'
|
|
|
|
interface DocEntry {
|
|
id: string
|
|
type: string
|
|
label: string
|
|
url: string
|
|
}
|
|
|
|
const DOC_TYPES = [
|
|
{ id: 'dse', label: 'DSI (Datenschutzinformation)' },
|
|
{ id: 'agb', label: 'AGB / Nutzungsbedingungen' },
|
|
{ id: 'impressum', label: 'Impressum' },
|
|
{ id: 'cookie', label: 'Cookie-Richtlinie' },
|
|
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
|
|
{ id: 'other', label: 'Sonstiges' },
|
|
]
|
|
|
|
function newEntry(): DocEntry {
|
|
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '', url: '' }
|
|
}
|
|
|
|
export function DocCheckTab() {
|
|
const [entries, setEntries] = useState<DocEntry[]>(() => {
|
|
if (typeof window === 'undefined') return [newEntry()]
|
|
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
|
|
})
|
|
const [checkCookieBanner, setCheckCookieBanner] = 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('doc-check-results'); return s ? JSON.parse(s) : null } catch { return null }
|
|
})
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [history, setHistory] = useState<{ date: string; urls: number; findings: number }[]>(() => {
|
|
if (typeof window === 'undefined') return []
|
|
try { return JSON.parse(localStorage.getItem('doc-check-history') || '[]') } catch { return [] }
|
|
})
|
|
|
|
// Persist entries
|
|
React.useEffect(() => { localStorage.setItem('doc-check-entries', JSON.stringify(entries)) }, [entries])
|
|
|
|
const updateEntry = (id: string, field: keyof DocEntry, value: string) => {
|
|
setEntries(prev => prev.map(e => e.id === id ? { ...e, [field]: value } : e))
|
|
}
|
|
|
|
const removeEntry = (id: string) => {
|
|
setEntries(prev => prev.filter(e => e.id !== id))
|
|
}
|
|
|
|
const addEntry = () => {
|
|
setEntries(prev => [...prev, newEntry()])
|
|
}
|
|
|
|
// Auto-detect label from URL
|
|
const autoLabel = (entry: DocEntry) => {
|
|
if (entry.label) return
|
|
try {
|
|
const path = new URL(entry.url).pathname
|
|
const last = path.split('/').filter(Boolean).pop() || ''
|
|
const label = last.replace(/-\d+$/, '').replace(/-/g, ' ')
|
|
.replace(/\b\w/g, c => c.toUpperCase())
|
|
if (label.length > 3) {
|
|
updateEntry(entry.id, 'label', label)
|
|
}
|
|
} catch { /* invalid URL */ }
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
const validEntries = entries.filter(e => e.url.trim())
|
|
if (validEntries.length === 0) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
setResults(null)
|
|
setProgress('Pruefung wird gestartet...')
|
|
|
|
try {
|
|
const startRes = await fetch('/api/sdk/v1/agent/doc-check', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
entries: validEntries.map(e => ({
|
|
doc_type: e.type,
|
|
label: e.label || e.url.split('/').pop() || 'Dokument',
|
|
url: e.url.trim(),
|
|
})),
|
|
check_cookie_banner: checkCookieBanner,
|
|
}),
|
|
})
|
|
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')
|
|
|
|
// Poll for results
|
|
let attempts = 0
|
|
while (attempts < 120) {
|
|
await new Promise(r => setTimeout(r, 3000))
|
|
const pollRes = await fetch(`/api/sdk/v1/agent/doc-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('doc-check-results', JSON.stringify(pollData.result))
|
|
const entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0 }
|
|
const updated = [entry, ...history].slice(0, 30)
|
|
setHistory(updated)
|
|
localStorage.setItem('doc-check-history', JSON.stringify(updated))
|
|
break
|
|
}
|
|
if (pollData.status === 'failed') {
|
|
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
|
}
|
|
attempts++
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
setProgress('')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* URL Entries */}
|
|
<div className="space-y-2">
|
|
{entries.map((entry, i) => (
|
|
<div key={entry.id} className="flex items-center gap-2">
|
|
<select
|
|
value={entry.type}
|
|
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
|
|
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
|
|
>
|
|
{DOC_TYPES.map(t => (
|
|
<option key={t.id} value={t.id}>{t.label}</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="text"
|
|
value={entry.label}
|
|
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
|
|
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
|
|
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
|
|
/>
|
|
<input
|
|
type="url"
|
|
value={entry.url}
|
|
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
|
|
onBlur={() => autoLabel(entry)}
|
|
placeholder="https://example.com/datenschutz"
|
|
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
|
|
/>
|
|
{entries.length > 1 && (
|
|
<button onClick={() => removeEntry(entry.id)}
|
|
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Add URL + Options */}
|
|
<div className="flex items-center justify-between">
|
|
<button onClick={addEntry}
|
|
className="flex items-center gap-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
URL hinzufuegen
|
|
</button>
|
|
|
|
<label className="flex items-center gap-2 text-sm text-gray-600">
|
|
<input
|
|
type="checkbox"
|
|
checked={checkCookieBanner}
|
|
onChange={e => setCheckCookieBanner(e.target.checked)}
|
|
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
|
/>
|
|
Cookie-Banner pruefen
|
|
</label>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={loading || entries.every(e => !e.url.trim())}
|
|
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...
|
|
</>
|
|
) : (
|
|
`${entries.filter(e => e.url.trim()).length} Dokument${entries.filter(e => e.url.trim()).length !== 1 ? 'e' : ''} pruefen`
|
|
)}
|
|
</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">
|
|
<ChecklistView results={results.results} />
|
|
|
|
{/* Cookie Banner Result */}
|
|
{results.cookie_banner_result && (
|
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
<h4 className="text-sm font-semibold text-gray-800 mb-2">Cookie-Banner</h4>
|
|
<div className="text-sm text-gray-600">
|
|
{results.cookie_banner_result.banner_detected
|
|
? `Banner erkannt: ${results.cookie_banner_result.banner_provider || 'unbekannt'}`
|
|
: 'Kein Banner erkannt'}
|
|
</div>
|
|
{results.cookie_banner_result.banner_checks?.violations?.length > 0 && (
|
|
<div className="mt-2 space-y-1">
|
|
{results.cookie_banner_result.banner_checks.violations.map((v: any, i: number) => (
|
|
<div key={i} className="text-xs text-red-600 flex items-start gap-1.5">
|
|
<span className="shrink-0 mt-0.5">!!</span>
|
|
<span>{v.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 Pruefungen</h4>
|
|
<div className="space-y-1">
|
|
{history.map((h, i) => (
|
|
<div key={i} className="flex items-center justify-between text-sm py-1.5 border-b border-gray-50 last:border-0">
|
|
<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.urls} Dok.</span>
|
|
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
|
{h.findings} Findings
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|