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:
Benjamin Admin
2026-05-11 11:44:20 +02:00
175 changed files with 20063 additions and 1283 deletions
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
const CONSENT_URL = process.env.CONSENT_TESTER_URL || 'http://bp-compliance-consent-tester:8094'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${CONSENT_URL}/authenticated-scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(120000),
})
if (!response.ok) {
return NextResponse.json({ error: `Auth-Test: ${response.status}` }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
return NextResponse.json({ error: 'Auth-Test fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/compare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(300000),
})
if (!response.ok) {
return NextResponse.json({ error: `Backend: ${response.status}` }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
return NextResponse.json({ error: 'Vergleich fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,142 @@
/**
* Consent Test API Proxy
* POST /api/sdk/v1/agent/consent-test → consent-tester:8094/scan → email via backend
*/
import { NextRequest, NextResponse } from 'next/server'
const CONSENT_TESTER_URL = process.env.CONSENT_TESTER_URL || 'http://bp-compliance-consent-tester:8094'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
interface Violation { service: string; severity: string; text: string; legal_ref: string }
function buildEmailHtml(data: any): string {
const url = data.url || ''
const banner = data.banner_detected ? data.banner_provider : 'Nicht erkannt'
const phases = data.phases || {}
const summary = data.summary || {}
const sev = (s: string) => s === 'CRITICAL'
? '<span style="background:#991b1b;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">KRITISCH</span>'
: '<span style="background:#ea580c;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">HOCH</span>'
const violationRows = (violations: Violation[]) => violations.length === 0
? '<tr><td colspan="3" style="padding:6px;color:#16a34a;">✓ Keine Verstoesse</td></tr>'
: violations.map(v =>
`<tr><td style="padding:6px;">${sev(v.severity)}</td><td style="padding:6px;font-weight:600;">${v.service}</td><td style="padding:6px;">${v.text}<br><span style="color:#6b7280;font-size:11px;">${v.legal_ref}</span></td></tr>`
).join('')
const undocRows = (items: string[]) => items.length === 0
? ''
: items.map(s => `<tr><td style="padding:6px;">⚠</td><td style="padding:6px;font-weight:600;">${s}</td><td style="padding:6px;">Nicht in Cookie-Policy dokumentiert</td></tr>`).join('')
return `
<div style="font-family:-apple-system,sans-serif;max-width:700px;margin:0 auto;">
<div style="background:linear-gradient(135deg,#1e1b4b,#312e81);color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h2 style="margin:0;font-size:18px;">Cookie-Consent-Test</h2>
<p style="margin:4px 0 0;opacity:0.8;font-size:13px;">${url}</p>
</div>
<div style="padding:20px 24px;border:1px solid #e2e8f0;border-top:none;">
<table style="width:100%;border-collapse:collapse;margin-bottom:20px;">
<tr><td style="padding:6px 0;color:#64748b;width:160px;">Cookie-Banner</td><td style="padding:6px 0;font-weight:600;">${data.banner_detected ? '✓ ' + banner : '✗ Nicht erkannt'}</td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Kritische Verstoesse</td><td style="padding:6px 0;"><strong style="color:${summary.critical > 0 ? '#dc2626' : '#16a34a'}">${summary.critical || 0}</strong></td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Hohe Verstoesse</td><td style="padding:6px 0;"><strong style="color:${summary.high > 0 ? '#ea580c' : '#16a34a'}">${summary.high || 0}</strong></td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Undokumentiert</td><td style="padding:6px 0;">${summary.undocumented || 0}</td></tr>
</table>
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
🔍 Phase A: Vor Einwilligung
</h3>
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt OHNE dass der Nutzer etwas geklickt hat?</p>
<table style="width:100%;border-collapse:collapse;">${violationRows(phases.before_consent?.violations || [])}</table>
${data.banner_detected ? `
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
🚫 Phase B: Nach Ablehnung
</h3>
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt NACHDEM der Nutzer "Nur notwendige" geklickt hat?</p>
<table style="width:100%;border-collapse:collapse;">${violationRows(phases.after_reject?.violations || [])}</table>
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
✅ Phase C: Nach Zustimmung
</h3>
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt NACHDEM der Nutzer "Alle akzeptieren" geklickt hat?</p>
<table style="width:100%;border-collapse:collapse;">${undocRows(phases.after_accept?.undocumented || [])}</table>
${(phases.after_accept?.undocumented?.length || 0) === 0 ? '<p style="color:#16a34a;font-size:13px;">✓ Alle Dienste dokumentiert</p>' : ''}
` : `
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;margin:12px 0;">
<strong style="color:#dc2626;">Kein Cookie-Banner erkannt.</strong>
Alle Tracking-Dienste laden ohne Einwilligung — Verstoss gegen §25 TDDDG.
</div>
`}
${(summary.critical || 0) > 0 ? `
<div style="background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin-top:20px;">
<strong style="color:#991b1b;">⚠ KRITISCH:</strong> Tracking-Dienste laden trotz Ablehnung.
Dies ist ein schwerer Verstoss gegen §25 TDDDG und kann als Dark Pattern gewertet werden.
Sofortige Korrektur der Cookie-Banner-Konfiguration empfohlen.
</div>
` : ''}
</div>
<div style="background:#f8fafc;padding:12px 24px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;">
<p style="color:#94a3b8;font-size:11px;margin:0;">
Automatisch erstellt vom BreakPilot Compliance Agent (Playwright + Chromium)
</p>
</div>
</div>
`
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const url = body.url
// Step 1: Run consent test
const response = await fetch(`${CONSENT_TESTER_URL}/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(180000),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: `Consent-Tester: ${response.status}`, detail: errorText },
{ status: response.status }
)
}
const data = await response.json()
// Step 2: Send email with phase-structured findings
try {
const total = (data.summary?.total_violations || 0)
const severity = (data.summary?.critical || 0) > 0 ? 'KRITISCH' : total > 0 ? 'FINDINGS' : 'OK'
await fetch(`${BACKEND_URL}/api/compliance/agent/notify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient: body.recipient || 'dsb@breakpilot.local',
subject: `[COOKIE-TEST] [${severity}] ${url}${total} Verstoesse`,
body_html: buildEmailHtml({ ...data, url }),
role: total > 0 ? 'Datenschutzbeauftragter' : 'Kein Handlungsbedarf',
}),
signal: AbortSignal.timeout(10000),
})
} catch (emailErr) {
console.warn('Email send failed (non-blocking):', emailErr)
}
return NextResponse.json(data)
} catch (error) {
console.error('Consent test proxy error:', error)
return NextResponse.json(
{ error: 'Cookie-Test fehlgeschlagen oder Timeout' },
{ status: 503 }
)
}
}
@@ -0,0 +1,30 @@
/**
* Agent Notify API Proxy
* POST /api/sdk/v1/agent/notify → backend-compliance /api/compliance/agent/notify
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/notify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(15000),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Agent notify proxy error:', error)
return NextResponse.json({ error: 'Email-Versand fehlgeschlagen' }, { status: 503 })
}
}
@@ -18,7 +18,11 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
<<<<<<< HEAD
signal: AbortSignal.timeout(30000), // 30s — just needs to start the job
=======
signal: AbortSignal.timeout(300000), // 5 min — multi-page scan + LLM calls
>>>>>>> feat/zeroclaw-compliance-agent
})
if (!response.ok) {
@@ -0,0 +1,36 @@
/**
* PDF Export Proxy
* POST /api/sdk/v1/agent/scans/pdf → backend /api/compliance/agent/scans/pdf
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/scans/pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
return NextResponse.json({ error: 'PDF generation failed' }, { status: response.status })
}
const pdfBytes = await response.arrayBuffer()
return new NextResponse(pdfBytes, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="compliance-report.pdf"',
},
})
} catch (error) {
console.error('PDF proxy error:', error)
return NextResponse.json({ error: 'PDF generation failed' }, { status: 503 })
}
}
@@ -0,0 +1,92 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { COMPANY_PROFILE_PRESETS, type CompanyProfilePreset } from '@/lib/sdk/company-profile-presets'
import { DOC_LABELS, CATEGORY_COLORS } from './doc-labels'
export function PresetSection({ projectId }: { projectId?: string }) {
const [selectedPreset, setSelectedPreset] = useState<CompanyProfilePreset | null>(null)
// Group recommended docs by category
const groupedDocs = selectedPreset
? selectedPreset.recommendedDocs.reduce<Record<string, string[]>>((acc, docType) => {
const info = DOC_LABELS[docType]
if (!info) return acc
if (!acc[info.category]) acc[info.category] = []
acc[info.category].push(info.label)
return acc
}, {})
: null
return (
<div className="bg-gradient-to-br from-purple-50 to-white rounded-xl border border-purple-200 p-6 space-y-4">
<div>
<h2 className="text-lg font-bold text-gray-900">Schnellstart: Welcher Unternehmenstyp sind Sie?</h2>
<p className="text-sm text-gray-500 mt-1">
Waehlen Sie Ihre Branche wir zeigen Ihnen welche Dokumente Sie benoetigen.
</p>
</div>
{/* Preset Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{COMPANY_PROFILE_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => setSelectedPreset(selectedPreset?.id === preset.id ? null : preset)}
className={`flex flex-col items-center gap-2 p-3 rounded-xl transition-all text-center ${
selectedPreset?.id === preset.id
? 'bg-purple-100 border-2 border-purple-500 shadow-md'
: 'bg-white border border-gray-200 hover:border-purple-300 hover:shadow-sm'
}`}
>
<span className="text-2xl">{preset.icon}</span>
<span className={`text-xs font-medium ${selectedPreset?.id === preset.id ? 'text-purple-700' : 'text-gray-900'}`}>
{preset.label}
</span>
<span className="text-[10px] text-gray-400 leading-tight">{preset.description}</span>
</button>
))}
</div>
{/* Document Preview — shown when a preset is selected */}
{selectedPreset && groupedDocs && (
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">
{selectedPreset.icon} {selectedPreset.label} Ihre Dokumente
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{selectedPreset.recommendedDocs.length} Dokumente werden fuer Sie vorbereitet
</p>
</div>
<Link
href={projectId
? `/sdk/company-profile?project=${projectId}&preset=${selectedPreset.id}`
: `/sdk/company-profile?preset=${selectedPreset.id}`}
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"
>
Jetzt starten
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Object.entries(groupedDocs).map(([category, docs]) => (
<div key={category} className="space-y-1.5">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${CATEGORY_COLORS[category] || 'bg-gray-100 text-gray-600'}`}>
{category}
</span>
{docs.map((doc) => (
<div key={doc} className="text-xs text-gray-700 pl-1">
{doc}
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,131 @@
/**
* Complete mapping of all document template types to display labels and categories.
* Used by PresetSection to show categorized document previews.
*/
export const DOC_LABELS: Record<string, { label: string; category: string }> = {
// ── Website ──────────────────────────────────────────────────────
privacy_policy: { label: 'Datenschutzerklaerung', category: 'Website' },
impressum: { label: 'Impressum', category: 'Website' },
cookie_policy: { label: 'Cookie-Richtlinie', category: 'Website' },
cookie_banner: { label: 'Cookie-Banner-Texte', category: 'Website' },
// ── Vertraege ────────────────────────────────────────────────────
agb: { label: 'AGB', category: 'Vertraege' },
dpa: { label: 'AVV (Auftragsverarbeitung)', category: 'Vertraege' },
nda: { label: 'Geheimhaltungsvereinbarung', category: 'Vertraege' },
sla: { label: 'Service Level Agreement', category: 'Vertraege' },
terms_of_use: { label: 'Nutzungsbedingungen', category: 'Vertraege' },
cloud_service_agreement: { label: 'Cloud-Vertrag', category: 'Vertraege' },
data_usage_clause: { label: 'Datennutzungsklausel', category: 'Vertraege' },
// ── Plattform ────────────────────────────────────────────────────
community_guidelines: { label: 'Community Guidelines', category: 'Plattform' },
acceptable_use: { label: 'Acceptable Use Policy', category: 'Plattform' },
media_content_policy: { label: 'Medien-Richtlinie', category: 'Plattform' },
copyright_policy: { label: 'Urheberrechtsrichtlinie', category: 'Plattform' },
// ── E-Commerce ───────────────────────────────────────────────────
widerruf: { label: 'Widerrufsbelehrung', category: 'E-Commerce' },
// ── HR / Personal ────────────────────────────────────────────────
employee_dsi: { label: 'Mitarbeiter-DSI', category: 'HR' },
applicant_dsi: { label: 'Bewerber-DSI', category: 'HR' },
whistleblower_policy: { label: 'Whistleblower-Richtlinie', category: 'HR' },
employee_security_policy: { label: 'Mitarbeiter-Sicherheitsrichtlinie', category: 'HR' },
security_awareness_policy: { label: 'Security-Awareness-Richtlinie', category: 'HR' },
remote_work_policy: { label: 'Remote-Work-Richtlinie', category: 'HR' },
offboarding_policy: { label: 'Offboarding-Richtlinie', category: 'HR' },
// ── Datenschutz (DSGVO) ──────────────────────────────────────────
tom_documentation: { label: 'TOM-Dokumentation', category: 'Datenschutz' },
vvt_register: { label: 'Verarbeitungsverzeichnis', category: 'Datenschutz' },
loeschkonzept: { label: 'Loeschkonzept', category: 'Datenschutz' },
dsfa: { label: 'Datenschutz-Folgenabschaetzung', category: 'Datenschutz' },
pflichtenregister: { label: 'Pflichtenregister', category: 'Datenschutz' },
data_protection_concept: { label: 'Datenschutzkonzept', category: 'Datenschutz' },
consent_texts: { label: 'Einwilligungstexte', category: 'Datenschutz' },
informationspflichten: { label: 'Informationspflichten', category: 'Datenschutz' },
verpflichtungserklaerung: { label: 'Verpflichtungserklaerung', category: 'Datenschutz' },
social_media_dsi: { label: 'Social-Media-DSI', category: 'Datenschutz' },
video_conference_dsi: { label: 'Videokonferenz-DSI', category: 'Datenschutz' },
// ── Daten-Policies ───────────────────────────────────────────────
data_protection_policy: { label: 'Datenschutzrichtlinie', category: 'Daten-Governance' },
data_classification_policy: { label: 'Datenklassifizierung', category: 'Daten-Governance' },
data_retention_policy: { label: 'Aufbewahrungsrichtlinie', category: 'Daten-Governance' },
data_transfer_policy: { label: 'Datentransfer-Richtlinie', category: 'Daten-Governance' },
privacy_incident_policy: { label: 'Datenschutzvorfall-Richtlinie', category: 'Daten-Governance' },
// ── Betroffenenrechte ────────────────────────────────────────────
dsr_process_art15: { label: 'Auskunftsrecht (Art. 15)', category: 'Betroffenenrechte' },
dsr_process_art16: { label: 'Berichtigungsrecht (Art. 16)', category: 'Betroffenenrechte' },
dsr_process_art17: { label: 'Loeschungsrecht (Art. 17)', category: 'Betroffenenrechte' },
dsr_process_art18: { label: 'Einschraenkungsrecht (Art. 18)', category: 'Betroffenenrechte' },
dsr_process_art19: { label: 'Mitteilungspflicht (Art. 19)', category: 'Betroffenenrechte' },
dsr_process_art20: { label: 'Datenportabilitaet (Art. 20)', category: 'Betroffenenrechte' },
dsr_process_art21: { label: 'Widerspruchsrecht (Art. 21)', category: 'Betroffenenrechte' },
// ── IT-Sicherheit (Konzepte) ─────────────────────────────────────
it_security_concept: { label: 'IT-Sicherheitskonzept', category: 'IT-Sicherheit' },
backup_recovery_concept: { label: 'Backup- & Recovery-Konzept', category: 'IT-Sicherheit' },
logging_concept: { label: 'Logging-Konzept', category: 'IT-Sicherheit' },
incident_response_plan: { label: 'Incident-Response-Plan', category: 'IT-Sicherheit' },
access_control_concept: { label: 'Zugriffskonzept', category: 'IT-Sicherheit' },
risk_management_concept: { label: 'Risikomanagement-Konzept', category: 'IT-Sicherheit' },
isms_manual: { label: 'ISMS-Handbuch', category: 'IT-Sicherheit' },
// ── IT-Sicherheit (Policies) ─────────────────────────────────────
information_security_policy: { label: 'Informationssicherheitsrichtlinie', category: 'IT-Policies' },
access_control_policy: { label: 'Zugriffskontrollrichtlinie', category: 'IT-Policies' },
password_policy: { label: 'Passwortrichtlinie', category: 'IT-Policies' },
encryption_policy: { label: 'Verschluesselungsrichtlinie', category: 'IT-Policies' },
logging_policy: { label: 'Protokollierungsrichtlinie', category: 'IT-Policies' },
backup_policy: { label: 'Datensicherungsrichtlinie', category: 'IT-Policies' },
incident_response_policy: { label: 'Incident-Response-Richtlinie', category: 'IT-Policies' },
change_management_policy: { label: 'Change-Management-Richtlinie', category: 'IT-Policies' },
patch_management_policy: { label: 'Patch-Management-Richtlinie', category: 'IT-Policies' },
asset_management_policy: { label: 'Asset-Management-Richtlinie', category: 'IT-Policies' },
cloud_security_policy: { label: 'Cloud-Security-Richtlinie', category: 'IT-Policies' },
devsecops_policy: { label: 'DevSecOps-Richtlinie', category: 'IT-Policies' },
secrets_management_policy: { label: 'Secrets-Management-Richtlinie', category: 'IT-Policies' },
vulnerability_management_policy: { label: 'Schwachstellenmanagement', category: 'IT-Policies' },
// ── Lieferanten / Drittanbieter ──────────────────────────────────
vendor_risk_management_policy: { label: 'Lieferanten-Risikomanagement', category: 'Lieferanten' },
third_party_security_policy: { label: 'Drittanbieter-Sicherheit', category: 'Lieferanten' },
supplier_security_policy: { label: 'Lieferanten-Anforderungen', category: 'Lieferanten' },
transfer_impact_assessment: { label: 'Transfer Impact Assessment', category: 'Lieferanten' },
scc_companion: { label: 'SCC-Begleitdokument', category: 'Lieferanten' },
// ── BCM / Notfall ────────────────────────────────────────────────
business_continuity_policy: { label: 'Business-Continuity', category: 'BCM' },
disaster_recovery_policy: { label: 'Disaster-Recovery', category: 'BCM' },
crisis_management_policy: { label: 'Krisenmanagement', category: 'BCM' },
// ── KI / Cyber ───────────────────────────────────────────────────
ai_usage_policy: { label: 'KI-Nutzungsrichtlinie', category: 'KI & Cyber' },
cybersecurity_policy: { label: 'Cybersecurity-Richtlinie (CRA)', category: 'KI & Cyber' },
byod_policy: { label: 'BYOD-Richtlinie', category: 'KI & Cyber' },
// ── SOP ──────────────────────────────────────────────────────────
standard_operating_procedure: { label: 'Standard Operating Procedure', category: 'Prozesse' },
}
export const CATEGORY_COLORS: Record<string, string> = {
Website: 'bg-blue-50 text-blue-700',
Vertraege: 'bg-purple-50 text-purple-700',
Plattform: 'bg-indigo-50 text-indigo-700',
'E-Commerce': 'bg-green-50 text-green-700',
HR: 'bg-amber-50 text-amber-700',
Datenschutz: 'bg-red-50 text-red-700',
'Daten-Governance': 'bg-rose-50 text-rose-700',
Betroffenenrechte: 'bg-fuchsia-50 text-fuchsia-700',
'IT-Sicherheit': 'bg-gray-100 text-gray-700',
'IT-Policies': 'bg-slate-100 text-slate-700',
Lieferanten: 'bg-orange-50 text-orange-700',
BCM: 'bg-yellow-50 text-yellow-700',
'KI & Cyber': 'bg-cyan-50 text-cyan-700',
Marketing: 'bg-pink-50 text-pink-700',
Prozesse: 'bg-teal-50 text-teal-700',
}
@@ -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 &quot;{ct.category_label}&quot;
</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 &quot;{ref.insert_after}&quot;</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>
)
}
+189
View File
@@ -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&#10;https://www.booking.com&#10;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>
)
}
+73
View File
@@ -54,6 +54,7 @@ export default function CMPDashboardPage() {
const [consentStats, setConsentStats] = useState<ConsentStats | null>(null)
const [dsrStats, setDSRStats] = useState<DSRStats | null>(null)
const [sites, setSites] = useState<any[]>([])
<<<<<<< HEAD
const [selectedSite, setSelectedSite] = useState<string>('')
const [loading, setLoading] = useState(true)
@@ -64,10 +65,21 @@ export default function CMPDashboardPage() {
async function load() {
const fa = (path: string) => fetch(`/api/sdk/v1/compliance/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
const [consent, dsr, siteList] = await Promise.all([
=======
const [loading, setLoading] = useState(true)
useEffect(() => {
async function load() {
const fb = (path: string) => fetch(`${BANNER_API}/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
const fa = (path: string) => fetch(`/api/sdk/v1/compliance/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
const [banner, consent, dsr, siteList] = await Promise.all([
fb('admin/stats/preview-test-site'),
>>>>>>> feat/zeroclaw-compliance-agent
fa('einwilligungen/consents/stats'),
fa('dsr/stats'),
fb('admin/sites'),
])
<<<<<<< HEAD
setConsentStats(consent)
setDSRStats(dsr)
const loadedSites = Array.isArray(siteList) ? siteList : []
@@ -89,6 +101,17 @@ export default function CMPDashboardPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSite])
=======
setBannerStats(banner)
setConsentStats(consent)
setDSRStats(dsr)
setSites(siteList || [])
setLoading(false)
}
load()
}, [])
>>>>>>> feat/zeroclaw-compliance-agent
const totalConsents = (bannerStats?.total_consents || 0) + (consentStats?.total_consents || 0)
const dsrOpen = dsrStats ? (dsrStats.by_status?.intake || 0) + (dsrStats.by_status?.processing || 0) + (dsrStats.by_status?.identity_verification || 0) : 0
const dsrOverdue = dsrStats?.overdue || 0
@@ -100,6 +123,7 @@ export default function CMPDashboardPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Consent Management Platform</h1>
<<<<<<< HEAD
<p className="text-gray-500 mt-1">Überblick über Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
</div>
<div className="flex items-center gap-3">
@@ -121,6 +145,14 @@ export default function CMPDashboardPage() {
Banner testen
</Link>
</div>
=======
<p className="text-gray-500 mt-1">Ueberblick ueber Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
</div>
<Link href="/sdk/cookie-banner/preview"
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
Banner testen
</Link>
>>>>>>> feat/zeroclaw-compliance-agent
</div>
{/* KPI Cards */}
@@ -203,6 +235,47 @@ export default function CMPDashboardPage() {
</div>
</div>
<<<<<<< HEAD
=======
{/* Banner-Bedarf Hinweis (TTDSG § 25) */}
{bannerStats && Object.keys(bannerStats.category_acceptance).length === 0 && sites.length === 0 && (
<div className="bg-green-50 border border-green-200 rounded-xl p-5 flex items-start gap-4">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
</div>
<div>
<h3 className="font-semibold text-green-800">Kein Cookie-Banner erforderlich</h3>
<p className="text-sm text-green-700 mt-1">
Es wurden keine Cookies, Tracker oder Analytics-Dienste erkannt. Gemaess TTDSG § 25 ist kein
Cookie-Banner erforderlich, da keine Informationen auf dem Endgeraet gespeichert werden.
</p>
<p className="text-xs text-green-600 mt-2">
<strong>Weiterhin Pflicht:</strong> Impressum (DDG § 5) und Datenschutzerklaerung (DSGVO Art. 13)
</p>
</div>
</div>
)}
{/* Banner-Warnung wenn Tracker ohne Banner */}
{bannerStats && Object.keys(bannerStats.category_acceptance).length > 0 && sites.length === 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-4">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<div>
<h3 className="font-semibold text-red-800">Cookie-Banner fehlt!</h3>
<p className="text-sm text-red-700 mt-1">
Es wurden Tracking-Dienste erkannt, aber kein Cookie-Banner ist konfiguriert.
Gemaess TTDSG § 25 ist eine Einwilligung erforderlich.
</p>
<Link href="/sdk/cookie-banner" className="inline-block mt-2 text-sm text-red-700 font-medium underline">
Jetzt Cookie-Banner einrichten
</Link>
</div>
</div>
)}
>>>>>>> feat/zeroclaw-compliance-agent
{/* Compliance Status */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
@@ -0,0 +1,49 @@
'use client'
import { COMPANY_PROFILE_PRESETS, type CompanyProfilePreset } from '@/lib/sdk/company-profile-presets'
interface PresetSelectorProps {
onSelect: (preset: CompanyProfilePreset) => void
onSkip: () => void
}
export function PresetSelector({ onSelect, onSkip }: PresetSelectorProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-bold text-gray-900">Welcher Unternehmenstyp passt zu Ihnen?</h2>
<p className="text-sm text-gray-500 mt-2">
Waehlen Sie eine Vorlage fuer Ihre Branche alle Felder werden vorbefuellt
und Sie koennen anschliessend anpassen.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{COMPANY_PROFILE_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => onSelect(preset)}
className="flex flex-col items-center gap-2 p-4 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:shadow-md transition-all text-center group"
>
<span className="text-3xl">{preset.icon}</span>
<span className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
{preset.label}
</span>
<span className="text-xs text-gray-500 leading-tight">
{preset.description}
</span>
</button>
))}
</div>
<div className="text-center">
<button
onClick={onSkip}
className="text-sm text-gray-400 hover:text-gray-600 underline"
>
Manuell ausfuellen (ohne Vorlage)
</button>
</div>
</div>
)
}
@@ -78,6 +78,14 @@ export default function ComplianceScopePage() {
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
// Enabled compliance modules (derived from applicable regulations)
const [enabledModules, setEnabledModules] = useState<string[]>([])
// Auto-enable all applicable regulations when they load
const handleToggleModule = (moduleId: string, enabled: boolean) => {
setEnabledModules(prev => enabled ? [...prev, moduleId] : prev.filter(id => id !== moduleId))
}
// Sync from SDK context when it becomes available (handles async loading).
// The SDK context loads state from server/localStorage asynchronously, so
// sdkState.complianceScope may arrive AFTER this page has already mounted.
@@ -159,6 +167,10 @@ export default function ComplianceScopePage() {
// Set applicable regulations from response
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
setApplicableRegulations(regs)
// Auto-enable all applicable regulations as modules
if (enabledModules.length === 0) {
setEnabledModules(regs.map(r => r.id))
}
// Derive supervisory authorities
const regIds = regs.map(r => r.id)
@@ -375,6 +387,8 @@ export default function ComplianceScopePage() {
supervisoryAuthorities={supervisoryAuthorities}
regulationAssessmentLoading={regulationAssessmentLoading}
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
enabledModules={enabledModules}
onToggleModule={handleToggleModule}
/>
)}
@@ -141,16 +141,24 @@ export default function ConsentManagementPage() {
)}
{activeTab === 'emails' && (
<EmailsTab
apiEmailTemplates={apiEmailTemplates}
templatesLoading={templatesLoading}
savingTemplateId={savingTemplateId}
savedTemplates={savedTemplates}
setShowCreateTemplateModal={setShowCreateTemplateModal}
saveApiEmailTemplate={saveApiEmailTemplate}
setPreviewTemplate={setPreviewTemplate}
setEditingTemplate={setEditingTemplate}
/>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-8 text-center">
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-xl flex items-center justify-center">
<svg className="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="font-semibold text-gray-900 mb-2">E-Mail-Templates wurden zentralisiert</h3>
<p className="text-sm text-gray-600 mb-4">
Alle E-Mail-Vorlagen (DSR, Consent, Breach, Training, etc.) werden jetzt zentral
im E-Mail-Template-Modul verwaltet mit Versionierung, Freigabe-Workflow und Audit-Log.
</p>
<button
onClick={() => router.push('/sdk/email-templates')}
className="px-6 py-2.5 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"
>
Zu E-Mail-Templates
</button>
</div>
)}
{activeTab === 'gdpr' && (
@@ -212,14 +212,14 @@ export function ControlDetail({
</section>
) : null}
{ctrl.requirements.length > 0 && (
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
</section>
)}
{ctrl.test_procedure.length > 0 && (
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
@@ -0,0 +1,203 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
interface Variant {
id: string
variant_name: string
variant_key: string
traffic_percent: number
is_control: boolean
banner_title: string | null
banner_description: string | null
position: string | null
primary_color: string | null
is_active: boolean
}
interface VariantStat {
variant_id: string
variant_key: string
variant_name: string
traffic_percent: number
is_control: boolean
total: number
accepted: number
opt_in_rate: number
is_winner?: boolean
significance?: number
}
const API = '/api/sdk/v1/compliance/banner/ab'
export function ABTestPanel({ siteConfigId }: { siteConfigId?: string }) {
const [variants, setVariants] = useState<Variant[]>([])
const [stats, setStats] = useState<VariantStat[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [newVariant, setNewVariant] = useState({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
const scid = siteConfigId || ''
const loadData = useCallback(async () => {
if (!scid) { setLoading(false); return }
setLoading(true)
try {
const [v, s] = await Promise.all([
fetch(`${API}/${scid}/variants`).then(r => r.ok ? r.json() : []),
fetch(`${API}/${scid}/stats`).then(r => r.ok ? r.json() : []),
])
setVariants(v)
setStats(s)
} catch { /* ignore */ }
setLoading(false)
}, [scid])
useEffect(() => { loadData() }, [loadData])
const handleCreate = async () => {
if (!scid || !newVariant.variant_name) return
await fetch(`${API}/${scid}/variants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newVariant),
})
setShowCreate(false)
setNewVariant({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
loadData()
}
const handleDelete = async (id: string) => {
await fetch(`${API}/variants/${id}`, { method: 'DELETE' })
loadData()
}
const handleTrafficChange = async (id: string, pct: number) => {
await fetch(`${API}/variants/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ traffic_percent: pct }),
})
loadData()
}
if (!scid) {
return <div className="text-center py-8 text-gray-400">Bitte waehlen Sie zuerst eine Site aus.</div>
}
if (loading) return <div className="text-center py-8 text-gray-400">Lade A/B-Test...</div>
const maxRate = Math.max(...stats.map(s => s.opt_in_rate), 1)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">A/B-Test Varianten</h3>
<p className="text-xs text-gray-500 mt-1">Testen Sie verschiedene Banner-Konfigurationen um die Opt-In-Rate zu optimieren.</p>
</div>
<button onClick={() => setShowCreate(!showCreate)}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Variante erstellen
</button>
</div>
{/* Create Form */}
{showCreate && (
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<input value={newVariant.variant_name} onChange={e => setNewVariant({ ...newVariant, variant_name: e.target.value })}
placeholder="Name (z.B. Variante B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<input value={newVariant.variant_key} onChange={e => setNewVariant({ ...newVariant, variant_key: e.target.value })}
placeholder="Key (z.B. B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<input value={newVariant.banner_title} onChange={e => setNewVariant({ ...newVariant, banner_title: e.target.value })}
placeholder="Banner-Titel (Override)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<input value={newVariant.primary_color} onChange={e => setNewVariant({ ...newVariant, primary_color: e.target.value })}
placeholder="Farbe (z.B. #22c55e)" type="color" className="px-3 py-2 h-10 text-sm border border-gray-200 rounded-lg" />
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-600">Traffic:</label>
<input type="range" min={5} max={95} value={newVariant.traffic_percent}
onChange={e => setNewVariant({ ...newVariant, traffic_percent: parseInt(e.target.value) })}
className="flex-1" />
<span className="text-sm font-medium w-12 text-right">{newVariant.traffic_percent}%</span>
</div>
<div className="flex gap-2">
<button onClick={handleCreate} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg">Abbrechen</button>
</div>
</div>
)}
{/* Variants + Stats */}
{variants.length === 0 ? (
<div className="text-center py-12 bg-white border border-gray-200 rounded-xl">
<p className="text-gray-400">Kein A/B-Test aktiv.</p>
<p className="text-xs text-gray-400 mt-1">Erstellen Sie mindestens 2 Varianten um einen Test zu starten.</p>
</div>
) : (
<div className="space-y-4">
{/* Comparison Chart */}
{stats.length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h4 className="font-medium text-gray-900 mb-4">Opt-In-Rate Vergleich</h4>
<div className="space-y-3">
{stats.map(s => (
<div key={s.variant_key} className="flex items-center gap-4">
<div className="w-24 text-sm text-gray-700 truncate">
{s.variant_name}
{s.is_control && <span className="ml-1 text-[10px] text-gray-400">(Kontrolle)</span>}
</div>
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden relative">
<div className={`h-full rounded-lg transition-all ${s.is_winner ? 'bg-green-500' : s.is_control ? 'bg-gray-400' : 'bg-purple-500'}`}
style={{ width: `${(s.opt_in_rate / maxRate) * 100}%` }} />
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-gray-900">
{s.opt_in_rate}% ({s.accepted}/{s.total})
</span>
</div>
{s.is_winner && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-green-100 text-green-700 rounded-full">
Gewinner ({s.significance}%)
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Variant Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{variants.map(v => (
<div key={v.id} className={`bg-white border rounded-xl p-4 ${v.is_control ? 'border-gray-300' : 'border-purple-200'}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-gray-900">{v.variant_name}</span>
<span className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded">{v.variant_key}</span>
{v.is_control && <span className="px-1.5 py-0.5 text-[10px] bg-blue-50 text-blue-600 rounded">Kontrolle</span>}
</div>
<button onClick={() => handleDelete(v.id)} className="text-xs text-red-500 hover:text-red-700">Loeschen</button>
</div>
<div className="flex items-center gap-3 mb-2">
<label className="text-xs text-gray-500">Traffic:</label>
<input type="range" min={5} max={95} value={v.traffic_percent}
onChange={e => handleTrafficChange(v.id, parseInt(e.target.value))}
className="flex-1 h-1" />
<span className="text-xs font-medium w-8 text-right">{v.traffic_percent}%</span>
</div>
{v.banner_title && <div className="text-xs text-gray-500">Titel: {v.banner_title}</div>}
{v.primary_color && (
<div className="flex items-center gap-1 mt-1">
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: v.primary_color }} />
<span className="text-xs text-gray-500">{v.primary_color}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,191 @@
'use client'
import { useState, useEffect } from 'react'
interface TimeSeriesPoint {
period: string
given: number
updated: number
withdrawn: number
total: number
opt_in_rate: number
}
interface CategoryStats {
[key: string]: { count: number; total: number; rate: number }
}
interface DeviceStats {
desktop: number
mobile: number
tablet: number
unknown: number
}
interface OverviewStats {
period_days: number
total_interactions: number
consents_given: number
consents_updated: number
consents_withdrawn: number
opt_in_rate: number
}
const PERIODS = [
{ value: 7, label: '7 Tage' },
{ value: 30, label: '30 Tage' },
{ value: 90, label: '90 Tage' },
]
const CAT_COLORS: Record<string, string> = {
necessary: '#22c55e',
statistics: '#eab308',
marketing: '#ef4444',
functional: '#3b82f6',
preferences: '#8b5cf6',
}
export function AnalyticsDashboard({ siteId }: { siteId?: string }) {
const [days, setDays] = useState(30)
const [overview, setOverview] = useState<OverviewStats | null>(null)
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([])
const [categories, setCategories] = useState<CategoryStats>({})
const [devices, setDevices] = useState<DeviceStats>({ desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
const [loading, setLoading] = useState(true)
const sid = siteId || 'preview-test-site'
useEffect(() => {
setLoading(true)
const base = `/api/sdk/v1/compliance/banner/analytics/${sid}`
Promise.all([
fetch(`${base}/overview?days=${days}`).then(r => r.ok ? r.json() : null),
fetch(`${base}/time-series?days=${days}&period=daily`).then(r => r.ok ? r.json() : []),
fetch(`${base}/categories?days=${days}`).then(r => r.ok ? r.json() : {}),
fetch(`${base}/devices?days=${days}`).then(r => r.ok ? r.json() : {}),
]).then(([o, ts, cats, devs]) => {
setOverview(o)
setTimeSeries(ts || [])
setCategories(cats || {})
setDevices(devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
}).catch(() => {}).finally(() => setLoading(false))
}, [sid, days])
const deviceTotal = devices.desktop + devices.mobile + devices.tablet + devices.unknown
if (loading) return <div className="text-center py-12 text-gray-400">Lade Analytik...</div>
return (
<div className="space-y-6">
{/* Period Selector */}
<div className="flex items-center gap-2">
{PERIODS.map(p => (
<button key={p.value} onClick={() => setDays(p.value)}
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
days === p.value ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
}`}>
{p.label}
</button>
))}
</div>
{/* Overview KPIs */}
{overview && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">Opt-In-Rate</div>
<div className="text-2xl font-bold text-green-600">{overview.opt_in_rate}%</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">Einwilligungen</div>
<div className="text-2xl font-bold text-gray-900">{overview.consents_given}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">Aktualisiert</div>
<div className="text-2xl font-bold text-blue-600">{overview.consents_updated}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">Widerrufen</div>
<div className="text-2xl font-bold text-red-600">{overview.consents_withdrawn}</div>
</div>
</div>
)}
{/* Time Series (simple bar visualization) */}
{timeSeries.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Opt-In-Rate im Zeitverlauf</h3>
<div className="flex items-end gap-1 h-32">
{timeSeries.map((pt, i) => {
const height = Math.max(pt.opt_in_rate, 2)
const date = new Date(pt.period)
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
<div className="w-full bg-purple-500 rounded-t transition-all hover:bg-purple-600"
style={{ height: `${height}%` }}
title={`${date.toLocaleDateString('de-DE')}: ${pt.opt_in_rate}% (${pt.total} Interaktionen)`}
/>
{i % Math.max(1, Math.floor(timeSeries.length / 6)) === 0 && (
<span className="text-[8px] text-gray-400">{date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
)}
</div>
)
})}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Category Acceptance */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Akzeptanz nach Kategorie</h3>
<div className="space-y-3">
{Object.entries(categories).map(([cat, stats]) => (
<div key={cat}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-700 capitalize">{cat}</span>
<span className="font-medium text-gray-900">{stats.rate}%</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${stats.rate}%`, backgroundColor: CAT_COLORS[cat] || '#9ca3af' }} />
</div>
</div>
))}
{Object.keys(categories).length === 0 && (
<p className="text-xs text-gray-400">Noch keine Daten vorhanden</p>
)}
</div>
</div>
{/* Device Breakdown */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Geraete-Verteilung</h3>
<div className="space-y-3">
{[
{ key: 'desktop', label: 'Desktop', color: 'bg-blue-500' },
{ key: 'mobile', label: 'Mobile', color: 'bg-green-500' },
{ key: 'tablet', label: 'Tablet', color: 'bg-purple-500' },
].map(d => {
const count = devices[d.key as keyof DeviceStats]
const pct = deviceTotal > 0 ? Math.round(count / deviceTotal * 100) : 0
return (
<div key={d.key}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-700">{d.label}</span>
<span className="font-medium text-gray-900">{pct}% ({count})</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${d.color}`} style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
{deviceTotal === 0 && (
<p className="text-xs text-gray-400">Noch keine Geraetedaten vorhanden</p>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,103 @@
'use client'
import { useState, useEffect } from 'react'
interface Vendor {
vendor_name: string
vendor_url: string | null
category_key: string
description_de: string | null
cookie_names: string[]
retention_days: number | null
}
const CAT_LABELS: Record<string, string> = {
necessary: 'Notwendig',
functional: 'Funktional',
statistics: 'Statistik',
marketing: 'Marketing',
}
function generateHTML(vendors: Vendor[]): string {
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
const key = v.category_key || 'other'
if (!acc[key]) acc[key] = []
acc[key].push(v)
return acc
}, {})
let html = `<div style="font-family:system-ui,sans-serif;font-size:14px;color:#1f2937;">\n`
html += `<h3 style="margin:0 0 12px;font-size:16px;">Eingesetzte Dienste und Cookies</h3>\n`
for (const [catKey, catVendors] of Object.entries(grouped)) {
const label = CAT_LABELS[catKey] || catKey
html += `<h4 style="margin:16px 0 8px;font-size:14px;color:#6b21a8;">${label}</h4>\n`
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;font-size:13px;">\n`
html += `<tr style="background:#f9fafb;"><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Anbieter</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Zweck</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Cookies</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Speicherdauer</th></tr>\n`
for (const v of catVendors) {
const name = v.vendor_url
? `<a href="${v.vendor_url}" target="_blank" rel="noopener">${v.vendor_name}</a>`
: v.vendor_name
const cookies = v.cookie_names?.join(', ') || '-'
const retention = v.retention_days ? `${v.retention_days} Tage` : '-'
html += `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;">${name}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${v.description_de || '-'}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;font-family:monospace;font-size:11px;">${cookies}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${retention}</td></tr>\n`
}
html += `</table>\n`
}
html += `</div>`
return html
}
export function EmbeddableVendorHTML({ siteId }: { siteId?: string }) {
const [vendors, setVendors] = useState<Vendor[]>([])
const [copied, setCopied] = useState(false)
useEffect(() => {
const sid = siteId || 'preview-test-site'
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
.then(r => r.ok ? r.json() : [])
.then(data => setVendors(Array.isArray(data) ? data : []))
.catch(() => {})
}, [siteId])
const html = generateHTML(vendors)
const handleCopy = () => {
navigator.clipboard.writeText(html)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">Einbettbarer HTML-Code</h3>
<p className="text-xs text-gray-500 mt-1">
Kopieren Sie diesen Code in Ihre Datenschutzerklaerung oder Cookie-Richtlinie.
</p>
</div>
<button onClick={handleCopy}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
{copied ? 'Kopiert!' : 'HTML kopieren'}
</button>
</div>
{/* Preview */}
<div className="border border-gray-200 rounded-lg p-4 bg-white">
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
{/* Raw HTML */}
<details className="group">
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
Quellcode anzeigen
</summary>
<pre className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto max-h-[300px] overflow-y-auto">
{html}
</pre>
</details>
</div>
)
}
@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
interface Site {
id: string
site_id: string
site_name: string
site_url: string
is_active: boolean
}
interface SiteSelectorProps {
sites: Site[]
activeSiteId: string | null
onSiteChange: (siteId: string) => void
onCreateSite: (data: { site_id: string; site_name: string; site_url: string }) => Promise<void>
}
export function SiteSelector({ sites, activeSiteId, onSiteChange, onCreateSite }: SiteSelectorProps) {
const [showCreate, setShowCreate] = useState(false)
const [newSite, setNewSite] = useState({ site_id: '', site_name: '', site_url: '' })
const [creating, setCreating] = useState(false)
const handleCreate = async () => {
if (!newSite.site_id || !newSite.site_name) return
setCreating(true)
try {
await onCreateSite(newSite)
setNewSite({ site_id: '', site_name: '', site_url: '' })
setShowCreate(false)
} finally {
setCreating(false)
}
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">Website / Domain</label>
<select value={activeSiteId || ''} onChange={e => onSiteChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 bg-white">
{sites.length === 0 && <option value="">Keine Sites konfiguriert</option>}
{sites.map(s => (
<option key={s.site_id} value={s.site_id}>
{s.site_name} ({s.site_url || s.site_id})
</option>
))}
</select>
</div>
<button onClick={() => setShowCreate(!showCreate)}
className="mt-5 px-3 py-2 text-sm bg-purple-50 text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-100">
+ Neue Seite
</button>
</div>
{showCreate && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-3">
<input value={newSite.site_id} onChange={e => setNewSite({ ...newSite, site_id: e.target.value })}
placeholder="Site-ID (z.B. main-website)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<input value={newSite.site_name} onChange={e => setNewSite({ ...newSite, site_name: e.target.value })}
placeholder="Name (z.B. Hauptwebsite)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<div className="flex gap-2">
<input value={newSite.site_url} onChange={e => setNewSite({ ...newSite, site_url: e.target.value })}
placeholder="URL (z.B. https://example.com)" className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<button onClick={handleCreate} disabled={creating || !newSite.site_id}
className="px-3 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{creating ? '...' : 'Anlegen'}
</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,161 @@
'use client'
import { useState, useEffect } from 'react'
interface IABPurpose {
id: number
name: string
name_de: string
}
const API = '/api/sdk/v1/compliance/tcf'
export function TCFSettings({ siteId, tcfEnabled, onToggle }: {
siteId?: string
tcfEnabled: boolean
onToggle: (enabled: boolean) => void
}) {
const [purposes, setPurposes] = useState<IABPurpose[]>([])
const [categoryMap, setCategoryMap] = useState<Record<string, number[]>>({})
const [testResult, setTestResult] = useState<string | null>(null)
const [testing, setTesting] = useState(false)
useEffect(() => {
Promise.all([
fetch(`${API}/purposes`).then(r => r.ok ? r.json() : []),
fetch(`${API}/category-mapping`).then(r => r.ok ? r.json() : {}),
]).then(([p, m]) => {
setPurposes(p)
setCategoryMap(m)
}).catch(() => {})
}, [])
const handleTestEncode = async () => {
setTesting(true)
setTestResult(null)
try {
const res = await fetch(`${API}/encode-categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categories: ['necessary', 'statistics', 'marketing'] }),
})
if (res.ok) {
const data = await res.json()
setTestResult(`TC String: ${data.tc_string}\nPurposes: ${data.purposes_consented.join(', ')}`)
}
} catch { setTestResult('Fehler beim Generieren') }
setTesting(false)
}
return (
<div className="space-y-6">
{/* Enable/Disable */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">IAB TCF 2.2</h3>
<p className="text-xs text-gray-500 mt-1">
Transparency & Consent Framework Standardisierte Einwilligungssignale fuer programmatische Werbung
</p>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" checked={tcfEnabled} onChange={e => onToggle(e.target.checked)}
className="w-5 h-5 text-purple-600 rounded" />
<span className="text-sm font-medium">{tcfEnabled ? 'Aktiv' : 'Inaktiv'}</span>
</label>
</div>
{!tcfEnabled && (
<p className="mt-3 text-xs text-amber-600 bg-amber-50 p-3 rounded-lg">
TCF ist nur erforderlich wenn Sie programmatische Werbung (AdTech) einsetzen.
Fuer die meisten Websites reicht das Standard-Cookie-Banner.
</p>
)}
</div>
{tcfEnabled && (
<>
{/* IAB Purposes */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">12 IAB-Zwecke (Purposes)</h4>
<p className="text-xs text-gray-500 mb-4">
Diese Zwecke werden automatisch aus Ihren Cookie-Kategorien abgeleitet.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{purposes.map(p => {
const activeCats = Object.entries(categoryMap)
.filter(([, pids]) => pids.includes(p.id))
.map(([cat]) => cat)
return (
<div key={p.id} className={`flex items-start gap-2 p-2 rounded-lg text-xs ${activeCats.length > 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${activeCats.length > 0 ? 'bg-green-500 text-white' : 'bg-gray-300 text-white'}`}>
{p.id}
</span>
<div>
<div className="font-medium text-gray-700">{p.name_de}</div>
{activeCats.length > 0 && (
<div className="text-gray-400 mt-0.5">via: {activeCats.join(', ')}</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Category Mapping */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">Kategorie Purpose Zuordnung</h4>
<div className="space-y-2">
{Object.entries(categoryMap).map(([cat, pids]) => (
<div key={cat} className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 w-24 capitalize">{cat}</span>
<div className="flex gap-1 flex-wrap">
{pids.length === 0 ? (
<span className="text-xs text-gray-400">Keine Einwilligung noetig</span>
) : (
pids.map(pid => (
<span key={pid} className="px-2 py-0.5 text-[10px] bg-purple-100 text-purple-700 rounded-full">
Purpose {pid}
</span>
))
)}
</div>
</div>
))}
</div>
</div>
{/* TC String Test */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">TC String testen</h4>
<button onClick={handleTestEncode} disabled={testing}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{testing ? 'Generiere...' : 'Test TC String generieren'}
</button>
{testResult && (
<pre className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap">
{testResult}
</pre>
)}
<p className="text-xs text-gray-400 mt-2">
Simuliert: necessary + statistics + marketing generiert base64url-codierten TC String
</p>
</div>
{/* CMP Registration Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h4 className="font-semibold text-blue-800 text-sm">CMP-Registrierung</h4>
<p className="text-xs text-blue-700 mt-1">
Fuer den produktiven Einsatz muss Ihr CMP bei der IAB Europe registriert werden.
Sie erhalten eine eindeutige CMP-ID die im TC String codiert wird.
</p>
<p className="text-xs text-blue-600 mt-2">
Registrierung: <a href="https://iabeurope.eu/tcf-for-cmps/" target="_blank" rel="noopener"
className="underline">iabeurope.eu/tcf-for-cmps</a>
</p>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,138 @@
'use client'
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
interface Vendor {
id: string
vendor_name: string
vendor_url: string | null
category_key: string
description_de: string | null
description_en: string | null
cookie_names: string[]
retention_days: number | null
is_active: boolean
}
const CATEGORY_LABELS: Record<string, { label: string; color: string }> = {
necessary: { label: 'Notwendig', color: 'bg-green-100 text-green-700' },
functional: { label: 'Funktional', color: 'bg-blue-100 text-blue-700' },
statistics: { label: 'Statistik', color: 'bg-yellow-100 text-yellow-700' },
marketing: { label: 'Marketing', color: 'bg-red-100 text-red-700' },
}
export function VendorTable({ siteId }: { siteId?: string }) {
const { projectId } = useSDK()
const [vendors, setVendors] = useState<Vendor[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
useEffect(() => {
const sid = siteId || 'preview-test-site'
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
.then(r => r.ok ? r.json() : [])
.then(data => setVendors(Array.isArray(data) ? data : []))
.catch(() => setVendors([]))
.finally(() => setLoading(false))
}, [siteId])
// Group by category
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
const key = v.category_key || 'other'
if (!acc[key]) acc[key] = []
acc[key].push(v)
return acc
}, {})
if (loading) {
return <div className="text-center py-12 text-gray-400">Lade Verarbeiter...</div>
}
if (vendors.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-400 mb-3">Keine Verarbeiter konfiguriert.</p>
<p className="text-xs text-gray-400">
Nutzen Sie den Website-Scanner oder fuegen Sie Verarbeiter manuell hinzu.
</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">Verarbeiter-Uebersicht</h3>
<p className="text-xs text-gray-500 mt-1">{vendors.length} Dienste in {Object.keys(grouped).length} Kategorien</p>
</div>
</div>
{Object.entries(grouped).map(([catKey, catVendors]) => {
const catInfo = CATEGORY_LABELS[catKey] || { label: catKey, color: 'bg-gray-100 text-gray-700' }
return (
<div key={catKey} className="border border-gray-200 rounded-xl overflow-hidden">
<div className="bg-gray-50 px-4 py-3 flex items-center gap-3">
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${catInfo.color}`}>
{catInfo.label}
</span>
<span className="text-xs text-gray-500">{catVendors.length} Dienste</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs text-gray-500">
<th className="px-4 py-2 font-medium">Anbieter</th>
<th className="px-4 py-2 font-medium">Zweck</th>
<th className="px-4 py-2 font-medium">Cookies</th>
<th className="px-4 py-2 font-medium">Aufbewahrung</th>
<th className="px-4 py-2 font-medium">Datenschutz</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{catVendors.map(v => (
<tr key={v.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<button onClick={() => setExpandedId(expandedId === v.id ? null : v.id)}
className="font-medium text-gray-900 hover:text-purple-600 text-left">
{v.vendor_name}
</button>
{expandedId === v.id && v.cookie_names?.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{v.cookie_names.map(c => (
<span key={c} className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded font-mono">
{c}
</span>
))}
</div>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-600 max-w-[200px] truncate">
{v.description_de || '-'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{v.cookie_names?.length || 0}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{v.retention_days ? `${v.retention_days} Tage` : '-'}
</td>
<td className="px-4 py-2.5">
{v.vendor_url ? (
<a href={v.vendor_url} target="_blank" rel="noopener noreferrer"
className="text-xs text-purple-600 hover:underline">
Link
</a>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
})}
</div>
)
}
@@ -96,13 +96,38 @@ const defaultBannerTexts: BannerTexts = {
privacyLink: '/datenschutz',
}
export interface BannerSite {
id: string
site_id: string
site_name: string
site_url: string
is_active: boolean
}
export function useCookieBanner() {
const [categories, setCategories] = useState<CookieCategory[]>([])
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
const [isSaving, setIsSaving] = useState(false)
const [exportToast, setExportToast] = useState<string | null>(null)
const [sites, setSites] = useState<BannerSite[]>([])
const [activeSiteId, setActiveSiteId] = useState<string | null>(null)
// Load sites list
React.useEffect(() => {
fetch('/api/sdk/v1/banner/admin/sites')
.then(r => r.ok ? r.json() : [])
.then(data => {
const siteList = Array.isArray(data) ? data : []
setSites(siteList)
if (siteList.length > 0 && !activeSiteId) {
setActiveSiteId(siteList[0].site_id)
}
})
.catch(() => {})
}, [])
// Load config for active site
React.useEffect(() => {
const loadConfig = async () => {
try {
@@ -125,7 +150,20 @@ export function useCookieBanner() {
}
}
loadConfig()
}, [])
}, [activeSiteId])
const createSite = async (data: { site_id: string; site_name: string; site_url: string }) => {
const res = await fetch('/api/sdk/v1/banner/admin/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
const newSite = await res.json()
setSites(prev => [...prev, newSite])
setActiveSiteId(newSite.site_id || data.site_id)
}
}
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
setCategories(prev =>
@@ -180,5 +218,6 @@ export function useCookieBanner() {
categories, config, bannerTexts, isSaving, exportToast,
setConfig, setBannerTexts,
handleCategoryToggle, handleExportCode, handleSaveConfig,
sites, activeSiteId, setActiveSiteId, createSite,
}
}
@@ -1,18 +1,28 @@
'use client'
import React from 'react'
import React, { useState } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { useCookieBanner } from './_hooks/useCookieBanner'
import { BannerPreview } from './_components/BannerPreview'
import { CategoryCard } from './_components/CategoryCard'
import { VendorTable } from './_components/VendorTable'
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
import { SiteSelector } from './_components/SiteSelector'
import { AnalyticsDashboard } from './_components/AnalyticsDashboard'
import { ABTestPanel } from './_components/ABTestPanel'
import { TCFSettings } from './_components/TCFSettings'
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest' | 'tcf'
export default function CookieBannerPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<BannerTab>('config')
const {
categories, config, bannerTexts, isSaving, exportToast,
setConfig, setBannerTexts,
handleCategoryToggle, handleExportCode, handleSaveConfig,
sites, activeSiteId, setActiveSiteId, createSite,
} = useCookieBanner()
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
@@ -57,6 +67,58 @@ export default function CookieBannerPage() {
</div>
</StepHeader>
{/* Site Selector */}
{sites.length > 0 && (
<SiteSelector sites={sites} activeSiteId={activeSiteId} onSiteChange={setActiveSiteId} onCreateSite={createSite} />
)}
{/* Tabs */}
<div className="flex border-b border-gray-200">
{([
{ id: 'config' as const, label: 'Konfiguration' },
{ id: 'vendors' as const, label: 'Verarbeiter' },
{ id: 'embed' as const, label: 'Einbettung' },
{ id: 'analytics' as const, label: 'Analytik' },
{ id: 'abtest' as const, label: 'A/B-Test' },
{ id: 'tcf' as const, label: 'TCF/IAB' },
]).map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
}`}>
{tab.label}
</button>
))}
</div>
{/* Tab: Verarbeiter */}
{activeTab === 'vendors' && <VendorTable siteId={activeSiteId || undefined} />}
{/* Tab: Einbettung */}
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
{/* Tab: Analytik */}
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
{/* Tab: A/B-Test */}
{activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />}
{/* Tab: TCF/IAB */}
{activeTab === 'tcf' && (
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={false}
onToggle={(enabled) => {
if (activeSiteId) {
fetch(`/api/sdk/v1/banner/admin/sites/${activeSiteId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tcf_enabled: enabled }),
})
}
}}
/>
)}
{/* Tab: Konfiguration */}
{activeTab !== 'config' ? null : (<>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -207,6 +269,7 @@ export default function CookieBannerPage() {
))}
</div>
</div>
</>)}
</div>
)
}
@@ -1,7 +1,9 @@
'use client'
import { useState } from 'react'
import { LegalTemplateResult } from '@/lib/sdk/types'
import { RuleEngineResult } from '../ruleEngine'
import ReviewAssignmentPanel from './ReviewAssignmentPanel'
interface GeneratorPreviewTabProps {
template: LegalTemplateResult
@@ -10,8 +12,76 @@ interface GeneratorPreviewTabProps {
missing: string[]
onCopy: () => void
onExportMarkdown: () => void
onSaveToWorkflow?: () => void
saveStatus?: string | null
}
// ============================================================================
// Lightweight Markdown → HTML (no dependency needed)
// ============================================================================
function markdownToHtml(md: string): string {
let html = md
// Escape HTML entities first
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Headings
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Horizontal rules
html = html.replace(/^---$/gm, '<hr/>')
// Bold + Italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-purple-600 underline">$1</a>')
// Tables (simple)
html = html.replace(/^\|(.+)\|$/gm, (match) => {
const cells = match.split('|').filter(c => c.trim())
const isHeader = cells.every(c => /^[\s-:]+$/.test(c))
if (isHeader) return '<!-- separator -->'
const tag = 'td'
return '<tr>' + cells.map(c => `<${tag}>${c.trim()}</${tag}>`).join('') + '</tr>'
})
// Wrap consecutive table rows
html = html.replace(/((?:<tr>.*<\/tr>\n?<!-- separator -->\n?)?(?:<tr>.*<\/tr>\n?)+)/g, (block) => {
const rows = block.split('\n').filter(r => r.startsWith('<tr>'))
if (rows.length === 0) return block
const headerRow = rows[0].replace(/<td>/g, '<th>').replace(/<\/td>/g, '</th>')
const bodyRows = rows.slice(1).join('\n')
return `<table><thead>${headerRow}</thead><tbody>${bodyRows}</tbody></table>`
})
// Remove separator comments
html = html.replace(/<!-- separator -->\n?/g, '')
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
// Paragraphs (lines that aren't already HTML)
html = html.replace(/^(?!<[a-z/]|$)(.+)$/gm, '<p>$1</p>')
// Clean up empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '')
return html
}
// ============================================================================
// Component
// ============================================================================
export default function GeneratorPreviewTab({
template,
ruleResult,
@@ -19,13 +89,20 @@ export default function GeneratorPreviewTab({
missing,
onCopy,
onExportMarkdown,
onSaveToWorkflow,
saveStatus,
}: GeneratorPreviewTabProps) {
const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview')
const htmlContent = markdownToHtml(renderedContent)
return (
<div className="space-y-4">
{/* Violations */}
{ruleResult && ruleResult.violations.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<p className="text-sm font-semibold text-red-700 mb-2">
🔴 {ruleResult.violations.length} Fehler
{ruleResult.violations.length} Fehler
</p>
<ul className="space-y-1">
{ruleResult.violations.map((v) => (
@@ -36,6 +113,8 @@ export default function GeneratorPreviewTab({
</ul>
</div>
)}
{/* Warnings */}
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<ul className="space-y-1">
@@ -43,69 +122,156 @@ export default function GeneratorPreviewTab({
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
.map((w) => (
<li key={w.id} className="text-xs text-yellow-700">
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
<span className="font-mono font-medium">[{w.id}]</span> {w.message}
</li>
))}
</ul>
</div>
)}
{/* Legal notice */}
{ruleResult && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
<p className="text-xs text-blue-700">
Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
wird eine rechtliche Überprüfung dringend empfohlen.
Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
wird eine rechtliche Ueberpruefung dringend empfohlen.
</p>
</div>
)}
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
<p className="text-xs text-gray-400">
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
</p>
)}
{/* Toolbar */}
<div className="flex items-center justify-between flex-wrap gap-2">
<span className="text-sm text-gray-600">
{missing.length > 0 && (
<span className="text-orange-600">
{missing.length} Platzhalter noch nicht ausgefüllt
</span>
)}
</span>
<div className="flex gap-2">
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
<button
onClick={onCopy}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
onClick={() => setViewMode('preview')}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
viewMode === 'preview' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Kopieren
Vorschau
</button>
<button
onClick={onExportMarkdown}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
onClick={() => setViewMode('markdown')}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
viewMode === 'markdown' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Markdown
</button>
</div>
<div className="flex items-center gap-2">
{missing.length > 0 && (
<span className="text-xs text-orange-600">
{missing.length} Platzhalter offen
</span>
)}
<button onClick={onCopy} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
Kopieren
</button>
<button onClick={onExportMarkdown} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
Markdown
</button>
<button
onClick={() => window.print()}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
onClick={() => {
const printWindow = window.open('', '_blank')
if (!printWindow) return
printWindow.document.write(`<!DOCTYPE html><html><head><title>${template.documentTitle || 'Dokument'}</title><style>
@page { size: A4; margin: 25mm 20mm; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11pt; line-height: 1.6; color: #1a202c; max-width: 170mm; margin: 0 auto; }
h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
ul { padding-left: 20pt; }
li { margin: 2pt 0; }
hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
a { color: #7c3aed; }
p { margin: 4pt 0; }
strong { font-weight: 600; }
</style></head><body>${htmlContent}</body></html>`)
printWindow.document.close()
printWindow.print()
}}
className="px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
PDF drucken
</button>
{onSaveToWorkflow && (
<button
onClick={onSaveToWorkflow}
disabled={saveStatus === 'saving'}
className={`px-4 py-1.5 text-xs rounded-lg transition-colors ${
saveStatus === 'saved' ? 'bg-green-600 text-white' :
saveStatus === 'error' ? 'bg-red-600 text-white' :
'bg-indigo-600 text-white hover:bg-indigo-700'
} disabled:opacity-50`}
>
{saveStatus === 'saving' ? 'Speichern...' :
saveStatus === 'saved' ? 'Gespeichert!' :
saveStatus === 'error' ? 'Fehler' :
'Als Version speichern'}
</button>
)}
</div>
</div>
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
{/* Content */}
{viewMode === 'markdown' ? (
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[800px] overflow-y-auto">
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-mono">
{renderedContent}
</pre>
</div>
) : (
<div className="bg-gray-100 rounded-xl p-8 flex justify-center overflow-y-auto max-h-[85vh]">
{/* A4 Page */}
<div
className="bg-white shadow-lg border border-gray-300"
style={{
width: '210mm',
minHeight: '297mm',
padding: '25mm 20mm',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: '11pt',
lineHeight: '1.6',
color: '#1a202c',
}}
>
<style>{`
.a4-content h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
.a4-content h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
.a4-content h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
.a4-content h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
.a4-content table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
.a4-content th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
.a4-content td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
.a4-content ul { padding-left: 20pt; margin: 4pt 0; }
.a4-content li { margin: 2pt 0; }
.a4-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
.a4-content a { color: #7c3aed; text-decoration: underline; }
.a4-content p { margin: 4pt 0; }
.a4-content strong { font-weight: 600; }
`}</style>
<div
className="a4-content"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
</div>
)}
{/* Review Assignment */}
<ReviewAssignmentPanel
documentType={template.templateType || ''}
documentTitle={template.documentTitle || 'Dokument'}
documentContent={renderedContent}
/>
{/* Attribution */}
{template.attributionRequired && template.attributionText && (
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
<strong>Attribution erforderlich:</strong> {template.attributionText}
@@ -38,7 +38,7 @@ export default function GeneratorSection({
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
const placeholders = template.placeholders || []
const placeholders = Array.isArray(template.placeholders) ? template.placeholders : []
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
@@ -101,6 +101,45 @@ export default function GeneratorSection({
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
const [saveStatus, setSaveStatus] = useState<string | null>(null)
const handleSaveToWorkflow = async () => {
setSaveStatus('saving')
try {
// 1. Create or find document
const docRes = await fetch('/api/sdk/v1/compliance/legal-documents/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: template.templateType || 'custom',
name: template.documentTitle || 'Dokument',
description: `Generiert aus Template: ${template.templateType}`,
}),
})
if (!docRes.ok) throw new Error('Dokument konnte nicht erstellt werden')
const doc = await docRes.json()
// 2. Create version
const verRes = await fetch('/api/sdk/v1/compliance/legal-documents/versions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
document_id: doc.id,
title: template.documentTitle || 'Dokument',
content: renderedContent,
language: template.language || 'de',
version: '1.0',
}),
})
if (!verRes.ok) throw new Error('Version konnte nicht erstellt werden')
setSaveStatus('saved')
setTimeout(() => setSaveStatus(null), 3000)
} catch (e) {
setSaveStatus('error')
setTimeout(() => setSaveStatus(null), 3000)
}
}
const handleExportMarkdown = () => {
const blob = new Blob([renderedContent], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
@@ -160,6 +199,33 @@ export default function GeneratorSection({
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => {
// Load example data for current template type
const templateType = template.templateType || ''
const lang = template.language || 'de'
const exampleFile = `/sdk/document-generator/examples/${templateType}_${lang}.json`
fetch(exampleFile)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data?.context) return
const ctx = data.context
for (const [section, fields] of Object.entries(ctx)) {
if (typeof fields === 'object' && fields) {
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
onContextChange(section as keyof TemplateContext, key, value)
}
}
}
})
.catch(() => {/* no example available */})
}}
className="px-3 py-1 text-xs bg-blue-50 text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
>
Beispieldaten
</button>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -223,6 +289,8 @@ export default function GeneratorSection({
missing={missing}
onCopy={handleCopy}
onExportMarkdown={handleExportMarkdown}
onSaveToWorkflow={handleSaveToWorkflow}
saveStatus={saveStatus}
/>
)}
</div>
@@ -0,0 +1,130 @@
'use client'
import { useMemo, useState } from 'react'
import { useSDK } from '@/lib/sdk'
import { evaluateTemplateRecommendations, type TemplateRecommendation } from '../templateRecommendations'
import { getProfileLabel } from '../scopeDefaults'
import type { LegalTemplateResult } from '@/lib/sdk/types'
import type { ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types/core-levels'
interface Props {
allTemplates: LegalTemplateResult[]
onUseTemplate: (t: LegalTemplateResult) => void
}
export default function RecommendedDocuments({ allTemplates, onUseTemplate }: Props) {
const { state } = useSDK()
const [showOptional, setShowOptional] = useState(false)
const level = state?.complianceScope?.determinedLevel as ComplianceDepthLevel | undefined
const scopeAnswers = state?.complianceScope?.answers || []
const recommendations = useMemo(() => {
if (!level) return null
return evaluateTemplateRecommendations(
scopeAnswers,
level,
(state?.companyProfile as Record<string, unknown>) || {},
)
}, [level, scopeAnswers, state?.companyProfile])
if (!level || !recommendations || recommendations.length === 0) return null
// Match recommendations to actual templates in the library
const templateMap = new Map<string, LegalTemplateResult>()
for (const t of allTemplates) {
if (t.templateType) templateMap.set(t.templateType, t)
}
const required = recommendations.filter((r) => r.requirement === 'required')
const recommended = recommendations.filter((r) => r.requirement === 'recommended')
const optional = recommendations.filter((r) => r.requirement === 'optional')
const renderCard = (rec: TemplateRecommendation) => {
const template = templateMap.get(rec.templateType)
const exists = !!template
return (
<div
key={rec.templateType}
className={`rounded-lg border p-3 text-sm ${
exists
? 'border-gray-200 bg-white hover:border-purple-300 cursor-pointer'
: 'border-dashed border-gray-300 bg-gray-50'
}`}
onClick={() => exists && template && onUseTemplate(template)}
>
<div className="font-medium text-gray-900 truncate">{rec.label}</div>
<div className="text-xs text-gray-500 mt-1">
{exists ? (
<span className="text-purple-600">Vorlage verfuegbar</span>
) : (
<span className="text-gray-400">Noch nicht erstellt</span>
)}
</div>
</div>
)
}
return (
<div className="bg-gradient-to-br from-purple-50 to-white rounded-xl border border-purple-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Empfohlene Dokumente fuer Ihr Unternehmen
</h3>
<p className="text-sm text-gray-500 mt-1">
Basierend auf Ihrem Compliance-Profil ({getProfileLabel(level)})
</p>
</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
{level}
</span>
</div>
{/* Required */}
{required.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-red-700">Pflicht</span>
<span className="text-xs text-gray-400">({required.length})</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{required.map(renderCard)}
</div>
</div>
)}
{/* Recommended */}
{recommended.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-amber-700">Empfohlen</span>
<span className="text-xs text-gray-400">({recommended.length})</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{recommended.map(renderCard)}
</div>
</div>
)}
{/* Optional (collapsed by default) */}
{optional.length > 0 && (
<div>
<button
onClick={() => setShowOptional(!showOptional)}
className="text-sm text-gray-500 hover:text-purple-600 flex items-center gap-1"
>
<span>{showOptional ? '▼' : '▶'}</span>
<span>Optional ({optional.length})</span>
</button>
{showOptional && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 mt-2">
{optional.map(renderCard)}
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,170 @@
'use client'
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
interface ReviewerInfo {
role_key: string
role_label?: string
person_name?: string | null
person_email?: string | null
is_primary?: boolean
}
interface ReviewRecord {
id: string
status: string
reviewer_role_key: string
reviewer_name: string | null
email_sent: boolean
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700',
in_review: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
}
const STATUS_LABELS: Record<string, string> = {
pending: 'Ausstehend',
in_review: 'In Pruefung',
approved: 'Freigegeben',
rejected: 'Abgelehnt',
}
export default function ReviewAssignmentPanel({
documentType,
documentTitle,
documentContent,
}: {
documentType: string
documentTitle: string
documentContent: string
}) {
const { projectId } = useSDK()
const [reviewers, setReviewers] = useState<ReviewerInfo[]>([])
const [existingReviews, setExistingReviews] = useState<ReviewRecord[]>([])
const [sending, setSending] = useState(false)
const [result, setResult] = useState<string | null>(null)
// Load reviewers for this document type
useEffect(() => {
if (!documentType) return
const qs = new URLSearchParams()
if (projectId) qs.set('project_id', projectId)
qs.set('document_type', documentType)
// Load mapping + existing reviews
Promise.all([
fetch(`/api/sdk/v1/compliance/org-roles/mapping`).then(r => r.ok ? r.json() : []),
fetch(`/api/sdk/v1/compliance/org-roles${projectId ? `?project_id=${projectId}` : ''}`).then(r => r.ok ? r.json() : []),
fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.ok ? r.json() : []),
]).then(([mappings, roles, reviews]) => {
// Filter mappings for this document type
const relevant = (mappings as Array<{ document_type: string; role_key: string; is_primary: boolean }>)
.filter(m => m.document_type === documentType)
// Enrich with role info
const enriched: ReviewerInfo[] = relevant.map(m => {
const role = (roles as Array<{ role_key: string; role_label: string; person_name: string | null; person_email: string | null }>)
.find(r => r.role_key === m.role_key)
return { ...m, role_label: role?.role_label, person_name: role?.person_name, person_email: role?.person_email }
})
setReviewers(enriched)
setExistingReviews(reviews)
}).catch(() => {})
}, [documentType, projectId])
const handleSendForReview = async () => {
setSending(true)
setResult(null)
try {
const res = await fetch('/api/sdk/v1/compliance/document-reviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
document_type: documentType,
document_title: documentTitle,
document_content: documentContent,
project_id: projectId,
review_link: window.location.href,
}),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
const reviews = await res.json()
// Send email for each review
let sentCount = 0
for (const review of reviews) {
if (review.reviewer_email) {
const sendRes = await fetch(`/api/sdk/v1/compliance/document-reviews/${review.id}/send`, { method: 'POST' })
if (sendRes.ok) sentCount++
}
}
setResult(`${reviews.length} Review(s) erstellt, ${sentCount} E-Mail(s) gesendet`)
// Refresh
const qs = new URLSearchParams({ document_type: documentType })
if (projectId) qs.set('project_id', projectId)
const updated = await fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.json())
setExistingReviews(updated)
} catch (e) {
setResult(e instanceof Error ? e.message : 'Fehler')
} finally {
setSending(false)
}
}
if (reviewers.length === 0 && existingReviews.length === 0) return null
return (
<div className="border border-purple-200 rounded-lg p-4 bg-purple-50/50 space-y-3">
<h4 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Pruefung & Freigabe
</h4>
{/* Assigned reviewers */}
{reviewers.length > 0 && (
<div className="space-y-1">
{reviewers.map(r => (
<div key={r.role_key} className="flex items-center gap-2 text-xs">
<span className="font-medium text-gray-700">{r.role_label || r.role_key}:</span>
{r.person_name ? (
<span className="text-gray-600">{r.person_name} ({r.person_email || 'keine E-Mail'})</span>
) : (
<span className="text-gray-400 italic">Nicht zugewiesen</span>
)}
</div>
))}
</div>
)}
{/* Existing reviews */}
{existingReviews.length > 0 && (
<div className="space-y-1">
{existingReviews.map(r => (
<div key={r.id} className="flex items-center gap-2">
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${STATUS_COLORS[r.status] || ''}`}>
{STATUS_LABELS[r.status] || r.status}
</span>
<span className="text-xs text-gray-600">{r.reviewer_name || r.reviewer_role_key}</span>
{r.email_sent && <span className="text-[10px] text-green-600">E-Mail gesendet</span>}
</div>
))}
</div>
)}
{/* Send for review */}
<button onClick={handleSendForReview} disabled={sending || reviewers.length === 0}
className="w-full px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors">
{sending ? 'Sende...' : 'Zur Pruefung senden'}
</button>
{result && (
<p className={`text-xs ${result.includes('Fehler') ? 'text-red-600' : 'text-green-600'}`}>{result}</p>
)}
</div>
)
}
@@ -6,6 +6,7 @@ import { TemplateContext } from './contextBridge'
export const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
{ key: 'all', label: 'Alle', types: null },
<<<<<<< HEAD
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
@@ -22,6 +23,66 @@ export const CATEGORIES: { key: string; label: string; types: string[] | null }[
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
]},
=======
// ── Nach Nutzungskontext sortiert ──────────────────────────────────────
// Jede Website / App braucht:
{ key: 'website', label: 'Website / App', types: ['privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner', 'social_media_dsi'] },
// Online-Shop / E-Commerce:
{ key: 'shop', label: 'Online-Shop', types: ['agb', 'widerruf', 'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner'] },
// SaaS / Cloud-Dienst:
{ key: 'saas', label: 'SaaS / Cloud', types: ['agb', 'dpa', 'sla', 'cloud_service_agreement', 'privacy_policy', 'terms_of_use'] },
// App / Plattform mit Nutzern:
{ key: 'platform', label: 'App / Plattform', types: ['terms_of_use', 'community_guidelines', 'privacy_policy', 'agb', 'acceptable_use', 'media_content_policy', 'copyright_policy'] },
// Vertraege mit Geschaeftspartnern:
{ key: 'contracts', label: 'Vertraege (B2B)', types: ['dpa', 'nda', 'sla', 'cloud_service_agreement', 'data_usage_clause'] },
// Drittlandtransfer:
{ key: 'third_country', label: 'Drittlandtransfer', types: ['transfer_impact_assessment', 'scc_companion'] },
// ── Interne Compliance-Dokumente ──────────────────────────────────────
// DSGVO-Kernpflichten:
{ key: 'dsgvo_core', label: 'DSGVO-Pflichten', types: ['tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa', 'pflichtenregister'] },
// Betroffenenrechte:
{ key: 'dsr', label: 'Betroffenenrechte', types: [
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
]},
// Datenschutz-Informationen (alle DSI-Typen):
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
// Einwilligungen:
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
// ── Sicherheit & IT ───────────────────────────────────────────────────
{ key: 'security_concepts', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'isms_manual'] },
{ key: 'security_policies', label: 'Sicherheitsrichtlinien', types: [
'information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy',
'cybersecurity_policy', 'incident_response_policy', 'logging_policy', 'patch_management_policy',
'vulnerability_management_policy', 'secrets_management_policy', 'devsecops_policy',
'cloud_security_policy', 'change_management_policy', 'asset_management_policy', 'backup_policy',
]},
// ── Organisation & HR ─────────────────────────────────────────────────
{ key: 'hr', label: 'HR & Mitarbeiter', types: ['applicant_dsi', 'employee_dsi', 'employee_security_policy', 'security_awareness_policy', 'remote_work_policy', 'offboarding_policy', 'byod_policy', 'ai_usage_policy', 'whistleblower_policy', 'verpflichtungserklaerung'] },
{ key: 'data_governance', label: 'Daten-Governance', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
{ key: 'vendor', label: 'Lieferanten / Vendor', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy', 'dpa'] },
{ key: 'bcm', label: 'BCM / Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy', 'incident_response_plan'] },
>>>>>>> feat/zeroclaw-compliance-agent
]
// =============================================================================
@@ -41,6 +102,8 @@ export const SECTION_LABELS: Record<keyof TemplateContext, string> = {
CONSENT: 'Cookie / Einwilligung',
HOSTING: 'Hosting-Provider',
FEATURES: 'Dokument-Features & Textbausteine',
TOM: 'TOM-Dokumentation',
DPA: 'AVV / Auftragsverarbeitung',
}
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
@@ -186,6 +249,192 @@ export const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
// ── SaaS AGB v2 ─────────────────────────────────────────────────────────
{ key: 'B2B_ONLY', label: 'Nur B2B (keine Verbraucher)', type: 'boolean' },
{ key: 'HAS_END_USERS', label: 'Endkunden-Weitergabe (B2B2C)', type: 'boolean' },
{ key: 'HAS_MODULAR_PACKAGES', label: 'Modulare Leistungspakete', type: 'boolean' },
{ key: 'HAS_STORAGE', label: 'Speicherplatz als Leistung', type: 'boolean' },
{ key: 'HAS_STORAGE_LIMITS', label: 'Speicherplatz begrenzt', type: 'boolean' },
{ key: 'HAS_TRIAL', label: 'Kostenlose Testphase', type: 'boolean' },
{ key: 'TRIAL_DAYS', label: 'Testphase (Tage)', type: 'select', opts: ['7', '14', '30'] },
{ key: 'HAS_PRICE_ADJUSTMENT', label: 'Preisanpassungsklausel', type: 'boolean' },
{ key: 'PRICE_ADJUSTMENT_NOTICE_WEEKS', label: 'Ankündigung Preisanpassung (Wo.)', type: 'select', opts: ['4', '8', '12'] },
{ key: 'PRICE_INCREASE_THRESHOLD_PERCENT', label: 'Schwelle Sonderkündigung (%)', type: 'select', opts: ['5', '10', '15'] },
{ key: 'HAS_UPLOAD', label: 'Datei-Upload Funktion', type: 'boolean' },
{ key: 'NO_AUDIT_PROOF_STORAGE', label: 'Keine revisionssichere Speicherung', type: 'boolean' },
{ key: 'HAS_API_ACCESS', label: 'API-Zugang', type: 'boolean' },
{ key: 'HAS_MAINTENANCE_ACCESS', label: 'Fernwartungszugang (On-Premise)', type: 'boolean' },
{ key: 'HAS_MAX_DOWNTIME', label: 'Max. Ausfalldauer begrenzt', type: 'boolean' },
{ key: 'MAX_DOWNTIME_DAYS', label: 'Max. Ausfalldauer (Tage)', type: 'number' },
{ key: 'HAS_IP_INDEMNIFICATION', label: 'IP-Freistellung (Schutzrechte)', type: 'boolean' },
{ key: 'LIABILITY_MULTIPLIER', label: 'Haftungsdeckel (x Jahreslizenz)', type: 'select', opts: ['1', '2', '3'] },
{ key: 'HAS_REFERENCE_MARKETING', label: 'Referenzmarketing (Logo-Nutzung)', type: 'boolean' },
{ key: 'HAS_WHITELABEL', label: 'Whitelabel-Paket vorhanden', type: 'boolean' },
{ key: 'HAS_FORCE_MAJEURE', label: 'Force-Majeure-Klausel', type: 'boolean' },
{ key: 'HAS_COMMUNITY_GUIDELINES', label: 'Community Guidelines als Bestandteil', type: 'boolean' },
// ── Community Guidelines (modular) ──────────────────────────────────────
{ key: 'TONE_FRIENDLY', label: 'Ton: Freundlich/Einladend', type: 'boolean' },
{ key: 'TONE_EDITORIAL', label: 'Ton: Editorial/Sachlich', type: 'boolean' },
{ key: 'TONE_FORMAL', label: 'Ton: Formal/Juristisch', type: 'boolean' },
{ key: 'HAS_MEDIA_UPLOADS', label: 'Plattform: Medien-Uploads (Bilder/Videos)', type: 'boolean' },
{ key: 'HAS_MESSAGING', label: 'Plattform: Messaging/Chat', type: 'boolean' },
{ key: 'HAS_MARKETPLACE', label: 'Plattform: Marketplace/Handel', type: 'boolean' },
{ key: 'DETAILED_ILLEGAL', label: '↳ Details: Rechtswidrige Inhalte', type: 'boolean' },
{ key: 'DETAILED_HATE_SPEECH', label: '↳ Details: Hassrede', type: 'boolean' },
{ key: 'DETAILED_FRAUD', label: '↳ Details: Betrug/Deepfakes', type: 'boolean' },
{ key: 'EXCEPTIONS_FRAUD', label: '↳ Ausnahmen: Parodie/Satire/Kunst', type: 'boolean' },
{ key: 'DETAILED_PRIVACY', label: '↳ Details: Sicherheit/Privatsphäre', type: 'boolean' },
{ key: 'DETAILED_VIOLENCE', label: '↳ Details: Gewalt (bei Medien-Uploads)', type: 'boolean' },
{ key: 'EXCEPTIONS_VIOLENCE', label: '↳ Ausnahmen: Kampfsport/Journalismus/Kunst', type: 'boolean' },
{ key: 'DETAILED_PORNOGRAPHY', label: '↳ Details: Pornografie (bei Medien-Uploads)', type: 'boolean' },
{ key: 'EXCEPTIONS_PORNOGRAPHY', label: '↳ Ausnahmen: Bodypainting/Stillen/Medizin', type: 'boolean' },
{ key: 'DETAILED_SELF_HARM', label: '↳ Details: Suizid/Selbstverletzung', type: 'boolean' },
{ key: 'EXCEPTIONS_SELF_HARM', label: '↳ Ausnahmen: Prävention/Journalismus', type: 'boolean' },
{ key: 'DETAILED_EXPLOITATION', label: '↳ Details: Ausbeutung/Missbrauch/CSAM', type: 'boolean' },
{ key: 'DETAILED_HARASSMENT', label: '↳ Details: Sexuelle Belästigung (bei Messaging)', type: 'boolean' },
{ key: 'DETAILED_DANGEROUS_PRODUCTS', label: '↳ Details: Gefährliche Produkte (bei Marketplace)', type: 'boolean' },
{ key: 'DETAILED_TERRORISM', label: '↳ Details: Terrorismus/Gefährliche Gruppen', type: 'boolean' },
{ key: 'DETAILED_DANGEROUS_ACTIVITIES', label: '↳ Details: Gefährdende Aktivitäten', type: 'boolean' },
{ key: 'GUIDELINES_URL', label: 'URL der Richtlinien' },
// ── Medien & Content Module ─────────────────────────────────────────────
{ key: 'IS_JOURNALISTIC_MEDIA', label: 'Journalistisches Medium (MStV §§ 18-22)', type: 'boolean' },
{ key: 'EDITORIAL_EMAIL', label: 'Redaktions-E-Mail (Gegendarstellung)', type: 'email' },
{ key: 'HAS_AI_GENERATED_CONTENT', label: 'KI-generierte Inhalte (AI Act Art. 50)', type: 'boolean' },
{ key: 'DETAILED_AI_LABELING', label: '↳ Detaillierte KI-Kennzeichnungstabelle', type: 'boolean' },
{ key: 'HAS_SPONSORED_CONTENT', label: 'Bezahlte/werbliche Inhalte (§ 5a UWG)', type: 'boolean' },
{ key: 'HAS_PRESS_COUNCIL', label: 'Pressekodex-Selbstverpflichtung (Presserat)', type: 'boolean' },
// ── Nutzungsbedingungen ─────────────────────────────────────────────────
{ key: 'HAS_UGC', label: 'User Generated Content', type: 'boolean' },
{ key: 'HAS_CONTENT_LICENSING', label: 'Content Licensing (Nutzer-zu-Nutzer)', type: 'boolean' },
{ key: 'HAS_TDM_OPTOUT', label: 'Text- und Data-Mining Opt-out', type: 'boolean' },
{ key: 'HAS_CONTENT_AUTHENTICITY', label: 'Content Authenticity (kryptogr. Herkunft)', type: 'boolean' },
{ key: 'HAS_TIPPING', label: 'Tipping/Anerkennungsfunktion', type: 'boolean' },
{ key: 'HAS_CRYPTO_PAYMENTS', label: 'Krypto-Zahlungen', type: 'boolean' },
{ key: 'HAS_INTEGRATED_WALLET', label: 'Integriertes Wallet (Non-Custodial)', type: 'boolean' },
{ key: 'HAS_IDENTITY_VERIFICATION', label: 'Identitätsprüfung erforderlich', type: 'boolean' },
{ key: 'HAS_COPYRIGHT_TAKEDOWN', label: 'Copyright Takedown-Verfahren', type: 'boolean' },
{ key: 'HAS_PAID_USER_ACCOUNTS', label: 'Kostenpflichtige Nutzeraccounts', type: 'boolean' },
{ key: 'HAS_EU_USERS', label: 'EU-weite Nutzer (Verbraucherschutz)', type: 'boolean' },
{ key: 'MFA_REQUIRED', label: 'MFA verpflichtend für Nutzer', type: 'boolean' },
{ key: 'DATA_EXPORT_BEFORE_DELETION', label: 'Datenexport vor Kontolöschung', type: 'boolean' },
{ key: 'EXPORT_BEFORE_DELETION_DAYS', label: 'Exportfrist (Tage)', type: 'select', opts: ['7', '14', '30'] },
{ key: 'MIN_AGE', label: 'Mindestalter', type: 'select', opts: ['13', '16', '18'] },
{ key: 'ALLOWS_MINORS', label: 'Minderjährige mit Eltern-Einwilligung', type: 'boolean' },
{ key: 'TIPPING_FEE_PERCENT', label: 'Tipping-Gebühr (%)', type: 'number' },
{ key: 'SUPPORTED_CURRENCIES', label: 'Unterstützte Währungen/Token' },
// ── Widerrufsbelehrung ──────────────────────────────────────────────────
{ key: 'HAS_PHYSICAL_GOODS', label: 'Physische Waren (Rücksendung)', type: 'boolean' },
{ key: 'HAS_COMBO_PACKAGE', label: 'Kombi-Paket (Hardware + Software)', type: 'boolean' },
{ key: 'HAS_DIGITAL_CONTENT', label: 'Digitale Inhalte (§ 356 Abs. 5 BGB)', type: 'boolean' },
{ key: 'HAS_SAAS_SERVICE', label: 'SaaS-Dienstleistung (§ 356 Abs. 4 BGB)', type: 'boolean' },
{ key: 'HAS_IOT_BUNDLE', label: 'Verbundenes Produkt (Hardware + App/Cloud)', type: 'boolean' },
{ key: 'IOT_SEPARATE_CONTRACTS', label: '↳ HW und Cloud getrennt widerrufbar', type: 'boolean' },
{ key: 'RETURN_ADDRESS', label: 'Rücksendeadresse (Servicecenter)' },
// ── Social Media DSI ────────────────────────────────────────────────────
{ key: 'HAS_FACEBOOK', label: 'Facebook & Instagram', type: 'boolean' },
{ key: 'HAS_YOUTUBE', label: 'YouTube', type: 'boolean' },
{ key: 'HAS_LINKEDIN', label: 'LinkedIn', type: 'boolean' },
{ key: 'HAS_TIKTOK', label: 'TikTok', type: 'boolean' },
{ key: 'HAS_X_TWITTER', label: 'X (Twitter)', type: 'boolean' },
{ key: 'HAS_META_PIXEL', label: 'Meta Pixel (Konversionsmessung)', type: 'boolean' },
{ key: 'HAS_RECRUITING_VIA_SOCIAL', label: 'Personalgewinnung über Social Media', type: 'boolean' },
{ key: 'SOCIAL_MEDIA_PLATFORMS_LIST', label: 'Plattform-Liste (Text)', type: 'textarea', span: true },
// ── DSI Erweiterungen ───────────────────────────────────────────────────
{ key: 'DSI_TITLE', label: 'Titel', type: 'select', opts: ['Datenschutzerklaerung', 'Datenschutzinformation'] },
{ key: 'SERVICE_SCOPE_DESCRIPTION', label: 'Geltungsbereich (z.B. "die App xy" / "den Online-Shop")' },
{ key: 'HAS_ONLINE_SHOP', label: 'Online-Shop Funktionen', type: 'boolean' },
{ key: 'HAS_PICKUP_STATION', label: 'Abholstationen', type: 'boolean' },
{ key: 'HAS_SUBSCRIPTION', label: 'Abonnement-Modell', type: 'boolean' },
{ key: 'HAS_PRODUCT_REVIEWS', label: 'Produktbewertungen', type: 'boolean' },
{ key: 'HAS_PARENT_COMPANY', label: 'Konzernstruktur (Mutter-/Tochtergesellschaft)', type: 'boolean' },
{ key: 'HAS_LOCATION', label: 'Standortdaten erhoben', type: 'boolean' },
{ key: 'HAS_E2E_ENCRYPTION', label: 'Ende-zu-Ende-Verschlüsselung (Messaging)', type: 'boolean' },
{ key: 'DETAILED_RIGHTS', label: 'Ausführliche Rechte-Beschreibung', type: 'boolean' },
{ key: 'PROCESSOR_LIST_URL', label: 'URL Auftragsverarbeiter-Liste' },
// ── Whistleblower ───────────────────────────────────────────────────────
{ key: 'WHISTLEBLOWER_CONTACT_NAME', label: 'Meldestelle: Ansprechperson' },
{ key: 'WHISTLEBLOWER_CONTACT_ROLE', label: 'Meldestelle: Funktion/Rolle' },
{ key: 'WHISTLEBLOWER_EMAIL', label: 'Meldestelle: E-Mail', type: 'email' },
{ key: 'WHISTLEBLOWER_PHONE', label: 'Meldestelle: Telefon' },
{ key: 'WHISTLEBLOWER_URL', label: 'Meldestelle: Online-Formular URL' },
{ key: 'HAS_ANONYMOUS_REPORTING', label: 'Anonyme Meldungen möglich', type: 'boolean' },
{ key: 'HAS_EXTERNAL_REPORTING', label: 'Externe Meldestelle (BfJ) erwähnen', type: 'boolean' },
// ── Bewerber-DSI ────────────────────────────────────────────────────────
{ key: 'HAS_VIDEO_INTERVIEW', label: 'Video-Interviews', type: 'boolean' },
{ key: 'HAS_ASSESSMENT', label: 'Assessment-Center/Tests', type: 'boolean' },
{ key: 'HAS_TALENT_POOL', label: 'Talentpool (Einwilligung)', type: 'boolean' },
{ key: 'TALENT_POOL_MONTHS', label: 'Talentpool Aufbewahrung (Monate)', type: 'select', opts: ['6', '12', '24'] },
{ key: 'HAS_RECRUITING_AGENCY', label: 'Personalvermittler', type: 'boolean' },
{ key: 'HAS_RECRUITING_SOFTWARE', label: 'Bewerbermanagement-Software', type: 'boolean' },
{ key: 'HAS_EMPLOYEE_REFERRAL', label: 'Mitarbeiterempfehlungen', type: 'boolean' },
// ── Mitarbeiter-DSI ─────────────────────────────────────────────────────
{ key: 'HAS_IT_USAGE_MONITORING', label: 'IT-Nutzungsüberwachung', type: 'boolean' },
{ key: 'HAS_COMPANY_VEHICLE', label: 'Dienstfahrzeuge/Fuhrpark', type: 'boolean' },
{ key: 'HAS_ACCESS_CONTROL', label: 'Zutrittskontrolle (Chipkarte)', type: 'boolean' },
{ key: 'HAS_VIDEO_SURVEILLANCE', label: 'Videoüberwachung (Arbeitsplatz)', type: 'boolean' },
{ key: 'HAS_COMPANY_PENSION', label: 'Betriebliche Altersvorsorge', type: 'boolean' },
{ key: 'HAS_EXTERNAL_HR_SOFTWARE', label: 'Externe HR-Software', type: 'boolean' },
{ key: 'HAS_WORKS_COUNCIL', label: 'Betriebsrat vorhanden', type: 'boolean' },
{ key: 'HAS_SPECIAL_CATEGORIES_EMPLOYEES', label: 'Besondere Datenkategorien (Gesundheit, Religion)', type: 'boolean' },
],
// ── TOM ─────────────────────────────────────────────────────────────────
TOM: [
{ key: 'ISB_NAME', label: 'IT-Sicherheitsbeauftragter' },
{ key: 'GF_NAME', label: 'Geschäftsführung' },
{ key: 'DOCUMENT_VERSION', label: 'Dokumentversion' },
{ key: 'NEXT_REVIEW_DATE', label: 'Nächste Prüfung (JJJJ-MM-TT)' },
{ key: 'HAS_MFA', label: 'Multi-Faktor-Authentifizierung aktiv', type: 'boolean' },
{ key: 'HAS_USB_LOCKED', label: 'USB-Schnittstellen physisch gesperrt', type: 'boolean' },
{ key: 'HAS_MOBILE_MEDIA', label: 'Mobile Datenträger im Einsatz', type: 'boolean' },
{ key: 'HAS_FOUR_EYES_DELETE', label: 'Vier-Augen-Prinzip für Löschungen', type: 'boolean' },
{ key: 'LOG_RETENTION_MONTHS', label: 'Log-Aufbewahrung (Monate)', type: 'select', opts: ['3', '6', '12', '24'] },
{ key: 'DIN_66399_LEVEL', label: 'Vernichtungsstufe (DIN 66399)', type: 'select', opts: ['1', '2', '3', '4', '5', '6', '7'] },
{ key: 'HAS_EXTERNAL_DESTRUCTION', label: 'Externer Vernichtungsdienstleister', type: 'boolean' },
{ key: 'HAS_PHYSICAL_TRANSPORT', label: 'Physischer Datenträgertransport', type: 'boolean' },
{ key: 'HAS_THIRD_COUNTRY_TRANSFER', label: 'Datenübermittlung in Drittländer', type: 'boolean' },
{ key: 'AVAILABILITY_TARGET', label: 'Verfügbarkeitsziel', type: 'select', opts: ['99.0', '99.5', '99.9', '99.99'] },
{ key: 'HAS_USV', label: 'USV vorhanden', type: 'boolean' },
{ key: 'HAS_REDUNDANCY', label: 'Redundante Systeme / Failover', type: 'boolean' },
{ key: 'HAS_GEO_REDUNDANCY', label: 'Georedundanter Standort', type: 'boolean' },
{ key: 'HAS_OWN_SERVER_ROOM', label: 'Eigener Serverraum', type: 'boolean' },
{ key: 'HAS_CLOUD_SERVICES', label: 'Cloud-Dienste im Einsatz', type: 'boolean' },
{ key: 'HAS_MULTI_TENANT', label: 'Multi-Tenant-System', type: 'boolean' },
{ key: 'SEPARATION_TYPE', label: 'Art der Mandantentrennung', type: 'select', opts: ['logisch', 'physisch', 'eigene Infrastruktur'] },
{ key: 'HAS_TEST_DATA_ANONYMIZED', label: 'Testdaten anonymisiert/synthetisch', type: 'boolean' },
],
// ── DPA / AVV ─────────────────────────────────────────────────────────
DPA: [
{ key: 'AG_NAME', label: 'Auftraggeber (Name/Firma)' },
{ key: 'AG_STRASSE', label: 'Auftraggeber Straße' },
{ key: 'AG_PLZ_ORT', label: 'Auftraggeber PLZ Ort' },
{ key: 'AN_NAME', label: 'Auftragnehmer (Name/Firma)' },
{ key: 'AN_STRASSE', label: 'Auftragnehmer Straße' },
{ key: 'AN_PLZ_ORT', label: 'Auftragnehmer PLZ Ort' },
{ key: 'VERARBEITUNGSGEGENSTAND', label: 'Gegenstand der Verarbeitung', type: 'textarea', span: true },
{ key: 'VERARBEITUNGSZWECK', label: 'Zweck der Verarbeitung', type: 'textarea', span: true },
{ key: 'VERARBEITUNGSARTEN', label: 'Art der Verarbeitung (Erheben, Speichern, …)', type: 'textarea', span: true },
{ key: 'DATENKATEGORIEN', label: 'Datenkategorien', type: 'textarea', span: true },
{ key: 'PERSONENKATEGORIEN', label: 'Betroffene Personenkategorien', type: 'textarea', span: true },
{ key: 'BREACH_NOTIFICATION_HOURS', label: 'Meldefrist Datenschutzverletzung (h)', type: 'select', opts: ['12', '24', '48'] },
{ key: 'INSTRUCTION_RETENTION_YEARS', label: 'Aufbewahrung Weisungen (Jahre)', type: 'select', opts: ['3', '5', '10'] },
{ key: 'SUB_PROCESSOR_NOTICE_WEEKS', label: 'Ankündigung Sub-AV (Wochen)', type: 'select', opts: ['2', '4', '6'] },
{ key: 'SUB_PROCESSOR_OBJECTION_WEEKS', label: 'Widerspruchsfrist Sub-AV (Wochen)', type: 'select', opts: ['2', '4'] },
{ key: 'DATA_EXPORT_FORMAT', label: 'Datenformat bei Rückgabe', type: 'select', opts: ['CSV/JSON', 'CSV', 'JSON', 'XML', 'nach Vereinbarung'] },
{ key: 'RETURN_CHOICE_WEEKS', label: 'Frist Rückgabe-Wahl (Wochen)', type: 'select', opts: ['2', '4', '8'] },
{ key: 'DELETION_DAYS', label: 'Löschfrist nach Vertragsende (Tage)', type: 'select', opts: ['30', '60', '90'] },
{ key: 'AN_DSB_NAME', label: 'DSB Auftragnehmer Name' },
{ key: 'AN_DSB_EMAIL', label: 'DSB Auftragnehmer E-Mail', type: 'email' },
{ key: 'VERTRAGSDATUM', label: 'Vertragsdatum (JJJJ-MM-TT)' },
{ key: 'GERICHTSSTAND', label: 'Gerichtsstand' },
{ key: 'HAS_LIABILITY_PROTECTION', label: 'Haftungsschutz bei Weisung (§ 4.1a)', type: 'boolean' },
{ key: 'HAS_SUPPORT_COST_CLAUSE', label: 'Kostenregelung Unterstützung (§ 7.4)', type: 'boolean' },
{ key: 'HAS_SUB_PROCESSOR_SILENCE_APPROVAL', label: 'Zustimmungsfiktion bei Sub-AV (§ 8.2a)', type: 'boolean' },
{ key: 'HAS_SUB_PROCESSOR_TERMINATION_RIGHT', label: 'Kündigungsrecht bei Sub-AV-Widerspruch (§ 8.3)', type: 'boolean' },
{ key: 'HAS_REACTIVATION_PERIOD', label: 'Reaktivierungszeitraum (§ 10.1)', type: 'boolean' },
{ key: 'REACTIVATION_MONTHS', label: 'Reaktivierung (Monate)', type: 'select', opts: ['1', '3', '6'] },
{ key: 'HAS_RETURN_COST_CLAUSE', label: 'Kosten für Datenrückgabe (§ 10.5)', type: 'boolean' },
{ key: 'HAS_GERICHTSSTAND_CLAUSE', label: 'Gerichtsstandklausel (§ 11.1)', type: 'boolean' },
{ key: 'HAS_UNILATERAL_CHANGE_RIGHT', label: '⚠️ Einseitiges Änderungsrecht AN (§ 11.6)', type: 'boolean' },
],
}
@@ -9,6 +9,8 @@ import type {
TemplateContext,
ProviderCtx,
ComputedFlags,
TOMCtx,
DPACtx,
} from './contextBridge'
// =============================================================================
@@ -44,6 +46,8 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
const con = ctx.CONSENT
const h = ctx.HOSTING
const f = ctx.FEATURES
const tom = ctx.TOM
const dpa = ctx.DPA
const address = providerAddress(p)
@@ -180,6 +184,86 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_TEXT),
'{{SUPPORT_CHANNELS_TEXT}}': str(f.SUPPORT_CHANNELS_TEXT),
// --- TOM ---
'{{ISB_NAME}}': str(tom.ISB_NAME),
'{{GF_NAME}}': str(tom.GF_NAME),
'{{DOCUMENT_VERSION}}': str(tom.DOCUMENT_VERSION),
'{{NEXT_REVIEW_DATE}}': str(tom.NEXT_REVIEW_DATE),
// --- DPA / AVV ---
'{{AG_NAME}}': str(dpa.AG_NAME) || str(c.LEGAL_NAME),
'{{AG_STRASSE}}': str(dpa.AG_STRASSE) || str(c.ADDRESS_LINE),
'{{AG_PLZ_ORT}}': str(dpa.AG_PLZ_ORT) || [c.POSTAL_CODE, c.CITY].filter(Boolean).join(' '),
'{{AN_NAME}}': str(dpa.AN_NAME) || str(p.LEGAL_NAME),
'{{AN_STRASSE}}': str(dpa.AN_STRASSE) || str(p.ADDRESS_LINE),
'{{AN_PLZ_ORT}}': str(dpa.AN_PLZ_ORT) || [p.POSTAL_CODE, p.CITY].filter(Boolean).join(' '),
'{{VERARBEITUNGSGEGENSTAND}}': str(dpa.VERARBEITUNGSGEGENSTAND),
'{{VERARBEITUNGSZWECK}}': str(dpa.VERARBEITUNGSZWECK),
'{{VERARBEITUNGSARTEN}}': str(dpa.VERARBEITUNGSARTEN),
'{{DATENKATEGORIEN}}': str(dpa.DATENKATEGORIEN),
'{{PERSONENKATEGORIEN}}': str(dpa.PERSONENKATEGORIEN),
'{{BREACH_NOTIFICATION_HOURS}}': str(dpa.BREACH_NOTIFICATION_HOURS) || str(sec.INCIDENT_NOTICE_HOURS),
'{{INSTRUCTION_RETENTION_YEARS}}': str(dpa.INSTRUCTION_RETENTION_YEARS),
'{{SUB_PROCESSOR_NOTICE_WEEKS}}': str(dpa.SUB_PROCESSOR_NOTICE_WEEKS),
'{{SUB_PROCESSOR_OBJECTION_WEEKS}}': str(dpa.SUB_PROCESSOR_OBJECTION_WEEKS),
'{{DATA_EXPORT_FORMAT}}': str(dpa.DATA_EXPORT_FORMAT),
'{{RETURN_CHOICE_WEEKS}}': str(dpa.RETURN_CHOICE_WEEKS),
'{{DELETION_DAYS}}': str(dpa.DELETION_DAYS),
'{{REACTIVATION_MONTHS}}': str(dpa.REACTIVATION_MONTHS),
'{{TERMINATION_WEEKS}}': str(dpa.TERMINATION_WEEKS),
'{{CHANGE_NOTICE_WEEKS}}': str(dpa.CHANGE_NOTICE_WEEKS),
'{{THIRD_COUNTRY_OBJECTION_WEEKS}}': str(dpa.THIRD_COUNTRY_OBJECTION_WEEKS),
'{{AN_DSB_NAME}}': str(dpa.AN_DSB_NAME) || str(prv.DPO_NAME),
'{{AN_DSB_EMAIL}}': str(dpa.AN_DSB_EMAIL) || str(prv.DPO_EMAIL),
'{{AG_ORT}}': str(dpa.AG_ORT),
'{{AN_ORT}}': str(dpa.AN_ORT),
'{{VERTRAGSDATUM}}': str(dpa.VERTRAGSDATUM) || str(l.VERSION_DATE),
'{{AG_UNTERZEICHNER_NAME}}': str(dpa.AG_UNTERZEICHNER_NAME),
'{{AG_UNTERZEICHNER_FUNKTION}}': str(dpa.AG_UNTERZEICHNER_FUNKTION),
'{{AN_UNTERZEICHNER_NAME}}': str(dpa.AN_UNTERZEICHNER_NAME) || str(p.CEO_NAME),
'{{AN_UNTERZEICHNER_FUNKTION}}': str(dpa.AN_UNTERZEICHNER_FUNKTION),
'{{GERICHTSSTAND}}': str(dpa.GERICHTSSTAND) || str(l.JURISDICTION_CITY),
// --- FEATURES: Whistleblower ---
'{{WHISTLEBLOWER_CONTACT_NAME}}': str(f.WHISTLEBLOWER_CONTACT_NAME),
'{{WHISTLEBLOWER_CONTACT_ROLE}}': str(f.WHISTLEBLOWER_CONTACT_ROLE),
'{{WHISTLEBLOWER_EMAIL}}': str(f.WHISTLEBLOWER_EMAIL),
'{{WHISTLEBLOWER_PHONE}}': str(f.WHISTLEBLOWER_PHONE),
'{{WHISTLEBLOWER_URL}}': str(f.WHISTLEBLOWER_URL),
// --- FEATURES: Video Conference ---
'{{VIDEO_PROVIDER_NAME}}': str(f.VIDEO_PROVIDER_NAME),
'{{VIDEO_PROVIDER_COUNTRY}}': str(f.VIDEO_PROVIDER_COUNTRY),
'{{VIDEO_PROVIDER_ROLE}}': str(f.VIDEO_PROVIDER_ROLE),
'{{VIDEO_PROVIDER_PRIVACY_URL}}': str(f.VIDEO_PROVIDER_PRIVACY_URL),
'{{RECORDING_RETENTION_DAYS}}': str(f.RECORDING_RETENTION_DAYS),
// --- FEATURES: KI/AI ---
'{{APPROVED_AI_SYSTEMS}}': str(f.APPROVED_AI_SYSTEMS),
// --- FEATURES: BYOD ---
'{{BYOD_COST_DETAILS}}': str(f.BYOD_COST_DETAILS),
// --- FEATURES: Consent ---
'{{NEWSLETTER_SIGNUP_URL}}': str(f.NEWSLETTER_SIGNUP_URL),
// --- FEATURES: Social Media ---
'{{SOCIAL_MEDIA_PLATFORMS_LIST}}': str(f.SOCIAL_MEDIA_PLATFORMS_LIST),
'{{EDITORIAL_EMAIL}}': str(f.EDITORIAL_EMAIL),
// --- FEATURES: Transfer/SCC ---
'{{RECIPIENT_NAME}}': str(f.RECIPIENT_NAME),
'{{RECIPIENT_COUNTRY}}': str(f.RECIPIENT_COUNTRY),
'{{RECIPIENT_ADDRESS}}': str(f.RECIPIENT_ADDRESS),
'{{RECIPIENT_CONTACT}}': str(f.RECIPIENT_CONTACT),
'{{RECIPIENT_EMAIL}}': str(f.RECIPIENT_EMAIL),
'{{RECIPIENT_ROLE}}': str(f.RECIPIENT_ROLE),
'{{TRANSFER_PURPOSE}}': str(f.TRANSFER_PURPOSE),
'{{TRANSFER_MECHANISM}}': str(f.TRANSFER_MECHANISM),
'{{DATA_CATEGORIES_TRANSFERRED}}': str(f.DATA_CATEGORIES_TRANSFERRED),
'{{DATA_SUBJECTS}}': str(f.DATA_SUBJECTS),
'{{TRANSFER_FREQUENCY}}': str(f.TRANSFER_FREQUENCY),
// --- FEATURES: DSI ---
'{{DSI_TITLE}}': str(f.DSI_TITLE) || 'Datenschutzerklaerung',
'{{SERVICE_SCOPE_DESCRIPTION}}': str(f.SERVICE_SCOPE_DESCRIPTION),
'{{FULFILLMENT_LOCATION}}': str(f.FULFILLMENT_LOCATION),
'{{GUIDELINES_URL}}': str(f.GUIDELINES_URL),
'{{PROCESSOR_LIST_URL}}': str(f.PROCESSOR_LIST_URL),
}
}
@@ -216,7 +300,9 @@ const SECTION_COVERS: Record<keyof TemplateContext, string[]> = {
NDA: ['{{PURPOSE}}', '{{DURATION_YEARS}}', '{{PENALTY_AMOUNT}}'],
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
HOSTING: ['{{HOSTING_PROVIDER_NAME}}', '{{HOSTING_PROVIDER_COUNTRY}}', '{{HOSTING_PROVIDER_CONTRACT_TYPE}}'],
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}'],
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}', '{{WHISTLEBLOWER_CONTACT_NAME}}', '{{WHISTLEBLOWER_EMAIL}}', '{{WHISTLEBLOWER_URL}}', '{{VIDEO_PROVIDER_NAME}}', '{{APPROVED_AI_SYSTEMS}}', '{{SOCIAL_MEDIA_PLATFORMS_LIST}}', '{{DSI_TITLE}}', '{{SERVICE_SCOPE_DESCRIPTION}}', '{{GUIDELINES_URL}}', '{{PROCESSOR_LIST_URL}}', '{{RECIPIENT_NAME}}', '{{RECIPIENT_COUNTRY}}', '{{TRANSFER_PURPOSE}}', '{{TRANSFER_MECHANISM}}'],
TOM: ['{{ISB_NAME}}', '{{GF_NAME}}', '{{DOCUMENT_VERSION}}', '{{NEXT_REVIEW_DATE}}'],
DPA: ['{{AG_NAME}}', '{{AG_STRASSE}}', '{{AG_PLZ_ORT}}', '{{AN_NAME}}', '{{AN_STRASSE}}', '{{AN_PLZ_ORT}}', '{{VERARBEITUNGSGEGENSTAND}}', '{{VERARBEITUNGSZWECK}}', '{{VERARBEITUNGSARTEN}}', '{{DATENKATEGORIEN}}', '{{PERSONENKATEGORIEN}}', '{{BREACH_NOTIFICATION_HOURS}}', '{{INSTRUCTION_RETENTION_YEARS}}', '{{SUB_PROCESSOR_NOTICE_WEEKS}}', '{{SUB_PROCESSOR_OBJECTION_WEEKS}}', '{{DATA_EXPORT_FORMAT}}', '{{RETURN_CHOICE_WEEKS}}', '{{DELETION_DAYS}}', '{{REACTIVATION_MONTHS}}', '{{TERMINATION_WEEKS}}', '{{AN_DSB_NAME}}', '{{AN_DSB_EMAIL}}', '{{AG_ORT}}', '{{AN_ORT}}', '{{VERTRAGSDATUM}}', '{{AG_UNTERZEICHNER_NAME}}', '{{AG_UNTERZEICHNER_FUNKTION}}', '{{AN_UNTERZEICHNER_NAME}}', '{{AN_UNTERZEICHNER_FUNKTION}}', '{{GERICHTSSTAND}}'],
}
/**
@@ -167,6 +167,84 @@ export interface FeaturesCtx {
SUPPORT_CHANNELS_TEXT: string
}
export interface TOMCtx {
ISB_NAME: string
GF_NAME: string
DOCUMENT_VERSION: string
NEXT_REVIEW_DATE: string
// Conditional blocks
HAS_PHYSICAL_TRANSPORT: boolean
HAS_THIRD_COUNTRY_TRANSFER: boolean
HAS_CLOUD_SERVICES: boolean
HAS_MFA: boolean
HAS_USB_LOCKED: boolean
HAS_MOBILE_MEDIA: boolean
HAS_FOUR_EYES_DELETE: boolean
HAS_EXTERNAL_DESTRUCTION: boolean
HAS_REDUNDANCY: boolean
HAS_GEO_REDUNDANCY: boolean
HAS_USV: boolean
HAS_OWN_SERVER_ROOM: boolean
HAS_MULTI_TENANT: boolean
HAS_TEST_DATA_ANONYMIZED: boolean
// Selects
LOG_RETENTION_MONTHS: number | ''
DIN_66399_LEVEL: string
AVAILABILITY_TARGET: string
SEPARATION_TYPE: string
}
export interface DPACtx {
// Parties
AG_NAME: string
AG_STRASSE: string
AG_PLZ_ORT: string
AN_NAME: string
AN_STRASSE: string
AN_PLZ_ORT: string
// Processing details
VERARBEITUNGSGEGENSTAND: string
VERARBEITUNGSZWECK: string
VERARBEITUNGSARTEN: string
DATENKATEGORIEN: string
PERSONENKATEGORIEN: string
// Timings
BREACH_NOTIFICATION_HOURS: number | ''
INSTRUCTION_RETENTION_YEARS: number | ''
SUB_PROCESSOR_NOTICE_WEEKS: number | ''
SUB_PROCESSOR_OBJECTION_WEEKS: number | ''
RETURN_CHOICE_WEEKS: number | ''
DELETION_DAYS: number | ''
REACTIVATION_MONTHS: number | ''
TERMINATION_WEEKS: number | ''
CHANGE_NOTICE_WEEKS: number | ''
THIRD_COUNTRY_OBJECTION_WEEKS: number | ''
// Data return
DATA_EXPORT_FORMAT: string
// DSB
AN_DSB_NAME: string
AN_DSB_EMAIL: string
// Signatures
AG_ORT: string
AN_ORT: string
VERTRAGSDATUM: string
AG_UNTERZEICHNER_NAME: string
AG_UNTERZEICHNER_FUNKTION: string
AN_UNTERZEICHNER_NAME: string
AN_UNTERZEICHNER_FUNKTION: string
GERICHTSSTAND: string
// Optional clauses
HAS_LIABILITY_PROTECTION: boolean
HAS_SUPPORT_COST_CLAUSE: boolean
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: boolean
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: boolean
HAS_REACTIVATION_PERIOD: boolean
HAS_RETURN_COST_CLAUSE: boolean
HAS_GERICHTSSTAND_CLAUSE: boolean
HAS_UNILATERAL_CHANGE_RIGHT: boolean
HAS_THIRD_COUNTRY_OBJECTION: boolean
}
export interface TemplateContext {
PROVIDER: ProviderCtx
CUSTOMER: CustomerCtx
@@ -180,6 +258,8 @@ export interface TemplateContext {
CONSENT: ConsentCtx
HOSTING: HostingCtx
FEATURES: FeaturesCtx
TOM: TOMCtx
DPA: DPACtx
}
export interface ComputedFlags {
@@ -263,6 +343,37 @@ export const EMPTY_CONTEXT: TemplateContext = {
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_TEXT: '',
SUPPORT_CHANNELS_TEXT: '',
},
TOM: {
ISB_NAME: '', GF_NAME: '', DOCUMENT_VERSION: '1.0.0', NEXT_REVIEW_DATE: '',
HAS_PHYSICAL_TRANSPORT: false, HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: false, HAS_MFA: true, HAS_USB_LOCKED: false,
HAS_MOBILE_MEDIA: false, HAS_FOUR_EYES_DELETE: false,
HAS_EXTERNAL_DESTRUCTION: false, HAS_REDUNDANCY: false,
HAS_GEO_REDUNDANCY: false, HAS_USV: true, HAS_OWN_SERVER_ROOM: false,
HAS_MULTI_TENANT: false, HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 6, DIN_66399_LEVEL: '3',
AVAILABILITY_TARGET: '99.5', SEPARATION_TYPE: 'logisch',
},
DPA: {
AG_NAME: '', AG_STRASSE: '', AG_PLZ_ORT: '',
AN_NAME: '', AN_STRASSE: '', AN_PLZ_ORT: '',
VERARBEITUNGSGEGENSTAND: '', VERARBEITUNGSZWECK: '', VERARBEITUNGSARTEN: '',
DATENKATEGORIEN: '', PERSONENKATEGORIEN: '',
BREACH_NOTIFICATION_HOURS: 24, INSTRUCTION_RETENTION_YEARS: 3,
SUB_PROCESSOR_NOTICE_WEEKS: 2, SUB_PROCESSOR_OBJECTION_WEEKS: 2,
RETURN_CHOICE_WEEKS: 4, DELETION_DAYS: 90, REACTIVATION_MONTHS: 3,
TERMINATION_WEEKS: 4, CHANGE_NOTICE_WEEKS: 4, THIRD_COUNTRY_OBJECTION_WEEKS: 3,
DATA_EXPORT_FORMAT: 'CSV/JSON', AN_DSB_NAME: '', AN_DSB_EMAIL: '',
AG_ORT: '', AN_ORT: '', VERTRAGSDATUM: '',
AG_UNTERZEICHNER_NAME: '', AG_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
AN_UNTERZEICHNER_NAME: '', AN_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
GERICHTSSTAND: '',
HAS_LIABILITY_PROTECTION: false, HAS_SUPPORT_COST_CLAUSE: false,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true, HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
HAS_REACTIVATION_PERIOD: true, HAS_RETURN_COST_CLAUSE: false,
HAS_GERICHTSSTAND_CLAUSE: true, HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
}
// =============================================================================
@@ -0,0 +1,14 @@
{
"document_type": "ai_usage_policy",
"language": "de",
"context": {
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
"FEATURES": {
"APPROVED_AI_SYSTEMS": "ChatGPT (OpenAI), GitHub Copilot, DeepL Pro",
"HAS_APPROVED_AI_LIST": true,
"HAS_AI_LABELING_INTERNAL": true,
"HAS_TDM_OPTOUT": true
},
"TOM": { "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2026-11-01" }
}
}
@@ -0,0 +1,36 @@
{
"document_type": "dpa",
"language": "de",
"context": {
"DPA": {
"AG_NAME": "Muster GmbH",
"AG_STRASSE": "Musterstrasse 1",
"AG_PLZ_ORT": "10115 Berlin",
"AN_NAME": "BreakPilot GmbH",
"AN_STRASSE": "Hardtring 6",
"AN_PLZ_ORT": "78224 Singen",
"VERARBEITUNGSGEGENSTAND": "Bereitstellung und Betrieb einer SaaS-Compliance-Plattform",
"VERARBEITUNGSZWECK": "Compliance-Management, Dokumentengenerierung, Risikobewertung",
"VERARBEITUNGSARTEN": "Erheben, Speichern, Veraendern, Auslesen, Abfragen, Uebermitteln, Loeschen",
"DATENKATEGORIEN": "Stammdaten, Kontaktdaten, Vertragsdaten, Nutzungsdaten, Kommunikationsdaten",
"PERSONENKATEGORIEN": "Mitarbeitende des Auftraggebers, Kunden des Auftraggebers, Ansprechpartner",
"BREACH_NOTIFICATION_HOURS": 24,
"INSTRUCTION_RETENTION_YEARS": 3,
"SUB_PROCESSOR_NOTICE_WEEKS": 4,
"SUB_PROCESSOR_OBJECTION_WEEKS": 2,
"DATA_EXPORT_FORMAT": "CSV/JSON",
"RETURN_CHOICE_WEEKS": 4,
"DELETION_DAYS": 90,
"AN_DSB_NAME": "Max Mustermann",
"AN_DSB_EMAIL": "datenschutz@breakpilot.ai",
"VERTRAGSDATUM": "2026-05-01",
"AG_ORT": "Berlin",
"AN_ORT": "Singen",
"AG_UNTERZEICHNER_NAME": "Anna Beispiel",
"AG_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrerin",
"AN_UNTERZEICHNER_NAME": "Benjamin Boenisch",
"AN_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrer",
"GERICHTSSTAND": "Singen"
}
}
}
@@ -0,0 +1,33 @@
{
"document_type": "employee_dsi",
"language": "de",
"context": {
"PROVIDER": {
"LEGAL_NAME": "Muster GmbH",
"LEGAL_FORM": "GmbH",
"ADDRESS_LINE": "Musterstrasse 1",
"POSTAL_CODE": "10115",
"CITY": "Berlin",
"COUNTRY": "DE",
"EMAIL": "info@muster.de",
"PHONE": "+49 30 123456"
},
"PRIVACY": {
"DPO_NAME": "Dr. Datenschutz",
"DPO_EMAIL": "dsb@muster.de",
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz"
},
"FEATURES": {
"HAS_IT_USAGE_MONITORING": true,
"HAS_COMPANY_VEHICLE": false,
"HAS_ACCESS_CONTROL": true,
"HAS_VIDEO_SURVEILLANCE": false,
"HAS_COMPANY_PENSION": true,
"HAS_EXTERNAL_HR_SOFTWARE": true,
"HAS_WORKS_COUNCIL": false,
"HAS_SPECIAL_CATEGORIES_EMPLOYEES": true,
"DATA_SUBJECT_REQUEST_CHANNEL": "per E-Mail an dsb@muster.de"
},
"SECURITY": { "LOG_RETENTION_DAYS": 90 }
}
}
@@ -0,0 +1,27 @@
{
"document_type": "social_media_dsi",
"language": "de",
"context": {
"PROVIDER": {
"LEGAL_NAME": "Muster GmbH",
"WEBSITE_URL": "https://www.muster.de",
"EMAIL": "info@muster.de",
"PHONE": "+49 30 123456"
},
"PRIVACY": {
"DPO_EMAIL": "dsb@muster.de",
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz",
"SUPERVISORY_AUTHORITY_ADDRESS": "Friedrichstr. 219, 10969 Berlin"
},
"FEATURES": {
"HAS_FACEBOOK": true,
"HAS_YOUTUBE": true,
"HAS_LINKEDIN": true,
"HAS_TIKTOK": false,
"HAS_X_TWITTER": false,
"HAS_META_PIXEL": true,
"HAS_RECRUITING_VIA_SOCIAL": true,
"SOCIAL_MEDIA_PLATFORMS_LIST": "Facebook, Instagram, YouTube und LinkedIn"
}
}
}
@@ -0,0 +1,19 @@
{
"document_type": "transfer_impact_assessment",
"language": "de",
"context": {
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
"PRIVACY": { "DPO_NAME": "Dr. Datenschutz", "DPO_EMAIL": "dsb@muster.de" },
"FEATURES": {
"RECIPIENT_NAME": "Cloud Provider Inc.",
"RECIPIENT_COUNTRY": "US",
"RECIPIENT_ROLE": "Auftragsverarbeiter",
"TRANSFER_PURPOSE": "Hosting der Anwendungsdaten",
"TRANSFER_MECHANISM": "EU-Standardvertragsklauseln (SCC) + EU-US DPF",
"DATA_CATEGORIES_TRANSFERRED": "Stammdaten, Kontaktdaten, Nutzungsdaten",
"DATA_SUBJECTS": "Kunden, Nutzer der Plattform",
"TRANSFER_FREQUENCY": "Kontinuierlich (Echtzeit-Datenverarbeitung)"
},
"TOM": { "GF_NAME": "Max Geschaeftsfuehrer", "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2027-05-01" }
}
}
@@ -0,0 +1,30 @@
{
"document_type": "tom_documentation",
"language": "de",
"context": {
"TOM": {
"ISB_NAME": "Thomas Sicher",
"GF_NAME": "Benjamin Boenisch",
"DOCUMENT_VERSION": "2.0.0",
"NEXT_REVIEW_DATE": "2027-05-01",
"HAS_MFA": true,
"HAS_USB_LOCKED": false,
"HAS_MOBILE_MEDIA": false,
"HAS_FOUR_EYES_DELETE": true,
"HAS_EXTERNAL_DESTRUCTION": true,
"HAS_PHYSICAL_TRANSPORT": false,
"HAS_THIRD_COUNTRY_TRANSFER": false,
"HAS_CLOUD_SERVICES": true,
"HAS_REDUNDANCY": true,
"HAS_GEO_REDUNDANCY": false,
"HAS_USV": true,
"HAS_OWN_SERVER_ROOM": true,
"HAS_MULTI_TENANT": true,
"HAS_TEST_DATA_ANONYMIZED": true,
"LOG_RETENTION_MONTHS": 12,
"DIN_66399_LEVEL": "4",
"AVAILABILITY_TARGET": "99.9",
"SEPARATION_TYPE": "logisch"
}
}
}
@@ -0,0 +1,18 @@
{
"document_type": "whistleblower_policy",
"language": "de",
"context": {
"PROVIDER": {
"LEGAL_NAME": "Muster GmbH"
},
"FEATURES": {
"WHISTLEBLOWER_CONTACT_NAME": "Dr. Maria Compliance",
"WHISTLEBLOWER_CONTACT_ROLE": "Compliance-Beauftragte / Meldestellenbeauftragte",
"WHISTLEBLOWER_EMAIL": "meldestelle@muster.de",
"WHISTLEBLOWER_PHONE": "+49 123 456789",
"WHISTLEBLOWER_URL": "https://muster.de/meldestelle",
"HAS_ANONYMOUS_REPORTING": true,
"HAS_EXTERNAL_REPORTING": true
}
}
}
@@ -11,8 +11,10 @@ import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-
import { loadAllTemplates } from './searchTemplates'
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
import { CATEGORIES } from './_constants'
import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
import TemplateLibrary from './_components/TemplateLibrary'
import GeneratorSection from './_components/GeneratorSection'
import RecommendedDocuments from './_components/RecommendedDocuments'
function DocumentGeneratorPageInner() {
const { state } = useSDK()
@@ -86,6 +88,7 @@ function DocumentGeneratorPageInner() {
}
}, [state?.companyProfile])
<<<<<<< HEAD
// ── MODULE WIRING: CookieBanner → CONSENT + FEATURES ─────────────────────
useEffect(() => {
const banner = state?.cookieBanner
@@ -157,6 +160,20 @@ function DocumentGeneratorPageInner() {
},
}))
}, [state?.useCases])
=======
// Pre-fill TOM/DPA context from Compliance Scope Engine
useEffect(() => {
const scopeLevel = state?.complianceScope?.determinedLevel
if (scopeLevel) {
const defaults = getGeneratorDefaults(scopeLevel, state?.companyProfile as never)
setContext((prev) => ({
...prev,
TOM: { ...prev.TOM, ...defaults.tom },
DPA: { ...prev.DPA, ...defaults.dpa },
}))
}
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
>>>>>>> feat/zeroclaw-compliance-agent
// Pre-fill extra placeholders from Einwilligungen data points
useEffect(() => {
@@ -249,6 +266,12 @@ function DocumentGeneratorPageInner() {
</div>
</div>
{/* Recommended documents based on scope profile */}
<RecommendedDocuments
allTemplates={allTemplates}
onUseTemplate={handleUseTemplate}
/>
<TemplateLibrary
allTemplates={allTemplates}
filteredTemplates={filteredTemplates}
@@ -0,0 +1,320 @@
/**
* Scope-basierte Generator-Defaults
*
* Nimmt ScopeDecision.determinedLevel + CompanyProfile und liefert
* vorausgefuellte TOM/DPA-Context-Werte. Alle Felder bleiben vom
* Kunden aenderbar die Defaults sind Empfehlungen.
*
* Mapping:
* L1 = Lean Startup (10 MA, Cloud-only, Home Office)
* L2 = KMU Standard (11-249 MA)
* L3 = Erweitert (risikoreich oder >100 MA)
* L4 = Zertifizierungsbereit (250 MA oder regulierte Branche)
*/
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
import type { CompanyProfile } from '../../lib/sdk/types'
import type { TOMCtx, DPACtx } from './contextBridge'
// ============================================================================
// TOM Defaults per Level
// ============================================================================
const TOM_DEFAULTS: Record<ComplianceDepthLevel, Partial<TOMCtx>> = {
L1: {
// Lean Startup: Cloud-only, kein eigener Serverraum, Home Office
HAS_MFA: true,
HAS_USB_LOCKED: false,
HAS_MOBILE_MEDIA: false,
HAS_FOUR_EYES_DELETE: false,
HAS_EXTERNAL_DESTRUCTION: false,
HAS_PHYSICAL_TRANSPORT: false,
HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: true,
HAS_REDUNDANCY: false,
HAS_GEO_REDUNDANCY: false,
HAS_USV: false,
HAS_OWN_SERVER_ROOM: false,
HAS_MULTI_TENANT: false,
HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 3,
DIN_66399_LEVEL: '3',
AVAILABILITY_TARGET: '99.0',
SEPARATION_TYPE: 'logisch',
},
L2: {
// KMU Standard
HAS_MFA: true,
HAS_USB_LOCKED: false,
HAS_MOBILE_MEDIA: false,
HAS_FOUR_EYES_DELETE: false,
HAS_EXTERNAL_DESTRUCTION: false,
HAS_PHYSICAL_TRANSPORT: false,
HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: true,
HAS_REDUNDANCY: false,
HAS_GEO_REDUNDANCY: false,
HAS_USV: false,
HAS_OWN_SERVER_ROOM: false,
HAS_MULTI_TENANT: false,
HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 6,
DIN_66399_LEVEL: '3',
AVAILABILITY_TARGET: '99.5',
SEPARATION_TYPE: 'logisch',
},
L3: {
// Erweitert
HAS_MFA: true,
HAS_USB_LOCKED: false,
HAS_MOBILE_MEDIA: false,
HAS_FOUR_EYES_DELETE: true,
HAS_EXTERNAL_DESTRUCTION: true,
HAS_PHYSICAL_TRANSPORT: false,
HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: true,
HAS_REDUNDANCY: true,
HAS_GEO_REDUNDANCY: false,
HAS_USV: true,
HAS_OWN_SERVER_ROOM: true,
HAS_MULTI_TENANT: true,
HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 12,
DIN_66399_LEVEL: '4',
AVAILABILITY_TARGET: '99.9',
SEPARATION_TYPE: 'logisch',
},
L4: {
// Zertifizierungsbereit / Enterprise
HAS_MFA: true,
HAS_USB_LOCKED: true,
HAS_MOBILE_MEDIA: false,
HAS_FOUR_EYES_DELETE: true,
HAS_EXTERNAL_DESTRUCTION: true,
HAS_PHYSICAL_TRANSPORT: false,
HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: true,
HAS_REDUNDANCY: true,
HAS_GEO_REDUNDANCY: true,
HAS_USV: true,
HAS_OWN_SERVER_ROOM: true,
HAS_MULTI_TENANT: true,
HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 24,
DIN_66399_LEVEL: '5',
AVAILABILITY_TARGET: '99.99',
SEPARATION_TYPE: 'logisch',
},
}
// ============================================================================
// DPA Defaults per Level
// ============================================================================
const DPA_DEFAULTS: Record<ComplianceDepthLevel, Partial<DPACtx>> = {
L1: {
BREACH_NOTIFICATION_HOURS: 48,
INSTRUCTION_RETENTION_YEARS: 3,
SUB_PROCESSOR_NOTICE_WEEKS: 2,
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
DATA_EXPORT_FORMAT: 'CSV/JSON',
RETURN_CHOICE_WEEKS: 4,
DELETION_DAYS: 90,
HAS_LIABILITY_PROTECTION: false,
HAS_SUPPORT_COST_CLAUSE: false,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
HAS_REACTIVATION_PERIOD: true,
REACTIVATION_MONTHS: 3,
HAS_RETURN_COST_CLAUSE: false,
HAS_GERICHTSSTAND_CLAUSE: false,
HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
L2: {
BREACH_NOTIFICATION_HOURS: 24,
INSTRUCTION_RETENTION_YEARS: 3,
SUB_PROCESSOR_NOTICE_WEEKS: 4,
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
DATA_EXPORT_FORMAT: 'CSV/JSON',
RETURN_CHOICE_WEEKS: 4,
DELETION_DAYS: 90,
HAS_LIABILITY_PROTECTION: false,
HAS_SUPPORT_COST_CLAUSE: false,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
HAS_REACTIVATION_PERIOD: true,
REACTIVATION_MONTHS: 3,
HAS_RETURN_COST_CLAUSE: false,
HAS_GERICHTSSTAND_CLAUSE: true,
HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
L3: {
BREACH_NOTIFICATION_HOURS: 24,
INSTRUCTION_RETENTION_YEARS: 5,
SUB_PROCESSOR_NOTICE_WEEKS: 4,
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
DATA_EXPORT_FORMAT: 'CSV/JSON',
RETURN_CHOICE_WEEKS: 4,
DELETION_DAYS: 60,
HAS_LIABILITY_PROTECTION: true,
HAS_SUPPORT_COST_CLAUSE: true,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
HAS_REACTIVATION_PERIOD: true,
REACTIVATION_MONTHS: 3,
HAS_RETURN_COST_CLAUSE: true,
HAS_GERICHTSSTAND_CLAUSE: true,
HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
L4: {
BREACH_NOTIFICATION_HOURS: 12,
INSTRUCTION_RETENTION_YEARS: 5,
SUB_PROCESSOR_NOTICE_WEEKS: 6,
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
DATA_EXPORT_FORMAT: 'CSV/JSON',
RETURN_CHOICE_WEEKS: 8,
DELETION_DAYS: 30,
HAS_LIABILITY_PROTECTION: true,
HAS_SUPPORT_COST_CLAUSE: true,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: false,
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
HAS_REACTIVATION_PERIOD: false,
REACTIVATION_MONTHS: 3,
HAS_RETURN_COST_CLAUSE: true,
HAS_GERICHTSSTAND_CLAUSE: true,
HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
}
// ============================================================================
// Public API
// ============================================================================
export interface GeneratorDefaults {
tom: Partial<TOMCtx>
dpa: Partial<DPACtx>
/** Which fields were set by the scope engine (for UI highlighting) */
scopeSet: Set<string>
}
/**
* Berechnet Generator-Defaults basierend auf dem Compliance-Level
* und dem CompanyProfile. Alle Werte sind Vorschlaege der Kunde
* kann sie aendern.
*/
export function getGeneratorDefaults(
level: ComplianceDepthLevel,
profile?: CompanyProfile | null,
): GeneratorDefaults {
const tomBase = { ...TOM_DEFAULTS[level] }
const dpaBase = { ...DPA_DEFAULTS[level] }
const scopeSet = new Set<string>()
// CompanyProfile-Felder in TOM/DPA uebernehmen
if (profile) {
if (profile.company_name) {
dpaBase.AN_NAME = profile.company_name
scopeSet.add('DPA.AN_NAME')
}
if (profile.address) {
dpaBase.AN_STRASSE = profile.address
scopeSet.add('DPA.AN_STRASSE')
}
if (profile.city && profile.postal_code) {
dpaBase.AN_PLZ_ORT = `${profile.postal_code} ${profile.city}`
scopeSet.add('DPA.AN_PLZ_ORT')
}
if (profile.dpo_name) {
tomBase.ISB_NAME = tomBase.ISB_NAME || ''
dpaBase.AN_DSB_NAME = profile.dpo_name
scopeSet.add('DPA.AN_DSB_NAME')
}
if (profile.dpo_email) {
dpaBase.AN_DSB_EMAIL = profile.dpo_email
scopeSet.add('DPA.AN_DSB_EMAIL')
}
if (profile.ceo_name) {
dpaBase.AN_UNTERZEICHNER_NAME = profile.ceo_name
tomBase.GF_NAME = profile.ceo_name
scopeSet.add('DPA.AN_UNTERZEICHNER_NAME')
scopeSet.add('TOM.GF_NAME')
}
}
// Alle gesetzten TOM/DPA Felder als scope-set markieren
for (const key of Object.keys(tomBase)) {
scopeSet.add(`TOM.${key}`)
}
for (const key of Object.keys(dpaBase)) {
scopeSet.add(`DPA.${key}`)
}
return { tom: tomBase, dpa: dpaBase, scopeSet }
}
/**
* Gibt das empfohlene Profil-Label zurueck (fuer UI-Anzeige).
*/
export function getProfileLabel(level: ComplianceDepthLevel): string {
const labels: Record<ComplianceDepthLevel, string> = {
L1: 'Startup / Kleinstunternehmen',
L2: 'KMU Standard',
L3: 'Erweiterte Compliance',
L4: 'Zertifizierungsbereit / Enterprise',
}
return labels[level]
}
/**
* Empfiehlt relevante Dokumenttypen basierend auf dem Compliance-Level.
* Hilft dem Kunden zu verstehen, welche Dokumente er braucht.
*/
export function getRecommendedDocuments(level: ComplianceDepthLevel): {
required: string[]
recommended: string[]
optional: string[]
} {
const always = [
'privacy_policy', 'impressum', 'agb', 'cookie_banner', 'cookie_policy',
]
const l2plus = [
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept',
'community_guidelines', 'terms_of_use',
]
const l3plus = [
'it_security_concept', 'data_protection_concept', 'incident_response_plan',
'access_control_concept', 'backup_recovery_concept', 'logging_concept',
'risk_management_concept', 'pflichtenregister',
'password_policy', 'encryption_policy', 'information_security_policy',
'access_control_policy', 'whistleblower_policy',
'employee_dsi', 'applicant_dsi', 'ai_usage_policy',
]
const l4only = [
'isms_manual', 'cybersecurity_policy', 'byod_policy',
'dsfa', 'social_media_dsi', 'media_content_policy',
'video_conference_dsi', 'consent_texts',
'data_protection_policy', 'data_classification_policy',
'data_retention_policy', 'data_transfer_policy',
'privacy_incident_policy', 'employee_security_policy',
'security_awareness_policy', 'remote_work_policy',
'offboarding_policy', 'vendor_risk_management_policy',
'third_party_security_policy', 'supplier_security_policy',
'business_continuity_policy', 'disaster_recovery_policy',
'crisis_management_policy',
]
switch (level) {
case 'L1':
return { required: always, recommended: [], optional: l2plus }
case 'L2':
return { required: always, recommended: l2plus, optional: l3plus }
case 'L3':
return { required: [...always, ...l2plus], recommended: l3plus, optional: l4only }
case 'L4':
return { required: [...always, ...l2plus, ...l3plus], recommended: l4only, optional: [] }
}
}
@@ -0,0 +1,326 @@
/**
* Template Recommendations Maps scope answers to document templates
*
* Bridges the gap between the Compliance Scope Engine (23 ScopeDocumentTypes)
* and the Document Generator (70+ database templates).
*
* The scope engine recommends high-level document categories (vvt, tom, dsfa...).
* This module recommends SPECIFIC templates based on additional context from
* the CompanyProfile and scope answers.
*/
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
import type { ScopeProfilingAnswer } from '../../lib/sdk/compliance-scope-types/state'
// ============================================================================
// Template recommendation rules
// ============================================================================
interface TemplateRule {
/** Database document_type */
templateType: string
/** Human-readable label */
label: string
/** When to recommend this template */
condition: (answers: Map<string, string>, level: ComplianceDepthLevel, profile: Record<string, unknown>) => 'required' | 'recommended' | 'optional' | null
}
/**
* Rules that map scope answers + profile to specific template recommendations.
* These cover templates NOT directly output by the scope engine.
*/
const TEMPLATE_RULES: TemplateRule[] = [
// ── HR-DSI ──────────────────────────────────────────────────────────────
{
templateType: 'employee_dsi',
label: 'Mitarbeiter-Datenschutzinformation',
condition: (answers, level) => {
const hasEmployees = answers.get('org_has_employees')
const empCount = answers.get('org_employee_count')
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
return level >= 'L2' ? 'required' : 'recommended'
}
return null
},
},
{
templateType: 'applicant_dsi',
label: 'Bewerber-Datenschutzinformation',
condition: (answers, level) => {
const hasEmployees = answers.get('org_has_employees')
const empCount = answers.get('org_employee_count')
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
return level >= 'L2' ? 'recommended' : 'optional'
}
return null
},
},
// ── Whistleblower ───────────────────────────────────────────────────────
{
templateType: 'whistleblower_policy',
label: 'Hinweisgeberrichtlinie (HinSchG)',
condition: (answers) => {
const empCount = answers.get('org_employee_count')
// HinSchG Pflicht ab 50 MA
if (empCount === '50_249' || empCount === '250_999' || empCount === '1000_plus') return 'required'
return null
},
},
// ── KI ──────────────────────────────────────────────────────────────────
{
templateType: 'ai_usage_policy',
label: 'KI-Nutzungsrichtlinie',
condition: (answers) => {
const aiUsage = answers.get('proc_ai_usage') || answers.get('proc_uses_ai_tools')
if (aiUsage && aiUsage !== 'none' && aiUsage !== 'no') return 'required'
return null
},
},
// ── BYOD ────────────────────────────────────────────────────────────────
{
templateType: 'byod_policy',
label: 'BYOD-Richtlinie',
condition: (answers, level) => {
const byod = answers.get('proc_byod_allowed')
if (byod === 'yes') return 'required'
if (level >= 'L3') return 'recommended'
return 'optional'
},
},
// ── Social Media ────────────────────────────────────────────────────────
{
templateType: 'social_media_dsi',
label: 'Social-Media-Datenschutzinformation',
condition: (answers, level) => {
const sm = answers.get('org_has_social_media')
if (sm === 'yes') return 'required'
return level >= 'L2' ? 'recommended' : 'optional'
},
},
// ── Videokonferenzen ────────────────────────────────────────────────────
{
templateType: 'video_conference_dsi',
label: 'Videokonferenz-Datenschutzinformation',
condition: (answers, level) => {
const video = answers.get('org_has_video_conferencing')
if (video === 'yes') return 'recommended'
if (level >= 'L3') return 'recommended'
return 'optional'
},
},
// ── Security Policies (nur ab L3/L4) ───────────────────────────────────
{
templateType: 'information_security_policy',
label: 'Informationssicherheitsrichtlinie',
condition: (_answers, level) => {
if (level >= 'L3') return 'required'
if (level === 'L2') return 'recommended'
return null
},
},
{
templateType: 'password_policy',
label: 'Passwortrichtlinie',
condition: (_answers, level) => level >= 'L2' ? 'recommended' : 'optional',
},
{
templateType: 'encryption_policy',
label: 'Verschluesselungsrichtlinie',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
{
templateType: 'access_control_policy',
label: 'Zugriffskontrollrichtlinie',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
// ── Security Concepts (nur ab L3) ──────────────────────────────────────
{
templateType: 'it_security_concept',
label: 'IT-Sicherheitskonzept',
condition: (_answers, level) => level >= 'L3' ? 'required' : 'optional',
},
{
templateType: 'backup_recovery_concept',
label: 'Backup-Recovery-Konzept',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
{
templateType: 'logging_concept',
label: 'Logging-Konzept',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
{
templateType: 'access_control_concept',
label: 'Zugriffskonzept',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
// ── Plattform/UGC ──────────────────────────────────────────────────────
{
templateType: 'community_guidelines',
label: 'Gemeinschaftsrichtlinien',
condition: (answers) => {
const model = answers.get('org_business_model')
const ugc = answers.get('prod_ugc_platform')
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social') return 'required'
return null
},
},
{
templateType: 'terms_of_use',
label: 'Nutzungsbedingungen',
condition: (answers) => {
const model = answers.get('org_business_model')
const ugc = answers.get('prod_ugc_platform')
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social' || model === 'saas') return 'required'
return null
},
},
{
templateType: 'media_content_policy',
label: 'Medien- und Inhalte-Richtlinie',
condition: (answers) => {
const model = answers.get('org_business_model')
if (model === 'platform' || model === 'media') return 'recommended'
return null
},
},
// ── E-Commerce ─────────────────────────────────────────────────────────
{
templateType: 'widerruf',
label: 'Widerrufsbelehrung',
condition: (answers) => {
const shop = answers.get('prod_webshop')
if (shop && shop !== 'no') return 'required'
return null
},
},
{
templateType: 'consent_texts',
label: 'Einwilligungstexte (Double-Opt-In)',
condition: (answers) => {
const consent = answers.get('prod_consent_management')
if (consent && consent !== 'no') return 'recommended'
return 'optional'
},
},
// ── Impressum + Cookie ─────────────────────────────────────────────────
{
templateType: 'impressum',
label: 'Impressum',
condition: () => 'required', // Immer Pflicht
},
{
templateType: 'cookie_policy',
label: 'Cookie-Richtlinie',
condition: () => 'required', // Immer Pflicht bei Websites
},
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
{
templateType: 'transfer_impact_assessment',
label: 'Transfer Impact Assessment (TIA)',
condition: (answers) => {
const thirdCountry = answers.get('tech_third_country')
if (!thirdCountry || thirdCountry === 'no') return null
// Wenn nur DPF-zertifizierte US-Anbieter: empfohlen statt pflicht
if (thirdCountry === 'us_dpf_only') return 'optional'
// Wenn nur Laender mit Angemessenheitsbeschluss: nicht noetig
if (thirdCountry === 'adequate_only') return null
return 'required'
},
},
{
templateType: 'scc_companion',
label: 'Standardvertragsklauseln (SCC) — Anhaenge',
condition: (answers) => {
const thirdCountry = answers.get('tech_third_country')
if (!thirdCountry || thirdCountry === 'no') return null
if (thirdCountry === 'us_dpf_only') return 'optional'
if (thirdCountry === 'adequate_only') return null
return 'required'
},
},
// ── ISMS (nur bei Zertifizierungsziel) ─────────────────────────────────
{
templateType: 'isms_manual',
label: 'ISMS-Handbuch',
condition: (answers) => {
const cert = answers.get('org_cert_target')
if (cert === 'iso27001' || cert === 'iso27701' || cert === 'tisax') return 'required'
return null
},
},
// ── Vendor/BCM (nur ab L4 oder bei Vendor-Management) ─────────────────
{
templateType: 'vendor_risk_management_policy',
label: 'Vendor-Risikomanagement',
condition: (answers, level) => {
const vendor = answers.get('comp_vendor_management')
if (vendor && vendor !== 'no') return 'recommended'
if (level === 'L4') return 'required'
return null
},
},
{
templateType: 'business_continuity_policy',
label: 'Business-Continuity-Richtlinie',
condition: (_answers, level) => level === 'L4' ? 'required' : 'optional',
},
]
// ============================================================================
// Public API
// ============================================================================
export interface TemplateRecommendation {
templateType: string
label: string
requirement: 'required' | 'recommended' | 'optional'
}
/**
* Evaluates all template rules against the user's scope answers and profile.
* Returns a prioritized list of template recommendations.
*/
export function evaluateTemplateRecommendations(
scopeAnswers: ScopeProfilingAnswer[],
level: ComplianceDepthLevel,
profile: Record<string, unknown> = {},
): TemplateRecommendation[] {
const answerMap = new Map<string, string>()
for (const a of scopeAnswers) {
answerMap.set(a.questionId, String(a.value))
}
const results: TemplateRecommendation[] = []
for (const rule of TEMPLATE_RULES) {
const requirement = rule.condition(answerMap, level, profile)
if (requirement) {
results.push({
templateType: rule.templateType,
label: rule.label,
requirement,
})
}
}
// Sort: required first, then recommended, then optional
const order = { required: 0, recommended: 1, optional: 2 }
results.sort((a, b) => order[a.requirement] - order[b.requirement])
return results
}
@@ -2,16 +2,38 @@
import React, { useState } from 'react'
import type { DSFA } from './DSFACard'
import type { DSFAPrefillResult } from '@/lib/sdk/dsfa/prefill-from-scope'
export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Partial<DSFA>) => Promise<void> }) {
interface GeneratorWizardProps {
onClose: () => void
onSubmit: (data: Partial<DSFA>) => Promise<void>
prefill?: DSFAPrefillResult | null
}
export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardProps) {
const [step, setStep] = useState(1)
const [saving, setSaving] = useState(false)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [processingActivity, setProcessingActivity] = useState('')
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
const [selectedMeasures, setSelectedMeasures] = useState<string[]>([])
const [title, setTitle] = useState(prefill?.title || '')
const [description, setDescription] = useState(prefill?.description || '')
const [processingActivity, setProcessingActivity] = useState(prefill?.processingActivity || '')
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>(riskMap2[prefill?.riskLevel || ''] || 'low')
const [residualRisk, setResidualRisk] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
const [selectedMeasures, setSelectedMeasures] = useState<string[]>(prefill?.measures || [])
const [linkedVvtId, setLinkedVvtId] = useState('')
const [vvtActivities, setVvtActivities] = useState<Array<{ id: string; name: string }>>([])
// Load VVT activities for linking
React.useEffect(() => {
fetch('/api/sdk/v1/compliance/vvt')
.then(r => r.ok ? r.json() : [])
.then(data => {
const items = Array.isArray(data) ? data : data.activities || []
setVvtActivities(items.map((a: any) => ({ id: a.id, name: a.name || a.processing_name || a.title || 'Unbenannt' })))
})
.catch(() => {})
}, [])
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
@@ -28,7 +50,12 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
riskLevel,
measures: selectedMeasures,
status: 'draft',
})
...(prefill?.federalState ? { federal_state: prefill.federalState } : {}),
...(prefill?.involvesAi ? { involves_ai: true } : {}),
...(prefill?.legalBasis ? { legal_basis: prefill.legalBasis } : {}),
...(linkedVvtId ? { linked_vvt_id: linkedVvtId } : {}),
...(residualRisk !== 'low' ? { residual_risk_level: residualRisk } : {}),
} as Partial<DSFA>)
onClose()
} finally {
setSaving(false)
@@ -48,7 +75,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-6">
{[1, 2, 3, 4].map(s => (
{[1, 2, 3, 4, 5].map(s => (
<React.Fragment key={s}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s < step ? 'bg-green-500 text-white' :
@@ -60,7 +87,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
</svg>
) : s}
</div>
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
{s < 5 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
</React.Fragment>
))}
</div>
@@ -89,6 +116,20 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
{vvtActivities.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte VVT-Aktivitaet (Art. 30)</label>
<select value={linkedVvtId} onChange={e => {
setLinkedVvtId(e.target.value)
const selected = vvtActivities.find(a => a.id === e.target.value)
if (selected && !processingActivity) setProcessingActivity(selected.name)
}} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white">
<option value=""> Keine Verknuepfung </option>
{vvtActivities.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<p className="text-xs text-gray-400 mt-1">Ordnen Sie diese DSFA einer VVT-Verarbeitungstaetigkeit zu.</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
<input
@@ -167,6 +208,43 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
</div>
</div>
)}
{step === 5 && (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Restrisiko nach Massnahmen</label>
<p className="text-xs text-gray-500 mb-3">
Bewerten Sie das verbleibende Risiko NACH Umsetzung der Schutzmassnahmen.
Bei hohem Restrisiko Art. 36 Vorabkonsultation der Aufsichtsbehoerde.
</p>
<div className="grid grid-cols-2 gap-2">
{[
{ value: 'low' as const, label: 'Niedrig', desc: 'Risiko ausreichend gemindert', color: 'border-green-300 bg-green-50' },
{ value: 'medium' as const, label: 'Mittel', desc: 'Akzeptables Restrisiko', color: 'border-yellow-300 bg-yellow-50' },
{ value: 'high' as const, label: 'Hoch', desc: 'Art. 36 Konsultation pruefen', color: 'border-orange-300 bg-orange-50' },
{ value: 'critical' as const, label: 'Kritisch', desc: 'Art. 36 Konsultation PFLICHT', color: 'border-red-300 bg-red-50' },
].map(r => (
<label key={r.value} className={`flex items-start gap-2 p-3 border-2 rounded-lg cursor-pointer ${
residualRisk === r.value ? r.color : 'border-gray-200 hover:border-gray-300'
}`}>
<input type="radio" name="residualRisk" value={r.value} checked={residualRisk === r.value}
onChange={() => setResidualRisk(r.value)} className="mt-0.5" />
<div>
<span className="text-sm font-medium">{r.label}</span>
<p className="text-xs text-gray-500">{r.desc}</p>
</div>
</label>
))}
</div>
{(residualRisk === 'high' || residualRisk === 'critical') && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-700 font-medium">Vorabkonsultation erforderlich (Art. 36 DSGVO)</p>
<p className="text-xs text-red-600 mt-1">
Bei hohem Restrisiko muss die Aufsichtsbehoerde VOR Beginn der Verarbeitung konsultiert werden.
</p>
</div>
)}
</div>
)}
</div>
{/* Navigation */}
@@ -179,11 +257,11 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
{step === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
<button
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
onClick={() => step < 5 ? setStep(step + 1) : handleSubmit()}
disabled={saving || (step === 1 && !title.trim())}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
{step === 5 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
</button>
</div>
</div>
+45 -1
View File
@@ -1,12 +1,13 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
import { DSFACard, type DSFA } from './_components/DSFACard'
import { GeneratorWizard } from './_components/GeneratorWizard'
import { prefillDSFAFromScope, isDSFARequired } from '@/lib/sdk/dsfa/prefill-from-scope'
export default function DSFAPage() {
const router = useRouter()
@@ -17,6 +18,17 @@ export default function DSFAPage() {
const [showGenerator, setShowGenerator] = useState(false)
const [filter, setFilter] = useState<string>('all')
// Pre-fill from Company Profile + Scope answers
const scopeAnswers = state.complianceScope?.answers || []
const prefill = useMemo(
() => prefillDSFAFromScope(state.companyProfile || null, scopeAnswers),
[state.companyProfile, scopeAnswers]
)
const dsfaCheck = useMemo(
() => isDSFARequired(scopeAnswers, state.companyProfile?.headquartersState),
[scopeAnswers, state.companyProfile?.headquartersState]
)
const loadDSFAs = useCallback(async () => {
setIsLoading(true)
setError(null)
@@ -120,10 +132,42 @@ export default function DSFAPage() {
)}
</StepHeader>
{/* DSFA Requirement Check */}
{dsfaCheck.required && dsfas.length === 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
<h3 className="font-semibold text-red-800">DSFA erforderlich (Art. 35 DSGVO)</h3>
<p className="text-sm text-red-700 mt-1">Basierend auf Ihrem Scope-Profiling wurde festgestellt:</p>
<ul className="mt-2 space-y-1">
{dsfaCheck.triggers.map(t => (
<li key={t} className="text-sm text-red-600 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
{t}
</li>
))}
</ul>
{dsfaCheck.blacklistMatches.length > 0 && (
<div className="mt-3 pt-3 border-t border-red-200">
<p className="text-xs font-medium text-red-800 mb-1">
Blacklist {dsfaCheck.authority || 'Aufsichtsbehoerde'} (Art. 35 Abs. 4):
</p>
<ul className="space-y-1">
{dsfaCheck.blacklistMatches.map(m => (
<li key={m} className="text-xs text-red-600 flex items-center gap-2">
<span className="w-1 h-1 bg-red-400 rounded-full flex-shrink-0" />
{m}
</li>
))}
</ul>
</div>
)}
</div>
)}
{showGenerator && (
<GeneratorWizard
onClose={() => setShowGenerator(false)}
onSubmit={handleCreateDSFA}
prefill={prefill}
/>
)}
@@ -9,7 +9,8 @@ export function ActionButtons({
onExtendDeadline,
onComplete,
onReject,
onAssign
onAssign,
onRejectArt11,
}: {
request: DSRRequest
onVerifyIdentity: () => void
@@ -17,15 +18,31 @@ export function ActionButtons({
onComplete: () => void
onReject: () => void
onAssign: () => void
onRejectArt11?: () => void
}) {
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
if (isTerminal) {
return (
<div className="space-y-2">
<button className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm">
<button
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=pdf`, '_blank')}
className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm"
>
PDF exportieren
</button>
<button
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=json`, '_blank')}
className="w-full px-4 py-2 text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors text-sm"
>
JSON exportieren (Art. 20)
</button>
<button
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=csv`, '_blank')}
className="w-full px-4 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors text-sm"
>
CSV exportieren
</button>
</div>
)
}
@@ -33,12 +50,23 @@ export function ActionButtons({
return (
<div className="space-y-2">
{!request.identityVerification.verified && (
<>
<button
onClick={onVerifyIdentity}
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
>
Identitaet verifizieren
</button>
{onRejectArt11 && (
<button
onClick={onRejectArt11}
className="w-full px-4 py-2 text-gray-600 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors text-sm"
title="Person kann anhand der gespeicherten Daten nicht identifiziert werden (Art. 11 DSGVO)"
>
Nicht identifizierbar (Art. 11)
</button>
)}
</>
)}
<button
@@ -0,0 +1,315 @@
'use client'
import React, { useState, useMemo, useCallback } from 'react'
import {
type InformationAsset,
type AssetCategory,
type AssetClassification,
type ProtectionLevel,
ASSET_CATEGORY_LABELS,
CLASSIFICATION_LABELS,
PROTECTION_LABELS,
} from '../_types'
// ============================================================================
// Local storage key (persisted in SDK state via JSONB)
// ============================================================================
const STORAGE_KEY = 'isms_assets'
function loadAssets(): InformationAsset[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch { return [] }
}
function saveAssets(assets: InformationAsset[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(assets))
}
// ============================================================================
// Protection level colors
// ============================================================================
const protectionColors: Record<ProtectionLevel, string> = {
normal: 'bg-green-100 text-green-800',
high: 'bg-amber-100 text-amber-800',
very_high: 'bg-red-100 text-red-800',
}
const classificationColors: Record<AssetClassification, string> = {
PUBLIC: 'bg-gray-100 text-gray-600',
INTERNAL: 'bg-blue-100 text-blue-700',
CONFIDENTIAL: 'bg-amber-100 text-amber-800',
STRICTLY_CONFIDENTIAL: 'bg-red-100 text-red-800',
}
// ============================================================================
// Component
// ============================================================================
export function AssetsTab() {
const [assets, setAssets] = useState<InformationAsset[]>(() => loadAssets())
const [showForm, setShowForm] = useState(false)
const [filterCategory, setFilterCategory] = useState<AssetCategory | 'ALL'>('ALL')
const [editingId, setEditingId] = useState<string | null>(null)
// Form state
const [form, setForm] = useState<Partial<InformationAsset>>({
category: 'SOFTWARE',
classification: 'INTERNAL',
protectionNeed: { confidentiality: 'normal', integrity: 'normal', availability: 'normal' },
})
const filtered = useMemo(() => {
if (filterCategory === 'ALL') return assets
return assets.filter((a) => a.category === filterCategory)
}, [assets, filterCategory])
const stats = useMemo(() => ({
total: assets.length,
byCategory: Object.entries(ASSET_CATEGORY_LABELS).map(([cat, label]) => ({
category: cat,
label,
count: assets.filter((a) => a.category === cat).length,
})),
highProtection: assets.filter(
(a) =>
a.protectionNeed.confidentiality === 'very_high' ||
a.protectionNeed.integrity === 'very_high' ||
a.protectionNeed.availability === 'very_high'
).length,
}), [assets])
const handleSave = useCallback(() => {
if (!form.name || !form.category || !form.owner) return
const now = new Date().toISOString()
const asset: InformationAsset = {
id: editingId || `asset_${Date.now()}`,
name: form.name || '',
category: form.category as AssetCategory,
description: form.description || '',
owner: form.owner || '',
location: form.location || '',
classification: form.classification as AssetClassification || 'INTERNAL',
protectionNeed: form.protectionNeed || { confidentiality: 'normal', integrity: 'normal', availability: 'normal' },
vendor: form.vendor,
notes: form.notes,
createdAt: editingId ? (assets.find((a) => a.id === editingId)?.createdAt || now) : now,
updatedAt: now,
}
const updated = editingId
? assets.map((a) => (a.id === editingId ? asset : a))
: [...assets, asset]
setAssets(updated)
saveAssets(updated)
setShowForm(false)
setEditingId(null)
setForm({
category: 'SOFTWARE',
classification: 'INTERNAL',
protectionNeed: { confidentiality: 'normal', integrity: 'normal', availability: 'normal' },
})
}, [form, editingId, assets])
const handleDelete = useCallback((id: string) => {
const updated = assets.filter((a) => a.id !== id)
setAssets(updated)
saveAssets(updated)
}, [assets])
const handleEdit = useCallback((asset: InformationAsset) => {
setForm(asset)
setEditingId(asset.id)
setShowForm(true)
}, [])
const handleExport = useCallback(() => {
const csv = [
['Name', 'Kategorie', 'Eigentuemer', 'Standort', 'Klassifizierung', 'C', 'I', 'A', 'Beschreibung'].join(';'),
...assets.map((a) =>
[a.name, ASSET_CATEGORY_LABELS[a.category], a.owner, a.location,
CLASSIFICATION_LABELS[a.classification],
PROTECTION_LABELS[a.protectionNeed.confidentiality],
PROTECTION_LABELS[a.protectionNeed.integrity],
PROTECTION_LABELS[a.protectionNeed.availability],
a.description].join(';')
),
].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `asset-register-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}, [assets])
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase">Gesamt</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</div>
</div>
{stats.byCategory.filter((s) => s.count > 0).slice(0, 2).map((s) => (
<div key={s.category} className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase">{s.label}</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{s.count}</div>
</div>
))}
<div className="bg-white rounded-xl border border-red-200 p-4">
<div className="text-xs text-red-600 uppercase">Sehr hoher Schutzbedarf</div>
<div className="text-2xl font-bold text-red-700 mt-1">{stats.highProtection}</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
{(['ALL', ...Object.keys(ASSET_CATEGORY_LABELS)] as const).map((cat) => (
<button
key={cat}
onClick={() => setFilterCategory(cat as AssetCategory | 'ALL')}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
filterCategory === cat ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{cat === 'ALL' ? 'Alle' : ASSET_CATEGORY_LABELS[cat as AssetCategory]}
</button>
))}
</div>
<div className="flex gap-2">
<button onClick={handleExport} className="px-3 py-1.5 rounded-lg text-sm font-medium bg-gray-100 text-gray-600 hover:bg-gray-200">
CSV Export
</button>
<button onClick={() => { setShowForm(true); setEditingId(null) }} className="px-4 py-1.5 rounded-lg text-sm font-medium bg-purple-600 text-white hover:bg-purple-700">
+ Asset hinzufuegen
</button>
</div>
</div>
{/* Form */}
{showForm && (
<div className="bg-white rounded-xl border border-purple-200 p-6 space-y-4">
<h3 className="font-semibold text-gray-900">{editingId ? 'Asset bearbeiten' : 'Neues Asset'}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input value={form.name || ''} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="z.B. PostgreSQL Produktions-DB" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie *</label>
<select value={form.category || 'SOFTWARE'} onChange={(e) => setForm({ ...form, category: e.target.value as AssetCategory })} className="w-full border rounded-lg px-3 py-2 text-sm">
{Object.entries(ASSET_CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Eigentuemer *</label>
<input value={form.owner || ''} onChange={(e) => setForm({ ...form, owner: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Person oder Abteilung" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standort</label>
<input value={form.location || ''} onChange={(e) => setForm({ ...form, location: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="z.B. Hetzner Cloud EU" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Klassifizierung</label>
<select value={form.classification || 'INTERNAL'} onChange={(e) => setForm({ ...form, classification: e.target.value as AssetClassification })} className="w-full border rounded-lg px-3 py-2 text-sm">
{Object.entries(CLASSIFICATION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor/Anbieter</label>
<input value={form.vendor || ''} onChange={(e) => setForm({ ...form, vendor: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Optional" />
</div>
</div>
{/* Protection need */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzbedarf (CIA)</label>
<div className="grid grid-cols-3 gap-4">
{(['confidentiality', 'integrity', 'availability'] as const).map((dim) => (
<div key={dim}>
<label className="block text-xs text-gray-500 mb-1">
{dim === 'confidentiality' ? 'Vertraulichkeit' : dim === 'integrity' ? 'Integritaet' : 'Verfuegbarkeit'}
</label>
<select
value={form.protectionNeed?.[dim] || 'normal'}
onChange={(e) => setForm({ ...form, protectionNeed: { ...form.protectionNeed!, [dim]: e.target.value as ProtectionLevel } })}
className="w-full border rounded-lg px-3 py-2 text-sm"
>
{Object.entries(PROTECTION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} />
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => { setShowForm(false); setEditingId(null) }} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={handleSave} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Speichern</button>
</div>
</div>
)}
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-500">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Kategorie</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Eigentuemer</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Klassifizierung</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">C</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">I</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">A</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
Keine Assets erfasst. Klicken Sie auf "Asset hinzufuegen".
</td>
</tr>
) : (
filtered.map((a) => (
<tr key={a.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{a.name}</td>
<td className="px-4 py-3 text-gray-600">{ASSET_CATEGORY_LABELS[a.category]}</td>
<td className="px-4 py-3 text-gray-600">{a.owner}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${classificationColors[a.classification]}`}>
{CLASSIFICATION_LABELS[a.classification]}
</span>
</td>
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs ${protectionColors[a.protectionNeed.confidentiality]}`}>{PROTECTION_LABELS[a.protectionNeed.confidentiality]}</span></td>
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs ${protectionColors[a.protectionNeed.integrity]}`}>{PROTECTION_LABELS[a.protectionNeed.integrity]}</span></td>
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs ${protectionColors[a.protectionNeed.availability]}`}>{PROTECTION_LABELS[a.protectionNeed.availability]}</span></td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button onClick={() => handleEdit(a)} className="text-xs text-blue-600 hover:text-blue-800">Bearbeiten</button>
<button onClick={() => handleDelete(a.id)} className="text-xs text-red-500 hover:text-red-700">Loeschen</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
+51 -1
View File
@@ -211,6 +211,56 @@ export interface PotentialFinding {
iso_reference: string
}
export type TabId = 'overview' | 'policies' | 'soa' | 'objectives' | 'audits' | 'reviews'
export type TabId = 'overview' | 'policies' | 'soa' | 'objectives' | 'audits' | 'reviews' | 'assets'
// =============================================================================
// ASSET REGISTER (ISO 27001 Annex A.5.9)
// =============================================================================
export type AssetCategory = 'HARDWARE' | 'SOFTWARE' | 'DATA' | 'SERVICE' | 'PEOPLE' | 'FACILITY'
export type AssetClassification = 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' | 'STRICTLY_CONFIDENTIAL'
export type ProtectionLevel = 'normal' | 'high' | 'very_high'
export interface InformationAsset {
id: string
name: string
category: AssetCategory
description: string
owner: string
location: string
classification: AssetClassification
protectionNeed: {
confidentiality: ProtectionLevel
integrity: ProtectionLevel
availability: ProtectionLevel
}
vendor?: string
relatedProcessingActivities?: string[]
notes?: string
createdAt: string
updatedAt: string
}
export const ASSET_CATEGORY_LABELS: Record<AssetCategory, string> = {
HARDWARE: 'Hardware',
SOFTWARE: 'Software',
DATA: 'Daten',
SERVICE: 'Dienst/Cloud',
PEOPLE: 'Personen',
FACILITY: 'Standort/Raum',
}
export const CLASSIFICATION_LABELS: Record<AssetClassification, string> = {
PUBLIC: 'Oeffentlich',
INTERNAL: 'Intern',
CONFIDENTIAL: 'Vertraulich',
STRICTLY_CONFIDENTIAL: 'Streng Vertraulich',
}
export const PROTECTION_LABELS: Record<ProtectionLevel, string> = {
normal: 'Normal',
high: 'Hoch',
very_high: 'Sehr hoch',
}
export const API = '/api/sdk/v1/isms'
+7 -1
View File
@@ -8,6 +8,7 @@ import { SoATab } from './_components/SoATab'
import { ObjectivesTab } from './_components/ObjectivesTab'
import { AuditsTab } from './_components/AuditsTab'
import { ReviewsTab } from './_components/ReviewsTab'
import { AssetsTab } from './_components/AssetsTab'
// =============================================================================
// MAIN PAGE
@@ -20,6 +21,7 @@ const TABS: { id: TabId; label: string }[] = [
{ id: 'objectives', label: 'Ziele' },
{ id: 'audits', label: 'Audits & Findings' },
{ id: 'reviews', label: 'Management Reviews' },
{ id: 'assets', label: 'Assets' },
]
export default function ISMSPage() {
@@ -29,10 +31,13 @@ export default function ISMSPage() {
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">ISMS ISO 27001</h1>
<h1 className="text-2xl font-bold text-gray-900">ISMS ISO 27001 Readiness</h1>
<p className="text-sm text-gray-600 mt-1">
Informationssicherheits-Managementsystem: Scope, Policies, SoA, Audits, CAPA und Management-Reviews
</p>
<p className="text-xs text-amber-600 mt-2">
Hinweis: Basierend auf eigenen Pruefaspekten, kein ISO-Normtext. Ersetzt kein Zertifizierungsaudit.
</p>
</div>
{/* Tabs */}
@@ -59,6 +64,7 @@ export default function ISMSPage() {
{tab === 'objectives' && <ObjectivesTab />}
{tab === 'audits' && <AuditsTab />}
{tab === 'reviews' && <ReviewsTab />}
{tab === 'assets' && <AssetsTab />}
</div>
</div>
)
+5
View File
@@ -208,7 +208,12 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
{/* Command Bar Modal */}
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
<<<<<<< HEAD
{/* Module-specific FAB navigators are rendered by each module's layout */}
=======
{/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */}
<SDKPipelineSidebar />
>>>>>>> feat/zeroclaw-compliance-agent
{/* Compliance Advisor Widget — immer sichtbar, auch ohne Projekt */}
<ComplianceAdvisorWidget currentStep={currentStep} />
@@ -62,18 +62,7 @@ const fallbackModules: Omit<DisplayModule, 'status' | 'completionPercent'>[] = [
requirementsCount: 28,
controlsCount: 18,
},
{
id: 'mod-iso27001',
name: 'ISO 27001',
description: 'Informationssicherheits-Managementsystem nach ISO/IEC 27001',
category: 'iso27001',
regulations: ['ISO 27001', 'ISO 27002'],
criticality: 'MEDIUM',
processesPersonalData: false,
hasAIComponents: false,
requirementsCount: 114,
controlsCount: 93,
},
// ISO 27001 ist kein Gesetz — Readiness-Check im ISMS-Modul (/sdk/isms)
{
id: 'mod-nis2',
name: 'NIS2 Richtlinie',
+1 -2
View File
@@ -104,7 +104,6 @@ export default function ModulesPage() {
>
<option value="gdpr">DSGVO</option>
<option value="ai-act">AI Act</option>
<option value="iso27001">ISO 27001</option>
<option value="nis2">NIS2</option>
<option value="custom">Eigene</option>
</select>
@@ -191,7 +190,7 @@ export default function ModulesPage() {
{/* Filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'active', 'inactive', 'gdpr', 'ai-act', 'iso27001', 'nis2'].map(f => (
{['all', 'active', 'inactive', 'gdpr', 'ai-act', 'nis2'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
+138 -268
View File
@@ -5,24 +5,15 @@ import Link from 'next/link'
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
import { RegulatoryNewsFeed } from '@/components/sdk/regulatory-news/RegulatoryNewsFeed'
import { PresetSection } from './_components/PresetSection'
import type { SDKPackageId } from '@/lib/sdk/types'
// =============================================================================
// DASHBOARD CARDS
// =============================================================================
function StatCard({
title,
value,
subtitle,
icon,
color,
}: {
title: string
value: string | number
subtitle: string
icon: React.ReactNode
color: string
function StatCard({ title, value, subtitle, icon, color }: {
title: string; value: string | number; subtitle: string; icon: React.ReactNode; color: string
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -38,18 +29,8 @@ function StatCard({
)
}
function PackageCard({
pkg,
completion,
stepsCount,
isLocked,
projectId,
}: {
pkg: (typeof SDK_PACKAGES)[number]
completion: number
stepsCount: number
isLocked: boolean
projectId?: string
function PackageCard({ pkg, completion, stepsCount, isLocked, projectId }: {
pkg: (typeof SDK_PACKAGES)[number]; completion: number; stepsCount: number; isLocked: boolean; projectId?: string
}) {
const steps = getStepsForPackage(pkg.id)
const firstStep = steps[0]
@@ -57,36 +38,20 @@ function PackageCard({
const href = projectId ? `${baseHref}?project=${projectId}` : baseHref
const content = (
<div
className={`block bg-white rounded-xl border-2 p-6 transition-all ${
isLocked
? 'border-gray-100 opacity-60 cursor-not-allowed'
: completion === 100
? 'border-green-200 hover:border-green-300 hover:shadow-lg'
<div className={`block bg-white rounded-xl border-2 p-6 transition-all ${
isLocked ? 'border-gray-100 opacity-60 cursor-not-allowed'
: completion === 100 ? 'border-green-200 hover:border-green-300 hover:shadow-lg'
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
}`}
>
}`}>
<div className="flex items-start gap-4">
<div
className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
isLocked
? 'bg-gray-100 text-gray-400'
: completion === 100
? 'bg-green-100 text-green-600'
: 'bg-purple-100 text-purple-600'
}`}
>
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
isLocked ? 'bg-gray-100 text-gray-400' : completion === 100 ? 'bg-green-100 text-green-600' : 'bg-purple-100 text-purple-600'
}`}>
{isLocked ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
) : completion === 100 ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
pkg.icon
)}
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : pkg.icon}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
@@ -97,61 +62,27 @@ function PackageCard({
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">{stepsCount} Schritte</span>
<span className={`font-medium ${completion === 100 ? 'text-green-600' : 'text-purple-600'}`}>
{completion}%
</span>
<span className={`font-medium ${completion === 100 ? 'text-green-600' : 'text-purple-600'}`}>{completion}%</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
completion === 100 ? 'bg-green-500' : 'bg-purple-600'
}`}
style={{ width: `${completion}%` }}
/>
<div className={`h-full rounded-full transition-all duration-500 ${completion === 100 ? 'bg-green-500' : 'bg-purple-600'}`} style={{ width: `${completion}%` }} />
</div>
</div>
{!isLocked && (
<p className="mt-3 text-xs text-gray-400">
Ergebnis: {pkg.result}
</p>
)}
{!isLocked && <p className="mt-3 text-xs text-gray-400">Ergebnis: {pkg.result}</p>}
</div>
</div>
</div>
)
if (isLocked) {
return content
return isLocked ? content : <Link href={href}>{content}</Link>
}
return (
<Link href={href}>
{content}
</Link>
)
}
function QuickActionCard({
title,
description,
icon,
href,
color,
projectId,
}: {
title: string
description: string
icon: React.ReactNode
href: string
color: string
projectId?: string
function QuickActionCard({ title, description, icon, href, color, projectId }: {
title: string; description: string; icon: React.ReactNode; href: string; color: string; projectId?: string
}) {
const finalHref = projectId ? `${href}?project=${projectId}` : href
return (
<Link
href={finalHref}
className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all"
>
<Link href={finalHref} className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all">
<div className={`p-3 rounded-lg ${color}`}>{icon}</div>
<div>
<h4 className="font-medium text-gray-900">{title}</h4>
@@ -170,23 +101,15 @@ function QuickActionCard({
export default function SDKDashboard() {
const { state, packageCompletion, completionPercentage, setCustomerType, projectId } = useSDK()
// customerType is set during project creation — default to 'new' for legacy projects
const effectiveCustomerType = state.customerType || 'new'
// No project selected → show project list
if (!projectId) {
return <ProjectSelector />
}
if (!projectId) return <ProjectSelector />
// Calculate total steps
const totalSteps = SDK_PACKAGES.reduce((sum, pkg) => {
const steps = getStepsForPackage(pkg.id)
// Filter import step for new customers
return sum + steps.filter(s => !(s.id === 'import' && effectiveCustomerType === 'new')).length
}, 0)
// Calculate stats
const completedCheckpoints = Object.values(state.checkpoints).filter(cp => cp.passed).length
const totalRisks = state.risks.length
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL' || r.severity === 'HIGH').length
@@ -195,11 +118,8 @@ export default function SDKDashboard() {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
// Check if previous package is complete
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
@@ -210,86 +130,96 @@ export default function SDKDashboard() {
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
<p className="mt-1 text-gray-500">
{effectiveCustomerType === 'new'
? 'Neukunden-Modus: Erstellen Sie alle Compliance-Dokumente von Grund auf.'
: 'Bestandskunden-Modus: Erweitern Sie bestehende Dokumente.'}
{effectiveCustomerType === 'new' ? 'Neukunden-Modus: Erstellen Sie alle Compliance-Dokumente von Grund auf.' : 'Bestandskunden-Modus: Erweitern Sie bestehende Dokumente.'}
</p>
</div>
<button
onClick={() => setCustomerType(effectiveCustomerType === 'new' ? 'existing' : 'new')}
className="text-sm text-purple-600 hover:text-purple-700 underline"
>
<button onClick={() => setCustomerType(effectiveCustomerType === 'new' ? 'existing' : 'new')} className="text-sm text-purple-600 hover:text-purple-700 underline">
{effectiveCustomerType === 'new' ? 'Zu Bestandskunden wechseln' : 'Zu Neukunden wechseln'}
</button>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Gesamtfortschritt"
value={`${completionPercentage}%`}
subtitle={`${state.completedSteps.length} von ${totalSteps} Schritten`}
icon={
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
{/* Industry Presets with Document Preview */}
<PresetSection projectId={projectId} />
{/* Applicable Regulations Card */}
{state.complianceScope?.decision && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
}
color="bg-purple-50"
/>
<StatCard
title="Use Cases"
value={state.useCases.length}
subtitle={state.useCases.length === 0 ? 'Noch keine erstellt' : 'Erfasst'}
icon={
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
}
color="bg-blue-50"
/>
<StatCard
title="Checkpoints"
value={`${completedCheckpoints}/${totalSteps}`}
subtitle="Validiert"
icon={
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
color="bg-green-50"
/>
<StatCard
title="Risiken"
value={totalRisks}
subtitle={criticalRisks > 0 ? `${criticalRisks} kritisch` : 'Keine kritischen'}
icon={
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div>
<div>
<h3 className="font-semibold text-gray-900">Erkannte Regulierungen</h3>
<p className="text-xs text-gray-500">Basierend auf Ihrem Scope-Profiling (Level {state.complianceScope.decision.level})</p>
</div>
</div>
<Link href={projectId ? `/sdk/compliance-scope?project=${projectId}` : '/sdk/compliance-scope'}
className="text-xs text-purple-600 hover:underline">
Details & Anpassen
</Link>
</div>
<div className="flex flex-wrap gap-2">
{(state.complianceScope.decision.requiredDocuments || []).length > 0 ? (
['DSGVO', 'AI Act', 'NIS2', 'HinSchG', 'TTDSG'].filter(reg => {
const docs = state.complianceScope?.decision?.requiredDocuments || []
const triggers = state.complianceScope?.decision?.hardTriggers || []
if (reg === 'DSGVO') return true
if (reg === 'AI Act') return triggers.some((t: string) => t.toLowerCase().includes('ai') || t.toLowerCase().includes('ki'))
if (reg === 'NIS2') return triggers.some((t: string) => t.toLowerCase().includes('nis') || t.toLowerCase().includes('kritisch'))
if (reg === 'HinSchG') return triggers.some((t: string) => t.toLowerCase().includes('whistleblower') || t.toLowerCase().includes('hinweis'))
return false
}).map(reg => (
<span key={reg} className="px-3 py-1.5 bg-green-50 text-green-700 border border-green-200 rounded-lg text-sm font-medium">
{reg}
</span>
))
) : (
<span className="px-3 py-1.5 bg-green-50 text-green-700 border border-green-200 rounded-lg text-sm font-medium">DSGVO</span>
)}
</div>
</div>
)}
{!state.complianceScope?.decision && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5 flex items-center gap-4">
<div className="p-2 bg-amber-100 rounded-lg flex-shrink-0">
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
}
color="bg-orange-50"
/>
</div>
<div>
<h3 className="font-semibold text-amber-800">Scope-Profiling noch nicht abgeschlossen</h3>
<p className="text-sm text-amber-700">
Fuehren Sie das <Link href={projectId ? `/sdk/compliance-scope?project=${projectId}` : '/sdk/compliance-scope'} className="underline font-medium">Scope-Profiling</Link> durch um zu erfahren welche Regulierungen fuer Ihr Unternehmen gelten.
</p>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Gesamtfortschritt" value={`${completionPercentage}%`} subtitle={`${state.completedSteps.length} von ${totalSteps} Schritten`}
icon={<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>} color="bg-purple-50" />
<StatCard title="Use Cases" value={state.useCases.length} subtitle={state.useCases.length === 0 ? 'Noch keine erstellt' : 'Erfasst'}
icon={<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>} color="bg-blue-50" />
<StatCard title="Checkpoints" value={`${completedCheckpoints}/${totalSteps}`} subtitle="Validiert"
icon={<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>} color="bg-green-50" />
<StatCard title="Risiken" value={totalRisks} subtitle={criticalRisks > 0 ? `${criticalRisks} kritisch` : 'Keine kritischen'}
icon={<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>} color="bg-orange-50" />
</div>
{/* Bestandskunden: Gap Analysis Banner */}
{effectiveCustomerType === 'existing' && state.importedDocuments.length === 0 && (
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-2xl">📄</span>
</div>
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0"><span className="text-2xl">📄</span></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">Bestehende Dokumente importieren</h3>
<p className="mt-1 text-gray-600">
Laden Sie Ihre vorhandenen Compliance-Dokumente hoch. Unsere KI analysiert sie und zeigt Ihnen, welche Erweiterungen fuer KI-Compliance erforderlich sind.
</p>
<Link
href={projectId ? `/sdk/import?project=${projectId}` : '/sdk/import'}
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<p className="mt-1 text-gray-600">Laden Sie Ihre vorhandenen Compliance-Dokumente hoch. Unsere KI analysiert sie und zeigt Ihnen, welche Erweiterungen erforderlich sind.</p>
<Link href={projectId ? `/sdk/import?project=${projectId}` : '/sdk/import'} className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
Dokumente hochladen
</Link>
</div>
@@ -301,38 +231,21 @@ export default function SDKDashboard() {
{state.gapAnalysis && (
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<span className="text-xl">📊</span>
</div>
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center"><span className="text-xl">📊</span></div>
<div>
<h3 className="font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
<p className="text-sm text-gray-500">
{state.gapAnalysis.totalGaps} Luecken gefunden
</p>
<p className="text-sm text-gray-500">{state.gapAnalysis.totalGaps} Luecken gefunden</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{state.gapAnalysis.criticalGaps}</div>
<div className="text-xs text-red-600">Kritisch</div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">{state.gapAnalysis.highGaps}</div>
<div className="text-xs text-orange-600">Hoch</div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{state.gapAnalysis.mediumGaps}</div>
<div className="text-xs text-yellow-600">Mittel</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{state.gapAnalysis.lowGaps}</div>
<div className="text-xs text-green-600">Niedrig</div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg"><div className="text-2xl font-bold text-red-600">{state.gapAnalysis.criticalGaps}</div><div className="text-xs text-red-600">Kritisch</div></div>
<div className="text-center p-3 bg-orange-50 rounded-lg"><div className="text-2xl font-bold text-orange-600">{state.gapAnalysis.highGaps}</div><div className="text-xs text-orange-600">Hoch</div></div>
<div className="text-center p-3 bg-yellow-50 rounded-lg"><div className="text-2xl font-bold text-yellow-600">{state.gapAnalysis.mediumGaps}</div><div className="text-xs text-yellow-600">Mittel</div></div>
<div className="text-center p-3 bg-green-50 rounded-lg"><div className="text-2xl font-bold text-green-600">{state.gapAnalysis.lowGaps}</div><div className="text-xs text-green-600">Niedrig</div></div>
</div>
</div>
)}
{/* Regulatory News */}
<RegulatoryNewsFeed businessModel={state.companyProfile?.businessModel as string} />
{/* 5 Packages */}
@@ -342,17 +255,7 @@ export default function SDKDashboard() {
{SDK_PACKAGES.map(pkg => {
const steps = getStepsForPackage(pkg.id)
const visibleSteps = steps.filter(s => !(s.id === 'import' && effectiveCustomerType === 'new'))
return (
<PackageCard
key={pkg.id}
pkg={pkg}
completion={packageCompletion[pkg.id]}
stepsCount={visibleSteps.length}
isLocked={isPackageLocked(pkg.id)}
projectId={projectId}
/>
)
return <PackageCard key={pkg.id} pkg={pkg} completion={packageCompletion[pkg.id]} stepsCount={visibleSteps.length} isLocked={isPackageLocked(pkg.id)} projectId={projectId} />
})}
</div>
</div>
@@ -361,57 +264,39 @@ export default function SDKDashboard() {
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Schnellaktionen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<QuickActionCard
title="Neuen Use Case erstellen"
description="Starten Sie den 5-Schritte-Wizard"
icon={
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
}
href="/sdk/advisory-board"
color="bg-purple-50"
projectId={projectId}
/>
<QuickActionCard
title="Security Screening"
description="SBOM generieren und Schwachstellen scannen"
icon={
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
}
href="/sdk/screening"
color="bg-red-50"
projectId={projectId}
/>
<QuickActionCard
title="DSFA generieren"
description="Datenschutz-Folgenabschaetzung erstellen"
icon={
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 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>
}
href="/sdk/dsfa"
color="bg-blue-50"
projectId={projectId}
/>
<QuickActionCard
title="Legal RAG"
description="Rechtliche Fragen stellen und Antworten erhalten"
icon={
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
href="/sdk/rag"
color="bg-green-50"
projectId={projectId}
/>
<QuickActionCard title="Neuen Use Case erstellen" description="Starten Sie den 5-Schritte-Wizard"
icon={<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>}
href="/sdk/advisory-board" color="bg-purple-50" projectId={projectId} />
<QuickActionCard title="Security Screening" description="SBOM generieren und Schwachstellen scannen"
icon={<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>}
href="/sdk/screening" color="bg-red-50" projectId={projectId} />
<QuickActionCard title="DSFA generieren" description="Datenschutz-Folgenabschaetzung erstellen"
icon={<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 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>}
href="/sdk/dsfa" color="bg-blue-50" projectId={projectId} />
<QuickActionCard title="Legal RAG" description="Rechtliche Fragen stellen und Antworten erhalten"
icon={<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>}
href="/sdk/rag" color="bg-green-50" projectId={projectId} />
</div>
</div>
{/* Compliance Report Download */}
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-6 flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">Compliance-Report</h3>
<p className="text-sm text-gray-500 mt-1">Umfassender PDF-Bericht ueber alle Module, Rollen, Risiken und Massnahmen.</p>
</div>
<button
onClick={() => {
const url = `/api/sdk/v1/compliance/report/pdf${projectId ? `?project_id=${projectId}` : ''}`
window.open(url, '_blank')
}}
className="px-5 py-2.5 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" 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>
{/* Recent Activity */}
{state.commandBarHistory.length > 0 && (
<div>
@@ -419,30 +304,15 @@ export default function SDKDashboard() {
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{state.commandBarHistory.slice(0, 5).map(entry => (
<div key={entry.id} className="flex items-center gap-4 px-4 py-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
entry.success ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'
}`}
>
{entry.success ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<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>
)}
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${entry.success ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
{entry.success ? <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
: <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>}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{entry.query}</p>
<p className="text-xs text-gray-500">
{new Date(entry.timestamp).toLocaleString('de-DE')}
</p>
<p className="text-xs text-gray-500">{new Date(entry.timestamp).toLocaleString('de-DE')}</p>
</div>
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded-full">
{entry.type}
</span>
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded-full">{entry.type}</span>
</div>
))}
</div>
@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
import type { DocumentReview, ReviewStats } from '../_types'
import { STATUS_CONFIG, ROLE_ICONS } from '../_types'
interface ReviewListProps {
reviews: DocumentReview[]
stats: ReviewStats
loading: boolean
statusFilter: string | null
onFilterChange: (status: string | null) => void
onApprove: (id: string) => Promise<void>
onReject: (id: string, comment: string) => Promise<void>
onSendNotification: (id: string) => Promise<void>
}
export function ReviewList({
reviews, stats, loading, statusFilter, onFilterChange,
onApprove, onReject, onSendNotification,
}: ReviewListProps) {
const [rejectId, setRejectId] = useState<string | null>(null)
const [rejectComment, setRejectComment] = useState('')
const [processing, setProcessing] = useState<string | null>(null)
const total = Object.values(stats).reduce((s, n) => s + (n || 0), 0)
const handleApprove = async (id: string) => {
setProcessing(id)
try { await onApprove(id) } finally { setProcessing(null) }
}
const handleReject = async () => {
if (!rejectId || !rejectComment.trim()) return
setProcessing(rejectId)
try {
await onReject(rejectId, rejectComment)
setRejectId(null)
setRejectComment('')
} finally { setProcessing(null) }
}
return (
<div className="space-y-4">
{/* Stats */}
<div className="flex gap-3 flex-wrap">
<button onClick={() => onFilterChange(null)}
className={`px-3 py-1 text-xs rounded-full border ${!statusFilter ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}`}>
Alle ({total})
</button>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
<button key={key} onClick={() => onFilterChange(key === statusFilter ? null : key)}
className={`px-3 py-1 text-xs rounded-full border ${statusFilter === key ? cfg.color + ' border-current' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}`}>
{cfg.label} ({stats[key as keyof ReviewStats] || 0})
</button>
))}
</div>
{/* List */}
{loading ? (
<div className="text-center py-8 text-gray-400">Lade Reviews...</div>
) : reviews.length === 0 ? (
<div className="text-center py-8 text-gray-400">Keine Reviews vorhanden</div>
) : (
<div className="space-y-2">
{reviews.map(review => {
const cfg = STATUS_CONFIG[review.status] || STATUS_CONFIG.pending
return (
<div key={review.id} className="bg-white border border-gray-200 rounded-lg p-4 flex items-center gap-4">
<span className="text-xl">{ROLE_ICONS[review.reviewer_role_key] || '\u{1F464}'}</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 truncate">{review.document_title}</div>
<div className="text-xs text-gray-500">
{review.reviewer_name || review.reviewer_role_key}
{review.submitted_at && `${new Date(review.submitted_at).toLocaleDateString('de-DE')}`}
</div>
</div>
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${cfg.color}`}>{cfg.label}</span>
{review.status === 'pending' && (
<button onClick={() => onSendNotification(review.id)}
className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded hover:bg-blue-100">
E-Mail
</button>
)}
{(review.status === 'pending' || review.status === 'in_review') && (
<div className="flex gap-1">
<button onClick={() => handleApprove(review.id)} disabled={processing === review.id}
className="px-2 py-1 text-xs bg-green-50 text-green-600 rounded hover:bg-green-100 disabled:opacity-50">
Freigeben
</button>
<button onClick={() => setRejectId(review.id)}
className="px-2 py-1 text-xs bg-red-50 text-red-600 rounded hover:bg-red-100">
Ablehnen
</button>
</div>
)}
{review.status === 'rejected' && review.review_comment && (
<span className="text-xs text-red-500 max-w-[200px] truncate" title={review.review_comment}>
{review.review_comment}
</span>
)}
</div>
)
})}
</div>
)}
{/* Reject Dialog */}
{rejectId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-2xl">
<h3 className="text-lg font-semibold mb-3">Dokument ablehnen</h3>
<textarea value={rejectComment} onChange={e => setRejectComment(e.target.value)}
placeholder="Grund fuer die Ablehnung..." rows={3}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-red-500" />
<div className="flex justify-end gap-2 mt-4">
<button onClick={() => { setRejectId(null); setRejectComment('') }}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={handleReject} disabled={!rejectComment.trim() || processing === rejectId}
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
Ablehnen
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,106 @@
'use client'
import { useState } from 'react'
import type { OrgRole, DefaultRole } from '../_types'
import { ROLE_ICONS } from '../_types'
interface RoleCardProps {
role: OrgRole | DefaultRole
onSave: (roleId: string, data: Partial<OrgRole>) => Promise<void>
onSendTest: (roleId: string) => Promise<{ sent: boolean; email: string }>
}
export function RoleCard({ role, onSave, onSendTest }: RoleCardProps) {
const isAssigned = 'id' in role
const [editing, setEditing] = useState(false)
const [name, setName] = useState((role as OrgRole).person_name || '')
const [email, setEmail] = useState((role as OrgRole).person_email || '')
const [dept, setDept] = useState((role as OrgRole).department || '')
const [sending, setSending] = useState(false)
const [testResult, setTestResult] = useState<string | null>(null)
const handleSave = async () => {
if (!isAssigned) return
await onSave((role as OrgRole).id, { person_name: name, person_email: email, department: dept })
setEditing(false)
}
const handleTest = async () => {
if (!isAssigned) return
setSending(true)
setTestResult(null)
try {
const result = await onSendTest((role as OrgRole).id)
setTestResult(result.sent ? `Gesendet an ${result.email}` : 'Fehler')
} catch {
setTestResult('Fehler beim Senden')
} finally {
setSending(false)
}
}
const icon = ROLE_ICONS[role.role_key] || '\u{1F464}'
return (
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-3">
<div className="flex items-center gap-3">
<span className="text-2xl">{icon}</span>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 text-sm">{role.role_label}</h3>
{isAssigned && (role as OrgRole).person_name && !editing && (
<p className="text-xs text-gray-500">{(role as OrgRole).person_name}</p>
)}
</div>
{isAssigned && !editing && (
<button onClick={() => setEditing(true)} className="text-xs text-purple-600 hover:underline">
Bearbeiten
</button>
)}
</div>
{editing ? (
<div className="space-y-2">
<input value={name} onChange={e => setName(e.target.value)} placeholder="Name"
className="w-full px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 focus:border-purple-500" />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="E-Mail" type="email"
className="w-full px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 focus:border-purple-500" />
<input value={dept} onChange={e => setDept(e.target.value)} placeholder="Abteilung"
className="w-full px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 focus:border-purple-500" />
<div className="flex gap-2">
<button onClick={handleSave} className="px-3 py-1 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Speichern
</button>
<button onClick={() => setEditing(false)} className="px-3 py-1 text-xs text-gray-500 hover:text-gray-700">
Abbrechen
</button>
</div>
</div>
) : (
<>
{isAssigned && (role as OrgRole).person_email && (
<div className="text-xs text-gray-500 space-y-0.5">
<div>{(role as OrgRole).person_email}</div>
{(role as OrgRole).department && <div>{(role as OrgRole).department}</div>}
</div>
)}
{!isAssigned && (
<p className="text-xs text-gray-400 italic">Noch nicht zugewiesen</p>
)}
</>
)}
{isAssigned && (role as OrgRole).person_email && !editing && (
<button onClick={handleTest} disabled={sending}
className="w-full px-3 py-1.5 text-xs bg-blue-50 text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-100 disabled:opacity-50">
{sending ? 'Sende...' : 'Test-E-Mail senden'}
</button>
)}
{testResult && (
<p className={`text-xs ${testResult.startsWith('Gesendet') ? 'text-green-600' : 'text-red-600'}`}>
{testResult}
</p>
)}
</div>
)
}
@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
import type { DocumentReview, ReviewStats } from '../_types'
const API_BASE = '/api/sdk/v1/compliance/document-reviews'
async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json()
}
export function useDocumentReviews() {
const { projectId } = useSDK()
const [reviews, setReviews] = useState<DocumentReview[]>([])
const [stats, setStats] = useState<ReviewStats>({})
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState<string | null>(null)
const loadReviews = useCallback(async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (projectId) params.set('project_id', projectId)
if (statusFilter) params.set('status', statusFilter)
const qs = params.toString() ? `?${params}` : ''
const [reviewsData, statsData] = await Promise.all([
apiFetch<DocumentReview[]>(`${API_BASE}${qs}`),
apiFetch<ReviewStats>(`${API_BASE}/stats${projectId ? `?project_id=${projectId}` : ''}`),
])
setReviews(reviewsData)
setStats(statsData)
} catch {
// Silent — component shows empty state
} finally {
setLoading(false)
}
}, [projectId, statusFilter])
useEffect(() => { loadReviews() }, [loadReviews])
const approveReview = useCallback(async (reviewId: string) => {
const updated = await apiFetch<DocumentReview>(`${API_BASE}/${reviewId}/approve`, { method: 'POST' })
setReviews(prev => prev.map(r => r.id === reviewId ? updated : r))
return updated
}, [])
const rejectReview = useCallback(async (reviewId: string, comment: string) => {
const updated = await apiFetch<DocumentReview>(`${API_BASE}/${reviewId}/reject`, {
method: 'POST',
body: JSON.stringify({ comment }),
})
setReviews(prev => prev.map(r => r.id === reviewId ? updated : r))
return updated
}, [])
const sendNotification = useCallback(async (reviewId: string) => {
return apiFetch<{ sent: boolean }>(`${API_BASE}/${reviewId}/send`, { method: 'POST' })
}, [])
return {
reviews, stats, loading, statusFilter, setStatusFilter,
loadReviews, approveReview, rejectReview, sendNotification,
}
}
@@ -0,0 +1,84 @@
import { useState, useEffect, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
import type { OrgRole, DefaultRole, RoleMapping } from '../_types'
const API_BASE = '/api/sdk/v1/compliance/org-roles'
async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json()
}
export function useOrgRoles() {
const { projectId } = useSDK()
const [roles, setRoles] = useState<OrgRole[]>([])
const [defaults, setDefaults] = useState<DefaultRole[]>([])
const [mapping, setMapping] = useState<RoleMapping[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadRoles = useCallback(async () => {
try {
setLoading(true)
const qs = projectId ? `?project_id=${projectId}` : ''
const [rolesData, defaultsData, mappingData] = await Promise.all([
apiFetch<OrgRole[]>(`${API_BASE}${qs}`),
apiFetch<DefaultRole[]>(`${API_BASE}/defaults`),
apiFetch<RoleMapping[]>(`${API_BASE}/mapping`),
])
setRoles(rolesData)
setDefaults(defaultsData)
setMapping(mappingData)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { loadRoles() }, [loadRoles])
const seedRoles = useCallback(async () => {
const qs = projectId ? `?project_id=${projectId}` : ''
await apiFetch(`${API_BASE}/seed${qs}`, { method: 'POST' })
await loadRoles()
}, [projectId, loadRoles])
const updateRole = useCallback(async (roleId: string, data: Partial<OrgRole>) => {
const updated = await apiFetch<OrgRole>(`${API_BASE}/${roleId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
setRoles(prev => prev.map(r => r.id === roleId ? updated : r))
return updated
}, [])
const sendTestEmail = useCallback(async (roleId: string) => {
return apiFetch<{ sent: boolean; email: string }>(`${API_BASE}/${roleId}/send-test`, {
method: 'POST',
})
}, [])
const updateMapping = useCallback(async (entries: { document_type: string; role_key: string; is_primary: boolean }[]) => {
await apiFetch(`${API_BASE}/mapping`, {
method: 'PUT',
body: JSON.stringify({ entries }),
})
await loadRoles()
}, [loadRoles])
// Get role by key (merge defaults with actual role data)
const getRoleByKey = useCallback((key: string): OrgRole | DefaultRole | undefined => {
return roles.find(r => r.role_key === key) || defaults.find(d => d.role_key === key)
}, [roles, defaults])
return {
roles, defaults, mapping, loading, error,
loadRoles, seedRoles, updateRole, sendTestEmail, updateMapping, getRoleByKey,
}
}
@@ -0,0 +1,75 @@
export interface OrgRole {
id: string
tenant_id: string
project_id: string | null
role_key: string
role_label: string
person_name: string | null
person_email: string | null
department: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface DefaultRole {
role_key: string
role_label: string
}
export interface DocumentReview {
id: string
tenant_id: string
project_id: string | null
document_type: string
document_title: string
document_content_hash: string | null
reviewer_role_key: string
reviewer_name: string | null
reviewer_email: string | null
status: 'pending' | 'in_review' | 'approved' | 'rejected'
submitted_at: string | null
submitted_by: string | null
reviewed_at: string | null
review_comment: string | null
review_link: string | null
email_sent: boolean
email_sent_at: string | null
created_at: string
updated_at: string
}
export interface RoleMapping {
id: string
tenant_id: string
document_type: string
role_key: string
is_primary: boolean
created_at: string
}
export interface ReviewStats {
pending?: number
in_review?: number
approved?: number
rejected?: number
}
export type RollenkonzeptTab = 'rollen' | 'zuordnung' | 'reviews'
export const ROLE_ICONS: Record<string, string> = {
dsb: '\u{1F6E1}\uFE0F',
gf: '\u{1F4BC}',
it_leiter: '\u{1F4BB}',
hr_leitung: '\u{1F465}',
marketing_leitung: '\u{1F4E3}',
compliance_beauftragter: '\u2696\uFE0F',
einkauf: '\u{1F6D2}',
}
export const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
in_review: { label: 'In Pruefung', color: 'bg-blue-100 text-blue-700' },
approved: { label: 'Freigegeben', color: 'bg-green-100 text-green-700' },
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
}
@@ -0,0 +1,140 @@
'use client'
import { useState } from 'react'
import { useOrgRoles } from './_hooks/useOrgRoles'
import { useDocumentReviews } from './_hooks/useDocumentReviews'
import { RoleCard } from './_components/RoleCard'
import { ReviewList } from './_components/ReviewList'
import type { RollenkonzeptTab } from './_types'
const TABS: { id: RollenkonzeptTab; label: string }[] = [
{ id: 'rollen', label: 'Rollen' },
{ id: 'zuordnung', label: 'Zuordnung' },
{ id: 'reviews', label: 'Reviews' },
]
export default function RollenkonzeptPage() {
const [tab, setTab] = useState<RollenkonzeptTab>('rollen')
const { roles, defaults, mapping, loading, seedRoles, updateRole, sendTestEmail } = useOrgRoles()
const reviewHook = useDocumentReviews()
// Merge defaults with actual roles
const mergedRoles = defaults.map(d => {
const actual = roles.find(r => r.role_key === d.role_key)
return actual || d
})
// Group mapping by role
const mappingByRole: Record<string, string[]> = {}
for (const m of mapping) {
if (!mappingByRole[m.role_key]) mappingByRole[m.role_key] = []
mappingByRole[m.role_key].push(m.document_type)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Rollenkonzept</h1>
<p className="mt-1 text-gray-500">
Weisen Sie Compliance-Rollen zu und verwalten Sie den Dokumenten-Pruefprozess.
</p>
</div>
{roles.length === 0 && !loading && (
<button onClick={seedRoles}
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700">
Standard-Rollen anlegen
</button>
)}
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === t.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
}`}>
{t.label}
{t.id === 'reviews' && (reviewHook.stats.pending || 0) > 0 && (
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-orange-100 text-orange-600 rounded-full">
{reviewHook.stats.pending}
</span>
)}
</button>
))}
</div>
{/* Tab: Rollen */}
{tab === 'rollen' && (
<div>
{loading ? (
<div className="text-center py-12 text-gray-400">Lade Rollen...</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{mergedRoles.map(role => (
<RoleCard key={role.role_key} role={role} onSave={updateRole} onSendTest={sendTestEmail} />
))}
</div>
)}
</div>
)}
{/* Tab: Zuordnung */}
{tab === 'zuordnung' && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="font-semibold text-gray-900">Dokument Rollen-Zuordnung</h2>
<p className="text-xs text-gray-500 mt-1">
Zeigt welche Rolle welche Dokumente zur Pruefung erhaelt. Anpassbar pro Tenant.
</p>
</div>
{Object.keys(mappingByRole).length === 0 ? (
<div className="text-center py-8 text-gray-400">
Keine Zuordnung vorhanden. Bitte erst Standard-Rollen anlegen.
</div>
) : (
<div className="divide-y divide-gray-100">
{defaults.map(d => {
const docs = mappingByRole[d.role_key] || []
return (
<div key={d.role_key} className="px-6 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm text-gray-900">{d.role_label}</span>
<span className="text-xs text-gray-400">({docs.length} Dokumente)</span>
</div>
<div className="flex flex-wrap gap-1">
{docs.map(dt => (
<span key={dt} className="px-2 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded-full">
{dt.replace(/_/g, ' ')}
</span>
))}
{docs.length === 0 && (
<span className="text-xs text-gray-400 italic">Keine Dokumente zugeordnet</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* Tab: Reviews */}
{tab === 'reviews' && (
<ReviewList
reviews={reviewHook.reviews}
stats={reviewHook.stats}
loading={reviewHook.loading}
statusFilter={reviewHook.statusFilter}
onFilterChange={reviewHook.setStatusFilter}
onApprove={reviewHook.approveReview}
onReject={reviewHook.rejectReview}
onSendNotification={reviewHook.sendNotification}
/>
)}
</div>
)
}
@@ -1,5 +1,8 @@
/**
* SDK Flow Steps Paket 2: Analyse (seq 10001600)
* SDK Flow Steps Paket 2: Analyse (seq 10001400)
*
* Neue Reihenfolge: Requirements Controls Risks Checklist Report
* Evidence Paket 5 (Betrieb), AI Act Paket 1 (optional)
*/
import type { SDKFlowStep } from './types'
@@ -13,16 +16,16 @@ export const STEPS_ANALYSE: SDKFlowStep[] = [
checkpointId: 'CP-REQ',
checkpointType: 'REQUIRED',
checkpointReviewer: 'NONE',
description: 'Ableitung konkreter Compliance-Anforderungen aus den aktivierten Modulen, mit RAG-Anreicherung.',
descriptionLong: 'Aus den aktivierten Modulen und der Source Policy werden konkrete, umsetzbare Compliance-Anforderungen abgeleitet. Vollstaendige CRUD-Operationen (Erstellen, Lesen, Aktualisieren, Loeschen) mit Backend-Persistenz. Die RAG-Collections bp_compliance_recht (DE-Gesetze) und bp_compliance_ce (EU-Verordnungen) liefern aktuelle Rechtstexte, aus denen spezifische Pflichten extrahiert werden. Die KI-gestuetzte Interpretation (interpret_requirement) und Control-Vorschlaege (suggest_controls) werden mit RAG-Kontext angereichert — die Collection wird automatisch anhand des Regulation-Codes gewaehlt (EU → bp_compliance_ce, DE → bp_compliance_recht). Status-Workflow: NOT_STARTED → IN_PROGRESS → IMPLEMENTED → VERIFIED mit automatischem Rollback bei Backend-Fehler. Filterung, Volltextsuche und Paginierung fuer grosse Datensaetze (500+ Anforderungen).',
description: 'Ableitung konkreter Compliance-Anforderungen aus den im Scope erkannten Regulierungen.',
descriptionLong: 'Aus den im Scope-Profiling erkannten Regulierungen (DSGVO, AI Act, NIS2) werden konkrete, umsetzbare Compliance-Anforderungen abgeleitet. Vollstaendige CRUD-Operationen mit Backend-Persistenz. Die RAG-Collections liefern aktuelle Rechtstexte, aus denen spezifische Pflichten extrahiert werden. KI-gestuetzte Interpretation und Control-Vorschlaege. Status-Workflow: NOT_STARTED → IN_PROGRESS → IMPLEMENTED → VERIFIED.',
legalBasis: 'Art. 5, 24, 25 DSGVO (Rechenschaftspflicht)',
inputs: ['modules', 'sourcePolicy'],
inputs: ['scopeDecision', 'companyProfile'],
outputs: ['requirements'],
prerequisiteSteps: ['source-policy'],
prerequisiteSteps: ['compliance-scope'],
dbTables: ['compliance_requirements'],
dbMode: 'read/write',
ragCollections: ['bp_compliance_recht', 'bp_compliance_ce'],
ragPurpose: 'Rechtliche Anforderungen ableiten + AI-Interpretation mit Rechtskontext anreichern (Collection-Routing: EU→ce, DE→recht)',
ragPurpose: 'Rechtliche Anforderungen ableiten + AI-Interpretation mit Rechtskontext anreichern',
isOptional: false,
url: '/sdk/requirements',
},
@@ -36,7 +39,7 @@ export const STEPS_ANALYSE: SDKFlowStep[] = [
checkpointType: 'REQUIRED',
checkpointReviewer: 'DSB',
description: 'Definition technischer und organisatorischer Kontrollen zur Erfuellung der Anforderungen.',
descriptionLong: 'Fuer jede Compliance-Anforderung werden konkrete Controls (Kontrollmassnahmen) definiert. Controls sind technische oder organisatorische Massnahmen, die sicherstellen, dass eine Anforderung erfuellt wird. Beispiele: Zugriffskontrolle (RBAC), Verschluesselung (AES-256), Logging, Schulungspflichten. Evidence-Linking: Jeder Control zeigt verknuepfte Nachweise mit Gueltigkeits-Badge an. Navigation zur Evidence-Seite mit vorausgewaehltem Control. Domaenen-basierte Gruppierung (gov, priv, iam, crypto, sdlc, ops, ai, cra, aud). Review-Workflow mit Verantwortlichem und naechstem Review-Datum. Der DSB muss diesen Schritt freigeben.',
descriptionLong: 'Fuer jede Compliance-Anforderung werden konkrete Controls (Kontrollmassnahmen) definiert. Controls sind technische oder organisatorische Massnahmen, die sicherstellen, dass eine Anforderung erfuellt wird. Beispiele: Zugriffskontrolle (RBAC), Verschluesselung (AES-256), Logging, Schulungspflichten. Evidence-Linking: Jeder Control zeigt verknuepfte Nachweise an. Domaenen-basierte Gruppierung. Review-Workflow mit Verantwortlichem. Der DSB muss diesen Schritt freigeben.',
legalBasis: 'Art. 32 DSGVO (Sicherheit der Verarbeitung)',
inputs: ['requirements'],
outputs: ['controls'],
@@ -47,84 +50,41 @@ export const STEPS_ANALYSE: SDKFlowStep[] = [
isOptional: false,
url: '/sdk/controls',
},
{
id: 'evidence',
name: 'Evidence',
nameShort: 'Nachweise',
package: 'analyse',
seq: 1200,
checkpointId: 'CP-EVI',
checkpointType: 'RECOMMENDED',
checkpointReviewer: 'NONE',
description: 'Sammlung und Verwaltung von Nachweisen fuer die Umsetzung der Controls.',
descriptionLong: 'Fuer jeden Control wird dokumentiert, wie und wann er umgesetzt wurde. Evidence (Nachweise) koennen Screenshots, Konfigurationsdateien, Audit-Logs, Schulungszertifikate oder Testprotokolle sein. Server-seitige Pagination (page, limit Query-Parameter) fuer grosse Nachweis-Sammlungen. Gueltigkeits-Tracking (valid_from, valid_until) mit Status: valid, expired, pending, failed. Verknuepfung mit Controls und Upload von Dateien als Nachweise. Essentiell fuer Audits und Zertifizierungen.',
legalBasis: 'Art. 5 Abs. 2 DSGVO (Rechenschaftspflicht)',
inputs: ['controls'],
outputs: ['evidence'],
prerequisiteSteps: ['controls'],
dbTables: ['compliance_evidence'],
dbMode: 'write',
ragCollections: [],
isOptional: false,
url: '/sdk/evidence',
},
{
id: 'risks',
name: 'Risk Matrix',
nameShort: 'Risiken',
package: 'analyse',
seq: 1300,
seq: 1200,
checkpointId: 'CP-RISK',
checkpointType: 'REQUIRED',
checkpointReviewer: 'DSB',
description: 'Bewertung aller Datenschutz- und Compliance-Risiken in einer Risikomatrix.',
descriptionLong: 'Die 5x5 Risikomatrix bewertet jedes Risiko nach Eintrittswahrscheinlichkeit und Schadenshoehe. Inherent Risk vs. Residual Risk mit visuellem Vergleich. Status-Workflow: IDENTIFIED → ASSESSED → MITIGATED → ACCEPTED → CLOSED. Expandierbare Mitigations-Sektion pro Risiko mit verknuepften Controls (Name, Status, Effectiveness). Automatische Risiko-Level-Berechnung: Score = Likelihood × Impact (LOW <6, MEDIUM 6-11, HIGH 12-19, CRITICAL ≥20). Der DSB muss die Risikobewertung freigeben.',
description: 'Bewertung aller Datenschutz- und Compliance-Risiken — wo sind Luecken?',
descriptionLong: 'Die 5x5 Risikomatrix bewertet jedes Risiko nach Eintrittswahrscheinlichkeit und Schadenshoehe. Inherent Risk vs. Residual Risk mit visuellem Vergleich. Status-Workflow: IDENTIFIED → ASSESSED → MITIGATED → ACCEPTED → CLOSED. Expandierbare Mitigations-Sektion pro Risiko mit verknuepften Controls. Automatische Risiko-Level-Berechnung: Score = Likelihood × Impact. Der DSB muss die Risikobewertung freigeben.',
legalBasis: 'Art. 35 DSGVO (Datenschutz-Folgenabschaetzung)',
inputs: ['controls', 'modules'],
inputs: ['controls'],
outputs: ['risks'],
prerequisiteSteps: ['evidence'],
prerequisiteSteps: ['controls'],
dbTables: ['compliance_risks'],
dbMode: 'write',
ragCollections: [],
isOptional: false,
url: '/sdk/risks',
},
{
id: 'ai-act',
name: 'AI Act Klassifizierung',
nameShort: 'AI Act',
package: 'analyse',
seq: 1400,
checkpointId: 'CP-AI',
checkpointType: 'REQUIRED',
checkpointReviewer: 'LEGAL',
description: 'Klassifizierung aller KI-Systeme nach EU AI Act Risikokategorien.',
descriptionLong: 'KI-System-Registrierung mit vollstaendiger Backend-Persistenz (CRUD). Risikopyramide: Minimal → Begrenzt → Hoch → Verboten. KI-gestuetzte Risikobewertung mit Rule-Based-Fallback: Der Assess-Endpoint analysiert Zweck, Sektor und Beschreibung und leitet Klassifizierung + Pflichten automatisch ab. Fuer Hochrisiko-Systeme werden 8 AI Act Pflichten abgeleitet (Risikomanagement, Daten-Governance, Dokumentation, Transparenz, menschliche Aufsicht, Genauigkeit, Robustheit, Cybersicherheit). Filterung nach Klassifizierung, Status und Sektor. Die Rechtsabteilung (LEGAL) muss die Klassifizierung freigeben.',
legalBasis: 'EU AI Act Art. 6-9 (Risikokategorien)',
inputs: ['useCases', 'companyProfile'],
outputs: ['aiActClassification', 'obligations'],
prerequisiteSteps: ['risks'],
dbTables: ['compliance_ai_systems'],
dbMode: 'read/write',
ragCollections: ['bp_compliance_ce'],
ragPurpose: 'EU AI Act Regulierungstexte',
isOptional: false,
url: '/sdk/ai-act',
},
{
id: 'audit-checklist',
name: 'Audit Checklist',
nameShort: 'Checklist',
package: 'analyse',
seq: 1500,
seq: 1300,
checkpointId: 'CP-CHK',
checkpointType: 'RECOMMENDED',
checkpointReviewer: 'NONE',
description: 'Erstellung einer pruefbaren Checkliste fuer interne und externe Audits.',
descriptionLong: 'Aus den Requirements und Controls wird eine strukturierte Audit-Checkliste generiert. Session-Management: Draft → In Progress → Completed → Archived. Interaktiver Sign-Off-Workflow mit digitalem Signatur-Hash (SHA-256). PDF-Download in Deutsch oder Englisch. Session-History: Anzeige vergangener Audit-Sitzungen mit Status-Badges. JSON-Export der Checkliste. Die Checkliste kann fuer interne Self-Assessments oder als Vorbereitung auf externe Audits (ISO 27001, BSI Grundschutz) verwendet werden.',
inputs: ['requirements', 'controls'],
description: 'Pruefbare Checkliste aus Requirements + Controls + Risiken.',
descriptionLong: 'Aus den Requirements und Controls wird eine strukturierte Audit-Checkliste generiert. Session-Management: Draft → In Progress → Completed → Archived. Interaktiver Sign-Off-Workflow mit digitalem Signatur-Hash (SHA-256). PDF-Download in Deutsch oder Englisch. JSON-Export der Checkliste. Kann fuer interne Self-Assessments oder als Vorbereitung auf externe Audits verwendet werden.',
inputs: ['requirements', 'controls', 'risks'],
outputs: ['checklist'],
prerequisiteSteps: ['ai-act'],
prerequisiteSteps: ['risks'],
dbTables: ['compliance_audit_sessions', 'compliance_audit_signoffs'],
dbMode: 'read/write',
ragCollections: [],
@@ -136,13 +96,13 @@ export const STEPS_ANALYSE: SDKFlowStep[] = [
name: 'Audit Report',
nameShort: 'Report',
package: 'analyse',
seq: 1600,
seq: 1400,
checkpointId: 'CP-AREP',
checkpointType: 'REQUIRED',
checkpointReviewer: 'NONE',
description: 'Generierung eines vollstaendigen Audit-Reports mit Findings und Empfehlungen.',
descriptionLong: 'Der Audit Report fasst alle Ergebnisse der Analyse-Phase zusammen. Uebersicht aller Audit-Sitzungen mit Status-Badges. Detail-Seite pro Sitzung mit Session-Metadaten (Auditor, Zeitraum, Status), Fortschrittsbalken (konform/nicht konform/ausstehend), interaktiven Checklist-Items mit Sign-Off, Notizen-Bearbeitung pro Pruefpunkt und PDF-Download mit Sprachauswahl (DE/EN). Click-Navigation von der Uebersicht zur Detail-Seite. Dient als Nachweis gegenueber Aufsichtsbehoerden.',
inputs: ['checklist', 'controls', 'evidence'],
description: 'Zusammenfassender Audit-Report mit Findings und Empfehlungen.',
descriptionLong: 'Der Audit Report fasst alle Ergebnisse der Analyse-Phase zusammen. Uebersicht aller Audit-Sitzungen mit Status-Badges. Detail-Seite pro Sitzung mit Fortschrittsbalken, interaktiven Checklist-Items mit Sign-Off, Notizen-Bearbeitung und PDF-Download (DE/EN). Dient als Nachweis gegenueber Aufsichtsbehoerden.',
inputs: ['checklist', 'controls', 'risks'],
outputs: ['auditReport'],
prerequisiteSteps: ['audit-checklist'],
dbTables: ['compliance_audit_sessions'],
@@ -4,6 +4,27 @@
import type { SDKFlowStep } from './types'
export const STEPS_BETRIEB: SDKFlowStep[] = [
{
id: 'evidence',
name: 'Evidence',
nameShort: 'Nachweise',
package: 'betrieb',
seq: 3900,
checkpointId: 'CP-EVI',
checkpointType: 'RECOMMENDED',
checkpointReviewer: 'NONE',
description: 'Laufende Sammlung und Verwaltung von Nachweisen fuer die Umsetzung der Controls.',
descriptionLong: 'Fuer jeden Control wird dokumentiert, wie und wann er umgesetzt wurde. Evidence koennen Screenshots, Konfigurationsdateien, Audit-Logs, Schulungszertifikate oder Testprotokolle sein. Gueltigkeits-Tracking (valid_from, valid_until) mit Status: valid, expired, pending, failed. Verknuepfung mit Controls. Nachweise werden laufend im Betrieb gesammelt, nicht einmalig waehrend der Analyse.',
legalBasis: 'Art. 5 Abs. 2 DSGVO (Rechenschaftspflicht)',
inputs: ['controls'],
outputs: ['evidence'],
prerequisiteSteps: ['audit-report'],
dbTables: ['compliance_evidence'],
dbMode: 'write',
ragCollections: [],
isOptional: false,
url: '/sdk/evidence',
},
{
id: 'dsr',
name: 'DSR Portal',
@@ -341,4 +362,26 @@ export const STEPS_BETRIEB: SDKFlowStep[] = [
url: '/sdk/control-library',
completion: 100,
},
{
id: 'compliance-agent',
name: 'Compliance Agent',
nameShort: 'Agent',
package: 'betrieb',
seq: 5000,
checkpointId: 'CP-AGENT',
checkpointType: 'OPTIONAL',
checkpointReviewer: 'NONE',
description: 'Automatische Website-Analyse auf DSGVO-Konformitaet mit 3 Modi: Schnellanalyse, Website-Scan und Cookie-Consent-Test.',
descriptionLong: 'Der Compliance Agent analysiert Websites und Dokumente automatisch auf DSGVO-Konformitaet. Drei Modi: (1) Schnellanalyse — einzelne URL klassifizieren und bewerten via Qwen LLM + UCCA Assessment. (2) Website-Scan — 5-10 Unterseiten crawlen, 82 Drittanbieter-Dienste erkennen, SOLL/IST-Abgleich gegen Datenschutzerklaerung, Pflichtinhalte pruefen (Art. 13 DSGVO, §5 TMG). (3) Cookie-Consent-Test — Playwright Headless Browser testet was VOR und NACH Cookie-Einwilligung geladen wird (§25 TDDDG). Pre-Launch-Modus fuer interne Dokumente mit einbaufertigen Korrekturvorschlaegen. Post-Launch-Modus mit Abmahnrisiko-Warnungen. Textblock-Referenzierung zeigt Originaltext, Position in der DSE und Korrekturvorschlag. Email-Benachrichtigung an zustaendige Rolle.',
legalBasis: 'Art. 5, 13, 25 DSGVO, §5 TMG, §25 TDDDG, §312k BGB',
inputs: [],
outputs: ['scanResults', 'findings', 'corrections'],
prerequisiteSteps: [],
dbTables: [],
dbMode: 'none',
ragCollections: [],
isOptional: true,
url: '/sdk/agent',
completion: 80,
},
]
@@ -124,27 +124,7 @@ export const STEPS_VORBEREITUNG: SDKFlowStep[] = [
isOptional: false,
url: '/sdk/screening',
},
{
id: 'modules',
name: 'Compliance Modules',
nameShort: 'Module',
package: 'vorbereitung',
seq: 600,
checkpointId: 'CP-MOD',
checkpointType: 'REQUIRED',
checkpointReviewer: 'NONE',
description: 'Aktivierung der relevanten Compliance-Module basierend auf Screening-Ergebnissen.',
descriptionLong: 'Basierend auf dem Unternehmensprofil und den Screening-Ergebnissen werden die relevanten Compliance-Module aktiviert. Module umfassen z.B. DSGVO-Grundschutz, AI Act, NIS2, ePrivacy, Whistleblower-Richtlinie usw. Die RAG-Collection bp_compliance_gesetze wird verwendet, um aktuelle Gesetzestexte den Modulen zuzuordnen. Nur aktivierte Module erzeugen in den nachfolgenden Schritten Anforderungen, Controls und Dokumentation.',
inputs: ['companyProfile', 'screening'],
outputs: ['modules'],
prerequisiteSteps: ['screening'],
dbTables: ['compliance_service_modules', 'sdk_states'],
dbMode: 'read/write',
ragCollections: ['bp_compliance_gesetze'],
ragPurpose: 'Regulierungen den Modulen zuordnen',
isOptional: false,
url: '/sdk/modules',
},
// Modules entfernt — Regulierungen im Scope-Decision-Tab
{
id: 'source-policy',
name: 'Source Policy',
@@ -162,7 +142,29 @@ export const STEPS_VORBEREITUNG: SDKFlowStep[] = [
dbTables: ['compliance_allowed_sources', 'compliance_pii_rules', 'compliance_source_operations', 'compliance_source_policy_audit'],
dbMode: 'read/write',
ragCollections: [],
isOptional: false,
isOptional: true,
url: '/sdk/source-policy',
},
{
id: 'ai-act',
name: 'AI Act Klassifizierung',
nameShort: 'AI Act',
package: 'vorbereitung',
seq: 350,
checkpointId: 'CP-AI',
checkpointType: 'RECOMMENDED',
checkpointReviewer: 'LEGAL',
description: 'Klassifizierung aller KI-Systeme nach EU AI Act Risikokategorien (nur bei KI-Einsatz).',
descriptionLong: 'KI-System-Registrierung mit Risikopyramide: Minimal → Begrenzt → Hoch → Verboten. KI-gestuetzte Risikobewertung. Fuer Hochrisiko-Systeme werden 8 AI Act Pflichten abgeleitet. Nur relevant wenn KI-Systeme im Einsatz sind.',
legalBasis: 'EU AI Act Art. 6-9 (Risikokategorien)',
inputs: ['useCases', 'companyProfile'],
outputs: ['aiActClassification'],
prerequisiteSteps: ['use-case-assessment'],
dbTables: ['compliance_ai_systems'],
dbMode: 'read/write',
ragCollections: ['bp_compliance_ce'],
ragPurpose: 'EU AI Act Regulierungstexte',
isOptional: true,
url: '/sdk/ai-act',
},
]
+77 -311
View File
@@ -1,364 +1,130 @@
'use client'
/**
* Source Policy Management Page (SDK Version)
* Source Policy Quellen-Whitelist fuer RAG Pipeline
*
* Whitelist-based data source management for compliance RAG corpus.
* Controls which legal sources may be used, PII rules, and audit trail.
* Kontrolliert welche externen Quellen (Rechtstexte, Standards, Leitfaeden)
* in die lokale Knowledge Base ingested werden duerfen.
* Enterprise-Feature fuer Kanzleien/Unternehmen mit eigenem RAG-System.
*/
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import StepHeader from '@/components/sdk/StepHeader/StepHeader'
import { SourcesTab } from '@/components/sdk/source-policy/SourcesTab'
import { OperationsMatrixTab } from '@/components/sdk/source-policy/OperationsMatrixTab'
import { PIIRulesTab } from '@/components/sdk/source-policy/PIIRulesTab'
import { AuditTab } from '@/components/sdk/source-policy/AuditTab'
// API base URL — now uses Next.js proxy routes
const API_BASE = '/api/sdk/v1/source-policy'
interface PolicyStats {
active_policies: number
allowed_sources: number
pii_rules: number
blocked_today: number
blocked_total: number
}
type TabId = 'dashboard' | 'sources' | 'operations' | 'pii' | 'audit' | 'blocked'
interface BlockedContent {
id: string
content_type: string
pattern: string
reason: string
blocked_at: string
source?: string
}
type TabId = 'overview' | 'sources' | 'operations'
export default function SourcePolicyPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [stats, setStats] = useState<PolicyStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [blockedContent, setBlockedContent] = useState<BlockedContent[]>([])
const [blockedLoading, setBlockedLoading] = useState(false)
useEffect(() => {
fetchStats()
fetch(`${API_BASE}/policy-stats`)
.then(r => r.ok ? r.json() : null)
.then(data => setStats(data))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (activeTab === 'blocked') {
fetchBlockedContent()
}
}, [activeTab])
const fetchBlockedContent = async () => {
setBlockedLoading(true)
try {
const res = await fetch(`${API_BASE}/blocked-content`)
if (res.ok) {
const data = await res.json()
setBlockedContent(Array.isArray(data) ? data : (data.items || []))
}
} catch {
// silently ignore — empty state shown
} finally {
setBlockedLoading(false)
}
}
const handleRemoveBlocked = async (id: string) => {
try {
await fetch(`${API_BASE}/blocked-content/${id}`, { method: 'DELETE' })
setBlockedContent(prev => prev.filter(item => item.id !== id))
} catch {
// ignore
}
}
const fetchStats = async () => {
try {
setLoading(true)
const res = await fetch(`${API_BASE}/policy-stats`)
if (!res.ok) {
throw new Error('Fehler beim Laden der Statistiken')
}
const data = await res.json()
setStats(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
setStats({
active_policies: 0,
allowed_sources: 0,
pii_rules: 0,
blocked_today: 0,
blocked_total: 0,
})
} finally {
setLoading(false)
}
}
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'dashboard',
name: 'Dashboard',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
),
},
{
id: 'sources',
name: 'Quellen',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
),
},
{
id: 'operations',
name: 'Operations',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
},
{
id: 'pii',
name: 'PII-Regeln',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
),
},
{
id: 'audit',
name: 'Audit',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 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>
),
},
{
id: 'blocked',
name: 'Blockierte Inhalte',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
),
},
const tabs: { id: TabId; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'sources', label: 'Quellen-Whitelist' },
{ id: 'operations', label: 'Berechtigungen' },
]
return (
<div className="space-y-6">
<StepHeader stepId="source-policy" showProgress={true} />
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Quellen-Verwaltung</h1>
<p className="mt-1 text-gray-500">
Definieren Sie welche externen Quellen in Ihre Knowledge Base aufgenommen werden duerfen.
</p>
</div>
)}
{/* Stats Cards */}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats.active_policies}</div>
<div className="text-sm text-slate-500">Aktive Policies</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-3xl font-bold text-green-600">{stats.allowed_sources}</div>
<div className="text-sm text-gray-500 mt-1">Zugelassene Quellen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.allowed_sources}</div>
<div className="text-sm text-slate-500">Zugelassene Quellen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-red-600">{stats.blocked_today}</div>
<div className="text-sm text-slate-500">Blockiert (heute)</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.pii_rules}</div>
<div className="text-sm text-slate-500">PII-Regeln</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-3xl font-bold text-purple-600">{stats.active_policies}</div>
<div className="text-sm text-gray-500 mt-1">Aktive Policies</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
activeTab === tab.id
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{tab.icon}
{tab.name}
<div className="flex border-b border-gray-200">
{tabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
}`}>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<>
{activeTab === 'dashboard' && stats && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quellen-Uebersicht</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Zugelassene Quellen</span>
<span className="text-2xl font-bold text-green-600">{stats.allowed_sources}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Aktive Policies</span>
<span className="text-2xl font-bold text-purple-600">{stats.active_policies}</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${stats.allowed_sources > 0 ? Math.min((stats.active_policies / stats.allowed_sources) * 100, 100) : 0}%` }}
/>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Datenschutz-Regeln</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">PII-Regeln aktiv</span>
<span className="text-2xl font-bold text-blue-600">{stats.pii_rules}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Blockiert (heute)</span>
<span className={`text-2xl font-bold ${stats.blocked_today > 0 ? 'text-red-600' : 'text-green-600'}`}>
{stats.blocked_today}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Blockiert (gesamt)</span>
<span className="text-lg font-semibold text-gray-500">{stats.blocked_total}</span>
</div>
</div>
</div>
<div className="md:col-span-2 bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Status</h3>
<div className="flex items-center gap-6">
<div className="flex-1 text-center p-4 bg-green-50 rounded-lg">
<div className="text-sm text-green-700 font-medium">Quellen konfiguriert</div>
<div className="text-3xl font-bold text-green-600 mt-1">
{stats.allowed_sources > 0 ? 'Ja' : 'Nein'}
</div>
</div>
<div className="flex-1 text-center p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-700 font-medium">PII-Schutz aktiv</div>
<div className="text-3xl font-bold text-blue-600 mt-1">
{stats.pii_rules > 0 ? 'Ja' : 'Nein'}
</div>
</div>
<div className="flex-1 text-center p-4 bg-purple-50 rounded-lg">
<div className="text-sm text-purple-700 font-medium">Policies definiert</div>
<div className="text-3xl font-bold text-purple-600 mt-1">
{stats.active_policies > 0 ? 'Ja' : 'Nein'}
{/* Tab: Uebersicht */}
{activeTab === 'overview' && (
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="font-semibold text-gray-900">Was ist die Quellen-Verwaltung?</h3>
<p className="text-sm text-gray-600">
Die Quellen-Verwaltung kontrolliert welche externen Dokumente und Rechtsquellen
in Ihre lokale RAG-Pipeline (Knowledge Base) aufgenommen werden duerfen.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div className="bg-green-50 rounded-lg p-4">
<h4 className="font-medium text-green-800 text-sm">Quellen-Whitelist</h4>
<p className="text-xs text-green-700 mt-1">
Definieren Sie vertrauenswuerdige Quellen: EUR-Lex, BSI Grundschutz,
Behoerden-Leitfaeden, eigene Dokumente.
</p>
</div>
<div className="bg-blue-50 rounded-lg p-4">
<h4 className="font-medium text-blue-800 text-sm">Berechtigungen</h4>
<p className="text-xs text-blue-700 mt-1">
Pro Quelle festlegen: Darf sie ingested, durchsucht, exportiert
oder mit Dritten geteilt werden?
</p>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<h4 className="font-medium text-purple-800 text-sm">Lizenzen</h4>
<p className="text-xs text-purple-700 mt-1">
Jede Quelle wird mit Lizenzinformationen versehen
(DL-DE-BY, CC-BY, CC0, eigene Dokumente).
</p>
</div>
</div>
{stats && stats.allowed_sources === 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-4">
<p className="text-sm text-amber-800">
<strong>Noch keine Quellen konfiguriert.</strong> Wechseln Sie zum Tab "Quellen-Whitelist"
um Ihre ersten Quellen hinzuzufuegen.
</p>
</div>
)}
{activeTab === 'dashboard' && !stats && loading && (
<div className="text-center py-12 text-slate-500">Lade Dashboard...</div>
</div>
)}
{activeTab === 'sources' && <SourcesTab apiBase={API_BASE} onUpdate={fetchStats} />}
{/* Tab: Quellen */}
{activeTab === 'sources' && <SourcesTab apiBase={API_BASE} onUpdate={() => {
fetch(`${API_BASE}/policy-stats`).then(r => r.ok ? r.json() : null).then(setStats).catch(() => {})
}} />}
{/* Tab: Berechtigungen */}
{activeTab === 'operations' && <OperationsMatrixTab apiBase={API_BASE} />}
{activeTab === 'pii' && <PIIRulesTab apiBase={API_BASE} onUpdate={fetchStats} />}
{activeTab === 'audit' && <AuditTab apiBase={API_BASE} />}
{activeTab === 'blocked' && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Blockierte Inhalte</h3>
<button
onClick={fetchBlockedContent}
className="text-sm text-purple-600 hover:text-purple-700"
>
Aktualisieren
</button>
</div>
{blockedLoading ? (
<div className="p-8 text-center text-gray-500">Lade blockierte Inhalte...</div>
) : blockedContent.length === 0 ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-gray-500 text-sm">Keine blockierten Inhalte vorhanden.</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 text-gray-600 font-medium">Typ</th>
<th className="text-left px-4 py-3 text-gray-600 font-medium">Muster / Pattern</th>
<th className="text-left px-4 py-3 text-gray-600 font-medium">Grund</th>
<th className="text-left px-4 py-3 text-gray-600 font-medium">Blockiert am</th>
<th className="text-left px-4 py-3 text-gray-600 font-medium">Quelle</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{blockedContent.map(item => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs font-medium">
{item.content_type}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-700 max-w-xs truncate">
{item.pattern}
</td>
<td className="px-4 py-3 text-gray-600">{item.reason}</td>
<td className="px-4 py-3 text-gray-500">
{new Date(item.blocked_at).toLocaleDateString('de-DE')}
</td>
<td className="px-4 py-3 text-gray-500">{item.source || '—'}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleRemoveBlocked(item.id)}
className="text-red-500 hover:text-red-700 text-xs px-2 py-1 rounded hover:bg-red-50"
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</>
</div>
)
}
@@ -48,6 +48,15 @@ const navItems: NavItem[] = [
</svg>
),
},
{
href: '/sdk/vendor-compliance/transfers',
label: 'Drittlandtransfers',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
href: '/sdk/vendor-compliance/risks',
label: 'Risiken',
@@ -0,0 +1,340 @@
'use client'
import { useMemo, useState } from 'react'
import { useVendorCompliance } from '@/lib/sdk/vendor-compliance'
import { getTransferRequirement, ADEQUACY_DECISIONS, type AdequacyDecision } from '@/lib/sdk/vendor-compliance/adequacy-decisions'
import Link from 'next/link'
// ============================================================================
// Types
// ============================================================================
interface TransferEntry {
vendorId: string
vendorName: string
country: string
isEU: boolean
isAdequate: boolean
mechanisms: string[]
hasSCC: boolean
hasTIA: boolean
status: 'green' | 'yellow' | 'red'
statusLabel: string
}
// ============================================================================
// Helpers
// ============================================================================
function getTransferStatus(
isEU: boolean,
isAdequate: boolean,
mechanisms: string[],
hasSCC: boolean,
): { status: 'green' | 'yellow' | 'red'; label: string } {
if (isEU) return { status: 'green', label: 'EU/EWR' }
if (isAdequate) return { status: 'green', label: 'Angemessenheitsbeschluss' }
if (hasSCC && mechanisms.length > 0) return { status: 'yellow', label: 'SCC vorhanden' }
if (mechanisms.length > 0) return { status: 'yellow', label: 'Mechanismus vorhanden' }
return { status: 'red', label: 'Kein Transfermechanismus' }
}
const statusColors = {
green: 'bg-green-100 text-green-800',
yellow: 'bg-amber-100 text-amber-800',
red: 'bg-red-100 text-red-800',
}
const statusDots = {
green: 'bg-green-500',
yellow: 'bg-amber-500',
red: 'bg-red-500',
}
// ============================================================================
// Component
// ============================================================================
export default function TransfersPage() {
const { vendors, contracts } = useVendorCompliance()
const [filter, setFilter] = useState<'all' | 'green' | 'yellow' | 'red'>('all')
// Build transfer entries from vendors with non-EU processing locations
const transfers = useMemo<TransferEntry[]>(() => {
if (!vendors) return []
const entries: TransferEntry[] = []
for (const vendor of vendors) {
const locations = (vendor as Record<string, unknown>).processingLocations as Array<{
country: string; isEU: boolean; isAdequate: boolean
}> || []
const mechanisms = (vendor as Record<string, unknown>).transferMechanisms as string[] || []
// Check if vendor has any SCC contract
const vendorContracts = (contracts || []).filter(
(c: Record<string, unknown>) => c.vendorId === vendor.id
)
const hasSCC = vendorContracts.some(
(c: Record<string, unknown>) => c.documentType === 'SCC'
)
for (const loc of locations) {
const { status, label } = getTransferStatus(loc.isEU, loc.isAdequate, mechanisms, hasSCC)
entries.push({
vendorId: vendor.id,
vendorName: vendor.name,
country: loc.country,
isEU: loc.isEU,
isAdequate: loc.isAdequate,
mechanisms,
hasSCC,
hasTIA: false, // TODO: Check if TIA document exists for this vendor
status,
statusLabel: label,
})
}
}
return entries
}, [vendors, contracts])
// Filter
const filtered = filter === 'all'
? transfers
: transfers.filter((t) => t.status === filter)
// Stats
const stats = useMemo(() => ({
total: transfers.length,
eu: transfers.filter((t) => t.isEU).length,
adequate: transfers.filter((t) => !t.isEU && t.isAdequate).length,
thirdCountry: transfers.filter((t) => !t.isEU && !t.isAdequate).length,
red: transfers.filter((t) => t.status === 'red').length,
}), [transfers])
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">Drittlandtransfers</h2>
<p className="text-sm text-gray-500 mt-1">
Uebersicht aller Datenuebermittlungen nach Verarbeitungsstandort. Art. 44-49 DSGVO.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Gesamt</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-4">
<div className="text-xs text-green-600 uppercase tracking-wide">EU/EWR + Adequat</div>
<div className="text-2xl font-bold text-green-700 mt-1">{stats.eu + stats.adequate}</div>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-4">
<div className="text-xs text-amber-600 uppercase tracking-wide">Drittland (mit Mechanismus)</div>
<div className="text-2xl font-bold text-amber-700 mt-1">{stats.thirdCountry - stats.red}</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-4">
<div className="text-xs text-red-600 uppercase tracking-wide">Handlungsbedarf</div>
<div className="text-2xl font-bold text-red-700 mt-1">{stats.red}</div>
</div>
</div>
{/* Filter */}
<div className="flex gap-2">
{(['all', 'green', 'yellow', 'red'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-gray-900 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' && 'Alle'}
{f === 'green' && 'OK'}
{f === 'yellow' && 'Pruefen'}
{f === 'red' && 'Handlungsbedarf'}
</button>
))}
</div>
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-500">Status</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Vendor</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Land</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Mechanismus</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">SCC</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">TIA</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
{transfers.length === 0
? 'Keine Vendors mit Verarbeitungsstandorten erfasst.'
: 'Keine Eintraege fuer den gewaehlten Filter.'}
</td>
</tr>
) : (
filtered.map((t, i) => (
<tr key={`${t.vendorId}-${t.country}-${i}`} className="hover:bg-gray-50">
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status]}`}>
<span className={`w-2 h-2 rounded-full ${statusDots[t.status]}`} />
{t.statusLabel}
</span>
</td>
<td className="px-4 py-3 font-medium text-gray-900">
<Link
href={`/sdk/vendor-compliance/vendors?id=${t.vendorId}`}
className="hover:text-blue-600"
>
{t.vendorName}
</Link>
</td>
<td className="px-4 py-3 text-gray-600">
{t.country}
{t.isEU && <span className="ml-1 text-xs text-green-600">(EU)</span>}
</td>
<td className="px-4 py-3 text-gray-600">
{t.mechanisms.length > 0
? t.mechanisms.join(', ')
: <span className="text-red-500">Keiner</span>}
</td>
<td className="px-4 py-3">
{t.hasSCC
? <span className="text-green-600">Vorhanden</span>
: <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-3">
{t.hasTIA
? <span className="text-green-600">Vorhanden</span>
: !t.isEU && !t.isAdequate
? <span className="text-red-500">Fehlt</span>
: <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-3">
{!t.isEU && !t.isAdequate && (
<Link
href="/sdk/document-generator"
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
>
TIA erstellen
</Link>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Explanation: What do I need to do? */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Was muss ich tun?</h3>
<p className="text-sm text-gray-600">
Wenn Ihr Unternehmen personenbezogene Daten an Empfaenger ausserhalb der EU/des EWR uebermittelt,
muessen Sie sicherstellen, dass ein angemessenes Datenschutzniveau besteht. Es gibt drei Wege:
</p>
<div className="grid md:grid-cols-3 gap-4">
{/* Option 1: Adequacy */}
<div className="border border-green-200 rounded-lg p-4 bg-green-50">
<div className="flex items-center gap-2 mb-2">
<span className="w-3 h-3 rounded-full bg-green-500" />
<span className="font-medium text-green-800">Angemessenheitsbeschluss</span>
</div>
<p className="text-xs text-green-700">
Die EU-Kommission hat fuer bestimmte Laender festgestellt, dass ein angemessenes Datenschutzniveau
besteht. Fuer diese Laender sind <strong>keine SCC und kein TIA erforderlich</strong>.
</p>
</div>
{/* Option 2: DPF */}
<div className="border border-blue-200 rounded-lg p-4 bg-blue-50">
<div className="flex items-center gap-2 mb-2">
<span className="w-3 h-3 rounded-full bg-blue-500" />
<span className="font-medium text-blue-800">DPF-Zertifizierung (nur USA)</span>
</div>
<p className="text-xs text-blue-700">
US-Unternehmen koennen sich nach dem <strong>EU-US Data Privacy Framework</strong> zertifizieren.
Pruefen Sie unter{' '}
<a href="https://www.dataprivacyframework.gov/list" target="_blank" rel="noopener noreferrer" className="underline">
dataprivacyframework.gov
</a>{' '}
ob Ihr US-Dienstleister zertifiziert ist. Falls ja: <strong>keine SCC/TIA noetig</strong>.
</p>
</div>
{/* Option 3: SCC + TIA */}
<div className="border border-amber-200 rounded-lg p-4 bg-amber-50">
<div className="flex items-center gap-2 mb-2">
<span className="w-3 h-3 rounded-full bg-amber-500" />
<span className="font-medium text-amber-800">SCC + TIA erforderlich</span>
</div>
<p className="text-xs text-amber-700">
Fuer alle anderen Drittlaender muessen Sie <strong>EU-Standardvertragsklauseln (SCC)</strong> abschliessen
und ein <strong>Transfer Impact Assessment (TIA)</strong> durchfuehren. Beides finden Sie im{' '}
<Link href="/sdk/document-generator" className="underline">Document Generator</Link> unter "Drittlandtransfer".
</p>
</div>
</div>
</div>
{/* Adequacy countries list */}
<details className="bg-white rounded-xl border border-gray-200">
<summary className="px-6 py-4 cursor-pointer text-sm font-medium text-gray-700 hover:text-purple-600">
Laender mit Angemessenheitsbeschluss anzeigen ({ADEQUACY_DECISIONS.length} Laender)
</summary>
<div className="px-6 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left py-2 font-medium text-gray-500">Land</th>
<th className="text-left py-2 font-medium text-gray-500">Seit</th>
<th className="text-left py-2 font-medium text-gray-500">Einschraenkung</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{ADEQUACY_DECISIONS.map((d: AdequacyDecision) => (
<tr key={d.countryCode}>
<td className="py-2 text-gray-900">
{d.countryName}
{d.requiresCertification && (
<span className="ml-2 text-xs text-blue-600 font-medium">Zertifizierung erforderlich</span>
)}
</td>
<td className="py-2 text-gray-600">{d.since}</td>
<td className="py-2 text-gray-500 text-xs">
{d.restriction || d.expires || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
{/* Schrems II info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<strong>Hintergrund EuGH Schrems II:</strong> Der EuGH hat 2020 das EU-US Privacy Shield fuer ungueltig erklaert
und klargestellt, dass bei Drittlandtransfers immer geprueft werden muss, ob die Gesetze des Empfaengerstaats
den Schutz der uebermittelten Daten beeintraechtigen (z.B. durch Massenueberwachung oder fehlende Rechtsbehelfe).
Das TIA dokumentiert genau diese Pruefung. Seit Juli 2023 gibt es mit dem EU-US Data Privacy Framework einen neuen
Angemessenheitsbeschluss fuer DPF-zertifizierte US-Unternehmen.
</div>
</div>
)
}
@@ -141,6 +141,54 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
setIsTyping(false)
}, [])
const [emailSending, setEmailSending] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const handleSendAsEmail = useCallback(async () => {
if (messages.length === 0 || emailSending) return
setEmailSending(true)
try {
// Build HTML from chat messages
const qaPairs = messages.reduce<{ q: string; a: string }[]>((acc, m, i) => {
if (m.role === 'user') {
const next = messages[i + 1]
acc.push({ q: m.content, a: next?.role === 'agent' ? next.content : '(keine Antwort)' })
}
return acc
}, [])
const qaHtml = qaPairs.map(({ q, a }) =>
`<div style="margin-bottom:16px;"><p style="font-weight:600;color:#1e293b;">Frage: ${q}</p><p style="color:#475569;white-space:pre-wrap;">${a}</p></div>`
).join('')
const bodyHtml = `
<h2 style="color:#1e293b;">Compliance Advisor Beratungsprotokoll</h2>
<p style="color:#64748b;font-size:13px;">Datum: ${new Date().toLocaleString('de-DE')} | Land: ${selectedCountry} | Kontext: ${currentStep}</p>
<hr style="border-color:#e2e8f0;margin:16px 0;">
${qaHtml}
<hr style="border-color:#e2e8f0;margin:16px 0;">
<p style="color:#94a3b8;font-size:11px;">Automatisch erstellt vom BreakPilot Compliance Advisor (Qwen)</p>
`
await fetch('/api/sdk/v1/agent/notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient: 'dsb@breakpilot.local',
subject: `Compliance Advisor — ${qaPairs.length} Fragen (${currentStep})`,
body_html: bodyHtml,
role: 'Datenschutzbeauftragter',
}),
})
setEmailSent(true)
setTimeout(() => setEmailSent(false), 3000)
} catch (e) {
console.error('Email send failed:', e)
} finally {
setEmailSending(false)
}
}, [messages, emailSending, selectedCountry, currentStep])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
@@ -188,6 +236,31 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
</div>
</div>
<div className="flex items-center gap-1">
{/* Send as Email */}
{messages.length > 0 && (
<button
onClick={handleSendAsEmail}
disabled={emailSending}
className={`text-white/80 hover:text-white transition-colors ${emailSent ? 'text-green-300' : ''}`}
aria-label="Als Email an DSB senden"
title={emailSent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
>
{emailSent ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : emailSending ? (
<svg className="w-5 h-5 animate-spin" 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>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
)}
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-white/80 hover:text-white transition-colors"
@@ -93,9 +93,17 @@ export function CookieBannerOverlay() {
return (
<>
<<<<<<< HEAD
{/* Non-blocking banner — no overlay, no pointer-events blocking */}
<div className="fixed bottom-0 left-16 xl:left-64 right-0 z-50 pointer-events-none">
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden pointer-events-auto">
=======
{/* Overlay — leaves sidebar (left 64px/16px) accessible */}
<div className="fixed inset-0 ml-16 xl:ml-64 bg-black/30 z-[9998]" onClick={() => setIsOpen(false)} />
<div className="fixed bottom-0 left-0 right-0 z-[9999]">
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden">
>>>>>>> feat/zeroclaw-compliance-agent
{/* Header with EWR toggle + close button */}
<div className="px-6 pt-5 pb-3">
@@ -7,6 +7,7 @@ import type { ProjectInfo } from '@/lib/sdk/types'
import { CreateProjectDialog, normalizeProject } from './CreateProjectDialog'
import { ProjectActionDialog } from './ProjectActionDialog'
import { ProjectCard } from './ProjectCard'
import { PresetSection } from '@/app/sdk/_components/PresetSection'
export function ProjectSelector() {
const router = useRouter()
@@ -152,6 +153,9 @@ export function ProjectSelector() {
</div>
)}
{/* Industry Presets — quick-start for new projects */}
{!loading && <PresetSection />}
{/* Archived Projects Section */}
{!loading && archivedProjects.length > 0 && (
<div className="mt-8">
@@ -38,6 +38,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
<AdditionalModuleItem href="/sdk/email-templates" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="E-Mail-Templates" isActive={pathname === '/sdk/email-templates'} collapsed={collapsed} projectId={projectId} />
</div>
<<<<<<< HEAD
{/* Master Controls Browser */}
<AdditionalModuleItem
href="/sdk/master-controls"
@@ -53,6 +54,8 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
projectId={projectId}
/>
=======
>>>>>>> feat/zeroclaw-compliance-agent
{/* Maschinenrecht / CE */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
@@ -129,6 +132,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
Zusatzmodule
</div>
)}
<AdditionalModuleItem href="/sdk/rollenkonzept" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>} label="Rollenkonzept" isActive={pathname?.startsWith('/sdk/rollenkonzept') ?? false} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/training" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>} label="Schulung (Admin)" isActive={pathname === '/sdk/training'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/training/learner" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>} label="Schulung (Learner)" isActive={pathname === '/sdk/training/learner'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/rag" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>} label="Legal RAG" isActive={pathname === '/sdk/rag'} collapsed={collapsed} projectId={projectId} />
@@ -143,7 +147,8 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
<AdditionalModuleItem href="/sdk/workshop" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>} label="Workshop" isActive={pathname === '/sdk/workshop'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/portfolio" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>} label="Portfolio" isActive={pathname === '/sdk/portfolio'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/roadmap" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /></svg>} label="Roadmap" isActive={pathname === '/sdk/roadmap'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/isms" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>} label="ISMS (ISO 27001)" isActive={pathname === '/sdk/isms'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/source-policy" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>} label="Quellen-Verwaltung" isActive={pathname?.startsWith('/sdk/source-policy') ?? false} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/isms" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>} label="ISMS Readiness" isActive={pathname === '/sdk/isms'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/audit-llm" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>} label="LLM Audit" isActive={pathname === '/sdk/audit-llm'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/rbac" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>} label="RBAC Admin" isActive={pathname === '/sdk/rbac'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/catalog-manager" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>} label="Kataloge" isActive={pathname === '/sdk/catalog-manager'} collapsed={collapsed} projectId={projectId} />
@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDK_STEPS } from '@/lib/sdk'
import { STEP_EXPLANATIONS } from './StepExplanations'
export { STEP_EXPLANATIONS }
// =============================================================================
// TYPES
@@ -110,6 +110,8 @@ interface RegulationsPanelProps {
supervisoryAuthorities?: SupervisoryAuthorityInfo[]
regulationAssessmentLoading?: boolean
onGoToObligations?: () => void
enabledModules?: string[]
onToggleModule?: (moduleId: string, enabled: boolean) => void
}
export function RegulationsPanel({
@@ -117,6 +119,8 @@ export function RegulationsPanel({
supervisoryAuthorities,
regulationAssessmentLoading,
onGoToObligations,
enabledModules,
onToggleModule,
}: RegulationsPanelProps) {
if (!applicableRegulations && !regulationAssessmentLoading) return null
return (
@@ -149,10 +153,23 @@ export function RegulationsPanel({
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right text-sm text-gray-600">
<span>{reg.obligation_count} Pflichten</span>
{reg.control_count > 0 && <span className="ml-2">{reg.control_count} Controls</span>}
</div>
{onToggleModule && (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledModules?.includes(reg.id) ?? true}
onChange={e => onToggleModule(reg.id, e.target.checked)}
className="w-4 h-4 text-purple-600 rounded"
/>
<span className="text-xs text-gray-500">Aktiv</span>
</label>
)}
</div>
</div>
))}
@@ -25,6 +25,8 @@ interface ScopeDecisionTabProps {
supervisoryAuthorities?: SupervisoryAuthorityInfo[]
regulationAssessmentLoading?: boolean
onGoToObligations?: () => void
enabledModules?: string[]
onToggleModule?: (moduleId: string, enabled: boolean) => void
}
export function ScopeDecisionTab({
@@ -38,6 +40,8 @@ export function ScopeDecisionTab({
supervisoryAuthorities,
regulationAssessmentLoading,
onGoToObligations,
enabledModules,
onToggleModule,
}: ScopeDecisionTabProps) {
const [expandedTrigger, setExpandedTrigger] = useState<number | null>(null)
const [showAuditTrail, setShowAuditTrail] = useState(false)
@@ -71,6 +75,8 @@ export function ScopeDecisionTab({
applicableRegulations={applicableRegulations}
supervisoryAuthorities={supervisoryAuthorities}
regulationAssessmentLoading={regulationAssessmentLoading}
enabledModules={enabledModules}
onToggleModule={onToggleModule}
onGoToObligations={onGoToObligations}
/>
@@ -1,13 +1,45 @@
<<<<<<< HEAD
=======
/**
* Playwright config for testing against live Mac Mini instance.
* No webServer assumes https://macmini:3007 is already running.
*
* Usage: npx playwright test --config=e2e/playwright-live.config.ts
*/
>>>>>>> feat/zeroclaw-compliance-agent
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './specs',
<<<<<<< HEAD
timeout: 30000,
use: {
baseURL: 'https://macmini:3007',
ignoreHTTPSErrors: true,
=======
fullyParallel: true,
retries: 0,
workers: 3,
reporter: [
['html', { outputFolder: 'e2e/reports/html' }],
['list'],
],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'https://macmini:3007',
ignoreHTTPSErrors: true,
screenshot: 'on',
trace: 'on-first-retry',
>>>>>>> feat/zeroclaw-compliance-agent
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
<<<<<<< HEAD
=======
outputDir: 'e2e/test-results',
timeout: 20000,
expect: { timeout: 5000 },
// No webServer — we test against the live instance
>>>>>>> feat/zeroclaw-compliance-agent
})
@@ -0,0 +1,83 @@
/**
* Document Generator E2E Test
*
* Prueft: Template-Library, Empfehlungen, Kategorie-Filter, Template-Auswahl
*/
import { test, expect } from '@playwright/test'
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'https://macmini:3007'
test.describe('Document Generator', () => {
test('Template Library loads with templates', async ({ page }) => {
await page.goto(`${BASE}/sdk/document-generator`)
await page.waitForLoadState('networkidle')
// Wait for templates to load
await page.waitForTimeout(2000)
// Check that template count is shown
const body = await page.textContent('body')
expect(body).toContain('Vorlagen gesamt')
await page.screenshot({ path: 'e2e/test-results/generator-library.png', fullPage: true })
})
test('Category filter works', async ({ page }) => {
await page.goto(`${BASE}/sdk/document-generator`)
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
// Click on "Datenschutz" category if it exists
const datenschutzButton = page.locator('button', { hasText: 'Datenschutz' })
if (await datenschutzButton.isVisible()) {
await datenschutzButton.click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'e2e/test-results/generator-filter-datenschutz.png' })
}
// Click "Alle" to reset
const alleButton = page.locator('button', { hasText: 'Alle' })
if (await alleButton.isVisible()) {
await alleButton.click()
await page.waitForTimeout(500)
}
})
test('Recommendation section visible when scope is set', async ({ page }) => {
await page.goto(`${BASE}/sdk/document-generator`)
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
// Check for recommendation section (may or may not be visible depending on scope state)
const recSection = page.locator('text=Empfohlene Dokumente')
const isVisible = await recSection.isVisible().catch(() => false)
if (isVisible) {
await page.screenshot({ path: 'e2e/test-results/generator-recommendations.png' })
// Check for Pflicht/Empfohlen sections
const pflicht = page.locator('text=Pflicht')
expect(await pflicht.isVisible()).toBeTruthy()
}
})
test('New template categories are present', async ({ page }) => {
await page.goto(`${BASE}/sdk/document-generator`)
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
const body = await page.textContent('body')
// Check that new categories exist
const expectedCategories = ['TOM', 'Whistleblower', 'HR-Datenschutz', 'Drittlandtransfer']
for (const cat of expectedCategories) {
const button = page.locator('button', { hasText: cat })
if (await button.isVisible()) {
await button.click()
await page.waitForTimeout(300)
await page.screenshot({ path: `e2e/test-results/generator-category-${cat.toLowerCase().replace(/[^a-z]/g, '')}.png` })
}
}
})
})
@@ -0,0 +1,64 @@
/**
* ISMS Asset Register E2E Test
*
* Prueft: Assets-Tab, CRUD, Filter, CSV-Export
*/
import { test, expect } from '@playwright/test'
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'https://macmini:3007'
test.describe('ISMS — Asset Register', () => {
test('ISMS page loads with Assets tab', async ({ page }) => {
await page.goto(`${BASE}/sdk/isms`)
await page.waitForLoadState('networkidle')
// Check that Assets tab exists
const assetsTab = page.locator('button', { hasText: 'Assets' })
expect(await assetsTab.isVisible()).toBeTruthy()
await page.screenshot({ path: 'e2e/test-results/isms-overview.png' })
})
test('Assets tab shows empty state', async ({ page }) => {
await page.goto(`${BASE}/sdk/isms`)
await page.waitForLoadState('networkidle')
// Click Assets tab
const assetsTab = page.locator('button', { hasText: 'Assets' })
await assetsTab.click()
await page.waitForTimeout(500)
// Check for empty state or asset table
const body = await page.textContent('body')
const hasAssets = body?.includes('Gesamt') || false
await page.screenshot({ path: 'e2e/test-results/isms-assets-tab.png' })
expect(hasAssets).toBeTruthy()
})
test('Add asset form is accessible', async ({ page }) => {
await page.goto(`${BASE}/sdk/isms`)
await page.waitForLoadState('networkidle')
// Click Assets tab
const assetsTab = page.locator('button', { hasText: 'Assets' })
await assetsTab.click()
await page.waitForTimeout(500)
// Click "Asset hinzufuegen" button
const addButton = page.locator('button', { hasText: 'Asset hinzufuegen' })
if (await addButton.isVisible()) {
await addButton.click()
await page.waitForTimeout(300)
// Check form fields are visible
const body = await page.textContent('body')
expect(body).toContain('Name')
expect(body).toContain('Kategorie')
expect(body).toContain('Schutzbedarf')
await page.screenshot({ path: 'e2e/test-results/isms-assets-form.png' })
}
})
})
@@ -0,0 +1,103 @@
/**
* Scope Profiling Test Three Customer Profiles
*
* Legt drei fiktive Kundenprofile an die NICHT geloescht werden:
* - TechStart GmbH (Startup, L1)
* - MittelstandHandel AG (KMU, L2)
* - FinanzKonzern SE (Enterprise, L4)
*
* WICHTIG: Testdaten werden NICHT aufgeraeumt (User will nachvollziehen).
*/
import { test, expect } from '@playwright/test'
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'https://macmini:3007'
test.describe('Scope Profiling — Customer Profiles', () => {
test.describe.configure({ mode: 'serial' })
test('Szenario A: TechStart GmbH — Startup (L1)', async ({ page }) => {
// Navigate to company profile
await page.goto(`${BASE}/sdk/company-profile`)
await page.waitForLoadState('networkidle')
// Take screenshot for documentation
await page.screenshot({ path: 'e2e/test-results/profile-techstart-start.png' })
// Navigate to compliance scope
await page.goto(`${BASE}/sdk/compliance-scope`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/scope-techstart.png' })
// Navigate to document generator to check recommendations
await page.goto(`${BASE}/sdk/document-generator`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/generator-techstart.png' })
// Verify page loads without errors
const body = await page.textContent('body')
expect(body).not.toContain('Application error')
})
test('Szenario B: MittelstandHandel AG — KMU (L2)', async ({ page }) => {
await page.goto(`${BASE}/sdk/company-profile`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/profile-mittelstand-start.png' })
await page.goto(`${BASE}/sdk/compliance-scope`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/scope-mittelstand.png' })
// Check vendor transfers tab
await page.goto(`${BASE}/sdk/vendor-compliance/transfers`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/transfers-mittelstand.png' })
// Check document generator
await page.goto(`${BASE}/sdk/document-generator`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/generator-mittelstand.png' })
const body = await page.textContent('body')
expect(body).not.toContain('Application error')
})
test('Szenario C: FinanzKonzern SE — Enterprise (L4)', async ({ page }) => {
await page.goto(`${BASE}/sdk/company-profile`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/profile-finanzkonzern-start.png' })
await page.goto(`${BASE}/sdk/compliance-scope`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/scope-finanzkonzern.png' })
// Check ISMS with assets
await page.goto(`${BASE}/sdk/isms`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/isms-finanzkonzern.png' })
// Check whistleblower (Pflicht ab 50 MA)
await page.goto(`${BASE}/sdk/whistleblower`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/whistleblower-finanzkonzern.png' })
// Check document generator
await page.goto(`${BASE}/sdk/document-generator`)
await page.waitForLoadState('networkidle')
await page.screenshot({ path: 'e2e/test-results/generator-finanzkonzern.png' })
const body = await page.textContent('body')
expect(body).not.toContain('Application error')
})
})
@@ -0,0 +1,90 @@
/**
* SDK Module Reachability Test
*
* Prueft dass alle SDK-Module erreichbar sind (kein 404, kein Crash).
* Schnellster Test findet kaputte Seiten sofort.
*/
import { test, expect } from '@playwright/test'
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'https://macmini:3007'
// All SDK module routes to test
const SDK_ROUTES = [
// Phase 1: Vorbereitung
'/sdk/company-profile',
'/sdk/compliance-scope',
'/sdk/advisory-board',
'/sdk/screening',
'/sdk/source-policy',
// Phase 2: Analyse
'/sdk/requirements',
'/sdk/controls',
'/sdk/evidence',
'/sdk/risks',
'/sdk/ai-act',
'/sdk/audit-checklist',
'/sdk/audit-report',
// Phase 3: Dokumentation
'/sdk/obligations',
'/sdk/dsfa',
'/sdk/tom',
'/sdk/loeschfristen',
'/sdk/vvt',
// Phase 4: Rechtliche Texte
'/sdk/einwilligungen',
'/sdk/consent',
'/sdk/cookie-banner',
'/sdk/document-generator',
'/sdk/workflow',
// Phase 5: Betrieb
'/sdk/dsr',
'/sdk/escalations',
'/sdk/vendor-compliance',
'/sdk/vendor-compliance/transfers',
'/sdk/consent-management',
'/sdk/email-templates',
'/sdk/notfallplan',
'/sdk/incidents',
'/sdk/whistleblower',
'/sdk/academy',
'/sdk/training',
'/sdk/control-library',
// Zusatzmodule
'/sdk/isms',
'/sdk/iace',
'/sdk/agent',
'/sdk/rag',
'/sdk/quality',
'/sdk/security-backlog',
'/sdk/reporting',
'/sdk/tom-generator',
]
test.describe('SDK Module Reachability', () => {
for (const route of SDK_ROUTES) {
test(`${route} is reachable`, async ({ page }) => {
const response = await page.goto(`${BASE}${route}`, {
waitUntil: 'domcontentloaded',
timeout: 15000,
})
// Page should load successfully (not 404 or 500)
expect(response?.status()).toBeLessThan(400)
// Wait for client-side hydration
await page.waitForTimeout(2000)
// Check no visible 404 page or application error (not RSC payload)
const has404Page = await page.locator('h1').filter({ hasText: '404' }).count()
const hasAppError = await page.getByText('Application error').count()
expect(has404Page).toBe(0)
expect(hasAppError).toBe(0)
})
}
})
@@ -0,0 +1,66 @@
/**
* Vendor Compliance Transfers Tab E2E Test
*
* Prueft: Drittlandtransfer-Tab, Erklaerungen, Adequacy-Liste
*/
import { test, expect } from '@playwright/test'
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'https://macmini:3007'
test.describe('Vendor Compliance — Transfers', () => {
test('Transfers tab is accessible', async ({ page }) => {
await page.goto(`${BASE}/sdk/vendor-compliance/transfers`)
await page.waitForLoadState('networkidle')
// Page should show transfer heading
const body = await page.textContent('body')
expect(body).toContain('Drittlandtransfers')
await page.screenshot({ path: 'e2e/test-results/transfers-tab.png', fullPage: true })
})
test('Explanation section is visible', async ({ page }) => {
await page.goto(`${BASE}/sdk/vendor-compliance/transfers`)
await page.waitForLoadState('networkidle')
// Check for the three explanation cards
const body = await page.textContent('body')
expect(body).toContain('Was muss ich tun')
expect(body).toContain('Angemessenheitsbeschluss')
expect(body).toContain('DPF-Zertifizierung')
expect(body).toContain('SCC + TIA')
await page.screenshot({ path: 'e2e/test-results/transfers-explanations.png' })
})
test('Adequacy countries list is expandable', async ({ page }) => {
await page.goto(`${BASE}/sdk/vendor-compliance/transfers`)
await page.waitForLoadState('networkidle')
// Click on the details summary to expand
const summary = page.locator('summary', { hasText: 'Angemessenheitsbeschluss' })
if (await summary.isVisible()) {
await summary.click()
await page.waitForTimeout(300)
// Check for country names
const body = await page.textContent('body')
expect(body).toContain('Schweiz')
expect(body).toContain('Japan')
expect(body).toContain('Vereinigte Staaten')
await page.screenshot({ path: 'e2e/test-results/transfers-adequacy-list.png', fullPage: true })
}
})
test('Schrems II info box is present', async ({ page }) => {
await page.goto(`${BASE}/sdk/vendor-compliance/transfers`)
await page.waitForLoadState('networkidle')
const body = await page.textContent('body')
expect(body).toContain('Schrems II')
await page.screenshot({ path: 'e2e/test-results/transfers-schrems.png' })
})
})
@@ -0,0 +1,331 @@
import type { CompanyProfilePreset } from './company-profile-presets'
export const COMPANY_PROFILE_PRESETS: CompanyProfilePreset[] = [
{
id: 'saas_startup',
label: 'SaaS Startup',
description: 'B2B Software-Startup, 1-5 Mitarbeiter, Cloud-basiert, remote-first',
icon: '\u{1F680}',
profile: {
legalForm: 'GmbH', industry: ['tech'], businessModel: 'b2b',
companySize: 'micro', employeeCount: '1-9', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU'], isDataController: true, isDataProcessor: true,
},
scopeHints: {
org_employee_count: '1-9', org_industry: 'tech', org_business_model: 'b2b',
proc_ai_usage: 'yes', tech_hosting_location: 'eu',
tech_encryption_transit: 'yes', tech_encryption_rest: 'yes',
comp_documentation_level: 'basic',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'agb', 'cookie_policy', 'cookie_banner',
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept',
'employee_dsi', 'applicant_dsi',
],
},
{
id: 'consumer_app',
label: 'App Startup (Consumer)',
description: 'B2C Mobile App, 1-5 Mitarbeiter, App Store, Nutzerdaten',
icon: '\u{1F4F1}',
profile: {
legalForm: 'GmbH', industry: ['tech'], businessModel: 'b2c',
companySize: 'micro', employeeCount: '1-9', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU'], isDataController: true, isDataProcessor: false,
},
scopeHints: {
org_employee_count: '1-9', org_industry: 'tech', org_business_model: 'b2c',
data_volume: '1000-10000', proc_tracking: 'yes',
prod_consent_management: 'yes', tech_hosting_location: 'eu',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'terms_of_use', 'cookie_policy', 'cookie_banner',
'community_guidelines', 'acceptable_use', 'widerruf',
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept',
'employee_dsi', 'applicant_dsi', 'social_media_dsi',
],
},
{
id: 'ecommerce',
label: 'E-Commerce / Online-Shop',
description: 'Online-Handel B2C, 5-20 Mitarbeiter, Webshop, Zahlungsabwicklung',
icon: '\u{1F6D2}',
profile: {
legalForm: 'GmbH', industry: ['retail'], businessModel: 'b2c',
companySize: 'small', employeeCount: '10-49', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU'], isDataController: true, isDataProcessor: false,
},
scopeHints: {
org_employee_count: '10-49', org_industry: 'retail', org_business_model: 'b2c',
prod_webshop: 'yes', data_volume: '10000-100000',
tech_hosting_location: 'eu', prod_consent_management: 'yes',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'agb', 'widerruf', 'cookie_policy', 'cookie_banner',
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept',
'employee_dsi', 'applicant_dsi',
],
},
{
id: 'it_agency',
label: 'IT-Dienstleister / Agentur',
description: 'IT-Beratung oder Agentur, 10-50 Mitarbeiter, Kundenprojekte',
icon: '\u{1F4BB}',
profile: {
legalForm: 'GmbH', industry: ['tech'], businessModel: 'b2b',
companySize: 'small', employeeCount: '10-49', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU'], isDataController: true, isDataProcessor: true,
},
scopeHints: {
org_employee_count: '10-49', org_industry: 'tech', org_business_model: 'b2b',
proc_ai_usage: 'yes', tech_hosting_location: 'eu',
comp_vendor_management: 'yes', comp_training: 'yes',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'agb', 'cookie_policy', 'cookie_banner',
'dpa', 'nda', 'sla', 'tom_documentation', 'vvt_register', 'loeschkonzept',
'employee_dsi', 'applicant_dsi',
],
},
{
id: 'maschinenbau',
label: 'Maschinenbau KMU',
description: 'Maschinenbau B2B, 50-200 Mitarbeiter, Produktion, CE-Kennzeichnung',
icon: '\u{1F3ED}',
profile: {
legalForm: 'GmbH', industry: ['manufacturing'], businessModel: 'b2b',
companySize: 'medium', employeeCount: '50-249', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU'], isDataController: true, isDataProcessor: false,
},
scopeHints: {
org_employee_count: '50-249', org_industry: 'manufacturing', org_business_model: 'b2b',
proc_employee_monitoring: 'no', tech_hosting_location: 'eu',
comp_vendor_management: 'yes', comp_documentation_level: 'structured',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'agb', 'cookie_policy', 'cookie_banner',
'dpa', 'nda', 'tom_documentation', 'vvt_register', 'loeschkonzept',
'employee_dsi', 'applicant_dsi', 'whistleblower_policy',
'dsfa', 'pflichtenregister',
],
},
{
id: 'law_firm',
label: 'Rechtsanwaltskanzlei',
description: 'Kanzlei, 5-20 Mitarbeiter, Mandantendaten, besondere Vertraulichkeit',
icon: '\u2696\uFE0F',
profile: {
legalForm: 'PartG', industry: ['legal'], businessModel: 'b2b',
companySize: 'small', employeeCount: '1-9', headquartersCountry: 'DE',
targetMarkets: ['DE'], isDataController: true, isDataProcessor: false,
},
scopeHints: {
org_employee_count: '1-9', org_industry: 'legal', org_business_model: 'b2b',
data_art9: 'no', tech_encryption_transit: 'yes',
tech_encryption_rest: 'yes', comp_documentation_level: 'basic',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner',
'dpa', 'nda', 'tom_documentation', 'vvt_register', 'loeschkonzept',
'employee_dsi', 'applicant_dsi',
],
},
{
id: 'healthcare',
label: 'Arztpraxis / Gesundheit',
description: 'Gesundheitswesen, 5-50 Mitarbeiter, Patientendaten (Art. 9), hoher Schutzbedarf',
icon: '\u{1F3E5}',
profile: {
legalForm: 'GbR', industry: ['healthcare'], businessModel: 'b2c',
companySize: 'small', employeeCount: '1-9', headquartersCountry: 'DE',
targetMarkets: ['DE'], isDataController: true, isDataProcessor: false,
},
scopeHints: {
org_employee_count: '1-9', org_industry: 'healthcare', org_business_model: 'b2c',
data_art9: 'yes', tech_encryption_transit: 'yes',
tech_encryption_rest: 'yes', comp_documentation_level: 'basic',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner',
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa',
'employee_dsi', 'applicant_dsi', 'pflichtenregister',
],
},
{
id: 'handwerk',
label: 'Handwerksbetrieb',
description: 'Handwerk, 5-20 Mitarbeiter, Kundendaten, einfache IT',
icon: '\u{1F527}',
profile: {
legalForm: 'GmbH', industry: ['crafts'], businessModel: 'b2c',
companySize: 'small', employeeCount: '1-9', headquartersCountry: 'DE',
targetMarkets: ['DE'], isDataController: true, isDataProcessor: false,
},
scopeHints: {
org_employee_count: '1-9', org_industry: 'other', org_business_model: 'b2c',
data_art9: 'no', tech_hosting_location: 'eu', comp_documentation_level: 'none',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'agb', 'cookie_policy', 'cookie_banner',
'tom_documentation', 'vvt_register', 'loeschkonzept', 'employee_dsi',
],
},
{
id: 'education',
label: 'Bildungseinrichtung',
description: 'Schule, Hochschule oder Weiterbildung, 20-100 Mitarbeiter, Schuelerdaten',
icon: '\u{1F393}',
profile: {
legalForm: 'gGmbH', industry: ['education'], businessModel: 'b2c',
companySize: 'medium', employeeCount: '10-49', headquartersCountry: 'DE',
targetMarkets: ['DE'], isDataController: true, isDataProcessor: false,
},
scopeHints: {
org_employee_count: '10-49', org_industry: 'education', org_business_model: 'b2c',
data_minors: 'yes', tech_hosting_location: 'eu', comp_training: 'yes',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner',
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa',
'employee_dsi', 'applicant_dsi', 'pflichtenregister',
],
},
{
id: 'enterprise',
label: 'Konzern / Enterprise',
description: 'Grossunternehmen, 500+ MA, international, reguliert, ISO 27001',
icon: '\u{1F3E2}',
profile: {
legalForm: 'AG', industry: ['finance'], businessModel: 'b2b',
companySize: 'enterprise', employeeCount: '1000+', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU', 'US'], isDataController: true, isDataProcessor: true,
},
scopeHints: {
org_employee_count: '1000+', org_industry: 'finance', org_business_model: 'b2b',
org_cert_target: 'iso27001', data_art9: 'yes', data_volume: '>1000000',
proc_ai_usage: 'yes', tech_third_country: 'yes',
tech_hosting_location: 'eu_us_adequacy', comp_vendor_management: 'yes',
comp_training: 'yes', comp_documentation_level: 'comprehensive',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'agb', 'cookie_policy', 'cookie_banner',
'dpa', 'nda', 'sla', 'cloud_service_agreement',
'tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa', 'pflichtenregister',
'data_protection_concept', 'consent_texts', 'informationspflichten', 'verpflichtungserklaerung',
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
'dsr_process_art18', 'dsr_process_art20', 'dsr_process_art21',
'isms_manual', 'it_security_concept', 'risk_management_concept',
'information_security_policy', 'access_control_policy', 'encryption_policy',
'change_management_policy', 'asset_management_policy',
'data_protection_policy', 'data_classification_policy',
'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy',
'employee_dsi', 'applicant_dsi', 'whistleblower_policy', 'social_media_dsi',
'employee_security_policy', 'security_awareness_policy', 'offboarding_policy',
'transfer_impact_assessment', 'scc_companion',
'vendor_risk_management_policy', 'third_party_security_policy',
'business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy',
'ai_usage_policy', 'standard_operating_procedure',
],
},
{
id: 'cloud_provider',
label: 'Cloud / SaaS-Anbieter',
description: 'Cloud-Infrastruktur oder SaaS, 20-100 MA, DevOps, ISO 27001 Ziel',
icon: '\u2601\uFE0F',
profile: {
legalForm: 'GmbH', industry: ['tech'], businessModel: 'b2b',
companySize: 'small', employeeCount: '10-49', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU'], isDataController: true, isDataProcessor: true,
},
scopeHints: {
org_employee_count: '10-49', org_industry: 'tech', org_business_model: 'b2b',
org_cert_iso27001: 'yes', proc_ai_usage: 'yes', tech_hosting_location: 'eu',
tech_encryption_transit: 'yes', tech_encryption_rest: 'yes',
comp_vendor_management: 'yes', comp_documentation_level: 'structured',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'agb', 'cookie_policy', 'cookie_banner',
'dpa', 'nda', 'sla', 'cloud_service_agreement',
'tom_documentation', 'vvt_register', 'loeschkonzept', 'pflichtenregister',
'data_protection_concept', 'consent_texts',
'isms_manual', 'it_security_concept', 'backup_recovery_concept',
'logging_concept', 'incident_response_plan',
'access_control_concept', 'risk_management_concept',
'information_security_policy', 'access_control_policy', 'password_policy',
'encryption_policy', 'logging_policy', 'backup_policy',
'incident_response_policy', 'change_management_policy',
'patch_management_policy', 'asset_management_policy',
'cloud_security_policy', 'devsecops_policy',
'secrets_management_policy', 'vulnerability_management_policy',
'employee_dsi', 'applicant_dsi', 'employee_security_policy',
'remote_work_policy', 'offboarding_policy',
'vendor_risk_management_policy', 'third_party_security_policy',
'business_continuity_policy', 'disaster_recovery_policy',
'ai_usage_policy', 'cybersecurity_policy', 'byod_policy',
'standard_operating_procedure',
],
},
{
id: 'fintech',
label: 'Finanzdienstleister',
description: 'Finanz- oder Versicherungsbranche, 50-500 MA, reguliert',
icon: '\u{1F3E6}',
profile: {
legalForm: 'GmbH', industry: ['finance'], businessModel: 'b2b',
companySize: 'medium', employeeCount: '50-249', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU'], isDataController: true, isDataProcessor: true,
},
scopeHints: {
org_employee_count: '50-249', org_industry: 'finance', org_business_model: 'b2b',
data_art9: 'no', data_volume: '100000-1000000', tech_hosting_location: 'eu',
tech_encryption_transit: 'yes', tech_encryption_rest: 'yes',
comp_vendor_management: 'yes', comp_training: 'yes',
comp_documentation_level: 'comprehensive',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'agb', 'cookie_policy', 'cookie_banner',
'dpa', 'nda', 'sla',
'tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa', 'pflichtenregister',
'data_protection_concept', 'verpflichtungserklaerung', 'informationspflichten',
'dsr_process_art15', 'dsr_process_art17', 'dsr_process_art20',
'data_protection_policy', 'data_classification_policy',
'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy',
'it_security_concept', 'risk_management_concept',
'information_security_policy', 'access_control_policy', 'encryption_policy',
'employee_dsi', 'applicant_dsi', 'whistleblower_policy',
'employee_security_policy', 'security_awareness_policy', 'offboarding_policy',
'transfer_impact_assessment', 'vendor_risk_management_policy',
'supplier_security_policy',
'business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy',
'standard_operating_procedure',
],
},
{
id: 'platform',
label: 'Plattform / Marketplace',
description: 'Online-Plattform mit Nutzern, UGC, Community, 10-50 MA',
icon: '\u{1F310}',
profile: {
legalForm: 'GmbH', industry: ['tech'], businessModel: 'b2b2c',
companySize: 'small', employeeCount: '10-49', headquartersCountry: 'DE',
targetMarkets: ['DE', 'EU'], isDataController: true, isDataProcessor: false,
},
scopeHints: {
org_employee_count: '10-49', org_industry: 'tech', org_business_model: 'b2b2c',
data_volume: '10000-100000', proc_tracking: 'yes',
prod_ugc_platform: 'yes', prod_consent_management: 'yes',
tech_hosting_location: 'eu',
},
recommendedDocs: [
'privacy_policy', 'impressum', 'terms_of_use', 'agb',
'cookie_policy', 'cookie_banner', 'dpa',
'community_guidelines', 'acceptable_use',
'media_content_policy', 'copyright_policy', 'data_usage_clause',
'tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa',
'consent_texts', 'social_media_dsi', 'video_conference_dsi',
'dsr_process_art15', 'dsr_process_art17', 'dsr_process_art20', 'dsr_process_art21',
'employee_dsi', 'applicant_dsi',
'ai_usage_policy',
],
},
]
@@ -0,0 +1,33 @@
/**
* Company Profile Presets Branchenvorlagen fuer typische Kundenszenarien
*
* Jeder Preset enthaelt ein vorbefuelltes CompanyProfile + typische Scope-Antworten.
* Der Kunde waehlt beim Onboarding ein Profil und passt es dann an.
*
* Data split: Interface here, preset data in ./company-profile-preset-data.ts
*/
export interface CompanyProfilePreset {
id: string
label: string
description: string
icon: string
/** Vorbefuellte CompanyProfile-Felder */
profile: {
legalForm: string
industry: string[]
businessModel: string
companySize: string
employeeCount: string
headquartersCountry: string
targetMarkets: string[]
isDataController: boolean
isDataProcessor: boolean
}
/** Typische Scope-Antworten fuer diese Branche */
scopeHints: Record<string, string>
/** Typische Dokumente die diese Branche braucht */
recommendedDocs: string[]
}
export { COMPANY_PROFILE_PRESETS } from './company-profile-preset-data'
@@ -52,6 +52,15 @@ export const QUESTION_SCORE_WEIGHTS: Record<
comp_training: { risk: 5, complexity: 4, assurance: 7 },
comp_vendor_management: { risk: 6, complexity: 6, assurance: 7 },
comp_documentation_level: { risk: 6, complexity: 7, assurance: 8 },
// Zusaetzliche Fragen fuer Template-Empfehlungen (7 Fragen)
org_has_employees: { risk: 2, complexity: 3, assurance: 3 },
org_has_social_media: { risk: 3, complexity: 2, assurance: 3 },
org_has_video_conferencing: { risk: 2, complexity: 2, assurance: 2 },
proc_uses_ai_tools: { risk: 7, complexity: 6, assurance: 7 },
proc_byod_allowed: { risk: 5, complexity: 4, assurance: 5 },
prod_ugc_platform: { risk: 6, complexity: 5, assurance: 6 },
org_cert_iso27001: { risk: 2, complexity: 8, assurance: 9 },
}
// ============================================================================
@@ -0,0 +1,113 @@
/**
* DSFA Bundesland-Blacklists Verarbeitungen die IMMER eine DSFA erfordern.
*
* Jede Aufsichtsbehoerde fuehrt eine eigene Positiv-/Negativliste
* gemaess Art. 35 Abs. 4 DSGVO. Diese Listen definieren Verarbeitungen
* die UNABHAENGIG von der Schwellwertanalyse eine DSFA erfordern.
*
* Quellen: Offizielle Muss-Listen der LfDI/BfDI (oeffentlich zugaenglich).
* KEIN Normtext eigene Zusammenfassung der Kriterien.
*/
export interface BlacklistEntry {
id: string
description: string
triggerKeywords: string[]
}
export interface BundeslandBlacklist {
state: string
stateCode: string
authority: string
authorityUrl: string
entries: BlacklistEntry[]
}
// Gemeinsame Kriterien die in ALLEN Bundeslaendern gelten (DSK-Liste)
const DSK_COMMON: BlacklistEntry[] = [
{ id: 'dsk-01', description: 'Umfangreiche Verarbeitung besonderer Kategorien (Art. 9)', triggerKeywords: ['art9', 'gesundheit', 'biometrie', 'genetik', 'religion', 'gewerkschaft'] },
{ id: 'dsk-02', description: 'Systematische umfangreiche Ueberwachung oeffentlicher Bereiche', triggerKeywords: ['videoueberwachung', 'kamera', 'oeffentlich', 'ueberwachung'] },
{ id: 'dsk-03', description: 'Scoring/Profiling mit rechtlicher Wirkung', triggerKeywords: ['scoring', 'profiling', 'bonitaet', 'kredit', 'automatisiert'] },
{ id: 'dsk-04', description: 'Verarbeitung von Daten Minderjaehriger fuer Marketing/Profiling', triggerKeywords: ['minderjaehrig', 'kinder', 'schueler', 'marketing'] },
{ id: 'dsk-05', description: 'Zusammenfuehrung von Daten aus verschiedenen Quellen', triggerKeywords: ['zusammenfuehrung', 'matching', 'datenfusion', 'big data'] },
{ id: 'dsk-06', description: 'Einsatz neuer Technologien (KI, Biometrie, IoT)', triggerKeywords: ['ki', 'kuenstliche intelligenz', 'biometrie', 'iot', 'gesichtserkennung'] },
{ id: 'dsk-07', description: 'Umfangreiche Verarbeitung von Standortdaten', triggerKeywords: ['standort', 'gps', 'tracking', 'bewegungsprofil'] },
{ id: 'dsk-08', description: 'Verarbeitung von Beschaeftigtendaten mit Ueberwachungscharakter', triggerKeywords: ['mitarbeiterueberwachung', 'keylogger', 'bildschirmaufnahme', 'leistungskontrolle'] },
{ id: 'dsk-09', description: 'Anonymisierung besonderer Kategorien', triggerKeywords: ['anonymisierung', 'art9', 'pseudonymisierung'] },
{ id: 'dsk-10', description: 'Verarbeitung von Kommunikationsinhalten oder -metadaten', triggerKeywords: ['kommunikation', 'email', 'telefon', 'metadaten', 'inhaltsdaten'] },
]
// Bundesland-spezifische Ergaenzungen
const BAYERN_EXTRA: BlacklistEntry[] = [
{ id: 'by-01', description: 'Betrieb von Whistleblower-Systemen mit Identifizierungsrisiko', triggerKeywords: ['whistleblower', 'hinweisgeber', 'meldesystem'] },
{ id: 'by-02', description: 'Einsatz von Drohnen mit Kamera in oeffentlichen Bereichen', triggerKeywords: ['drohne', 'uav', 'luftaufnahme'] },
]
const NRW_EXTRA: BlacklistEntry[] = [
{ id: 'nw-01', description: 'Social-Media-Monitoring von Mitarbeitern oder Bewerbern', triggerKeywords: ['social media', 'monitoring', 'bewerber', 'hintergrundcheck'] },
]
const BERLIN_EXTRA: BlacklistEntry[] = [
{ id: 'be-01', description: 'Automatisierte Mieterbonitaetspruefung', triggerKeywords: ['mieter', 'bonitaet', 'wohnung', 'schufa'] },
]
export const BUNDESLAND_BLACKLISTS: Record<string, BundeslandBlacklist> = {
BW: { state: 'Baden-Wuerttemberg', stateCode: 'BW', authority: 'LfDI BW', authorityUrl: 'https://www.baden-wuerttemberg.datenschutz.de', entries: [...DSK_COMMON] },
BY: { state: 'Bayern', stateCode: 'BY', authority: 'BayLDA', authorityUrl: 'https://www.lda.bayern.de', entries: [...DSK_COMMON, ...BAYERN_EXTRA] },
BE: { state: 'Berlin', stateCode: 'BE', authority: 'BlnBDI', authorityUrl: 'https://www.datenschutz-berlin.de', entries: [...DSK_COMMON, ...BERLIN_EXTRA] },
BB: { state: 'Brandenburg', stateCode: 'BB', authority: 'LDA BB', authorityUrl: 'https://www.lda.brandenburg.de', entries: [...DSK_COMMON] },
HB: { state: 'Bremen', stateCode: 'HB', authority: 'LfDI HB', authorityUrl: 'https://www.datenschutz.bremen.de', entries: [...DSK_COMMON] },
HH: { state: 'Hamburg', stateCode: 'HH', authority: 'HmbBfDI', authorityUrl: 'https://datenschutz-hamburg.de', entries: [...DSK_COMMON] },
HE: { state: 'Hessen', stateCode: 'HE', authority: 'HBDI', authorityUrl: 'https://datenschutz.hessen.de', entries: [...DSK_COMMON] },
MV: { state: 'Mecklenburg-Vorpommern', stateCode: 'MV', authority: 'LfDI MV', authorityUrl: 'https://www.datenschutz-mv.de', entries: [...DSK_COMMON] },
NI: { state: 'Niedersachsen', stateCode: 'NI', authority: 'LfD NI', authorityUrl: 'https://lfd.niedersachsen.de', entries: [...DSK_COMMON] },
NW: { state: 'Nordrhein-Westfalen', stateCode: 'NW', authority: 'LDI NRW', authorityUrl: 'https://www.ldi.nrw.de', entries: [...DSK_COMMON, ...NRW_EXTRA] },
RP: { state: 'Rheinland-Pfalz', stateCode: 'RP', authority: 'LfDI RP', authorityUrl: 'https://www.datenschutz.rlp.de', entries: [...DSK_COMMON] },
SL: { state: 'Saarland', stateCode: 'SL', authority: 'UDZ Saarland', authorityUrl: 'https://www.datenschutz.saarland.de', entries: [...DSK_COMMON] },
SN: { state: 'Sachsen', stateCode: 'SN', authority: 'SDB', authorityUrl: 'https://www.saechsdsb.de', entries: [...DSK_COMMON] },
ST: { state: 'Sachsen-Anhalt', stateCode: 'ST', authority: 'LfD LSA', authorityUrl: 'https://datenschutz.sachsen-anhalt.de', entries: [...DSK_COMMON] },
SH: { state: 'Schleswig-Holstein', stateCode: 'SH', authority: 'ULD SH', authorityUrl: 'https://www.datenschutzzentrum.de', entries: [...DSK_COMMON] },
TH: { state: 'Thueringen', stateCode: 'TH', authority: 'TLfDI', authorityUrl: 'https://www.tlfdi.de', entries: [...DSK_COMMON] },
}
/**
* Check scope answers against Bundesland blacklist.
* Returns matching entries that REQUIRE a DSFA.
*/
export function checkBlacklist(
stateCode: string,
scopeKeywords: string[],
): { matches: BlacklistEntry[]; authority: string; authorityUrl: string } {
const bl = BUNDESLAND_BLACKLISTS[stateCode]
if (!bl) return { matches: [], authority: 'BfDI', authorityUrl: 'https://www.bfdi.bund.de' }
const lowerKeywords = scopeKeywords.map(k => k.toLowerCase())
const matches = bl.entries.filter(entry =>
entry.triggerKeywords.some(tk => lowerKeywords.some(kw => kw.includes(tk) || tk.includes(kw)))
)
return { matches, authority: bl.authority, authorityUrl: bl.authorityUrl }
}
/**
* Derive keywords from scope answers for blacklist matching.
*/
export function scopeAnswersToKeywords(answers: Array<{ questionId: string; value: unknown }>): string[] {
const keywords: string[] = []
for (const a of answers) {
if (a.value === true || a.value === 'yes') {
keywords.push(a.questionId.replace(/_/g, ' '))
// Map specific question IDs to keywords
if (a.questionId === 'data_art9') keywords.push('art9', 'besondere kategorien')
if (a.questionId === 'data_minors') keywords.push('minderjaehrig', 'kinder')
if (a.questionId === 'proc_adm_scoring') keywords.push('scoring', 'profiling', 'automatisiert')
if (a.questionId === 'proc_video_surveillance') keywords.push('videoueberwachung', 'kamera')
if (a.questionId === 'proc_ai_usage') keywords.push('ki', 'kuenstliche intelligenz')
if (a.questionId === 'proc_employee_monitoring') keywords.push('mitarbeiterueberwachung')
}
if (Array.isArray(a.value)) {
for (const v of a.value) keywords.push(String(v).toLowerCase())
}
}
return keywords
}
@@ -0,0 +1,224 @@
/**
* DSFA Pre-Fill derives initial DSFA data from Company Profile + Scope answers.
*
* Maps: Firmensitz Bundesland, Scope-Antworten Datenkategorien/Risiken,
* Use Cases Verarbeitungstaetigkeiten, DPO Beratungsinformationen.
*/
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
interface CompanyProfileMinimal {
headquartersState?: string
industry?: string[]
businessModel?: string
dpoName?: string | null
dpoEmail?: string | null
companyName?: string
}
export interface DSFAPrefillResult {
title: string
description: string
processingActivity: string
dataCategories: string[]
dataSubjects: string[]
riskLevel: string
measures: string[]
federalState: string
involvesAi: boolean
legalBasis: string
processingPurpose: string
affectedRights: string[]
}
const ART9_CATEGORY_MAP: Record<string, string> = {
health: 'Gesundheitsdaten',
biometric: 'Biometrische Daten',
genetic: 'Genetische Daten',
ethnic: 'Ethnische Herkunft',
political: 'Politische Meinungen',
religious: 'Religioese Ueberzeugungen',
union: 'Gewerkschaftszugehoerigkeit',
sexual: 'Sexualleben/Orientierung',
}
const BUNDESLAND_LABELS: Record<string, string> = {
BW: 'Baden-Wuerttemberg', BY: 'Bayern', BE: 'Berlin', BB: 'Brandenburg',
HB: 'Bremen', HH: 'Hamburg', HE: 'Hessen', MV: 'Mecklenburg-Vorpommern',
NI: 'Niedersachsen', NW: 'Nordrhein-Westfalen', RP: 'Rheinland-Pfalz',
SL: 'Saarland', SN: 'Sachsen', ST: 'Sachsen-Anhalt',
SH: 'Schleswig-Holstein', TH: 'Thueringen',
}
function getAnswer(answers: ScopeProfilingAnswer[], questionId: string): string | string[] | boolean | undefined {
const a = answers.find(a => a.questionId === questionId)
return a?.value as string | string[] | boolean | undefined
}
export function prefillDSFAFromScope(
profile: CompanyProfileMinimal | null,
scopeAnswers: ScopeProfilingAnswer[],
): DSFAPrefillResult {
const result: DSFAPrefillResult = {
title: '',
description: '',
processingActivity: '',
dataCategories: [],
dataSubjects: [],
riskLevel: 'mittel',
measures: ['Zugriffskontrolle', 'Verschluesselung'],
federalState: '',
involvesAi: false,
legalBasis: '',
processingPurpose: '',
affectedRights: [],
}
// 1. Firmensitz → Bundesland
if (profile?.headquartersState) {
result.federalState = profile.headquartersState
}
// 2. Art. 9 Daten → Datenkategorien + Risikostufe
const art9 = getAnswer(scopeAnswers, 'data_art9')
if (art9 === true || art9 === 'yes') {
result.dataCategories.push('Besondere Kategorien (Art. 9)')
result.riskLevel = 'hoch'
result.title = 'DSFA — Verarbeitung besonderer Datenkategorien'
}
if (Array.isArray(art9)) {
for (const cat of art9) {
const label = ART9_CATEGORY_MAP[cat]
if (label) result.dataCategories.push(label)
}
if (art9.length > 0) result.riskLevel = 'hoch'
}
// 3. Minderjährige → Betroffene + Risiko
const minors = getAnswer(scopeAnswers, 'data_minors')
if (minors === true || minors === 'yes') {
result.dataSubjects.push('Minderjaehrige (unter 16 Jahre)')
result.riskLevel = 'hoch'
result.affectedRights.push('Besonderer Schutz Minderjaehriger (Art. 8 DSGVO)')
if (!result.title) result.title = 'DSFA — Verarbeitung von Daten Minderjaehriger'
}
// 4. Automatisierte Entscheidungen (Scoring)
const scoring = getAnswer(scopeAnswers, 'proc_adm_scoring')
if (scoring === true || scoring === 'yes') {
result.affectedRights.push('Recht auf nicht-automatisierte Entscheidung (Art. 22 DSGVO)')
result.riskLevel = 'hoch'
if (!result.title) result.title = 'DSFA — Automatisierte Einzelentscheidungen'
result.measures.push('Menschliche Pruefung')
}
// 5. KI-Einsatz
const aiUsage = getAnswer(scopeAnswers, 'proc_ai_usage')
if (aiUsage === true || aiUsage === 'yes' || (Array.isArray(aiUsage) && aiUsage.length > 0)) {
result.involvesAi = true
result.measures.push('KI-Transparenz', 'Human Oversight')
if (!result.title) result.title = 'DSFA — KI-gestuetzte Datenverarbeitung'
}
// 6. Videoueberwachung
const video = getAnswer(scopeAnswers, 'proc_video_surveillance')
if (video === true || video === 'yes') {
result.dataCategories.push('Videoaufnahmen / Bilddaten')
result.dataSubjects.push('Besucher', 'Mitarbeiter')
if (!result.title) result.title = 'DSFA — Videoueberwachung'
}
// 7. Datenvolumen
const volume = getAnswer(scopeAnswers, 'data_volume')
if (volume === '100000-1000000' || volume === '>1000000') {
result.riskLevel = 'hoch'
result.description += 'Grosse Datenmengen erhoehen das Risiko fuer Betroffene. '
}
// 8. Branche + Geschaeftsmodell → Verarbeitungszweck
if (profile?.industry?.length) {
const ind = profile.industry[0]
const purposeMap: Record<string, string> = {
healthcare: 'Patientenversorgung und Gesundheitsdatenverarbeitung',
finance: 'Finanzdienstleistungen und Bonitaetspruefung',
education: 'Bildungsverwaltung und Schuelerbetreuung',
tech: 'Software-Entwicklung und Cloud-Dienste',
retail: 'Handel und Kundenbeziehungsmanagement',
legal: 'Mandatsbearbeitung und Rechtsberatung',
}
result.processingPurpose = purposeMap[ind] || ''
result.processingActivity = purposeMap[ind] || ''
}
if (profile?.businessModel === 'b2c') {
result.dataSubjects.push('Endverbraucher')
result.legalBasis = 'Art. 6 Abs. 1 lit. b DSGVO (Vertragserfuellung)'
} else if (profile?.businessModel === 'b2b') {
result.dataSubjects.push('Geschaeftskunden', 'Ansprechpartner')
result.legalBasis = 'Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse)'
}
// Deduplicate
result.dataCategories = [...new Set(result.dataCategories)]
result.dataSubjects = [...new Set(result.dataSubjects)]
result.measures = [...new Set(result.measures)]
result.affectedRights = [...new Set(result.affectedRights)]
// Default title if nothing triggered
if (!result.title) {
result.title = `DSFA — ${profile?.companyName || 'Datenverarbeitung'}`
}
return result
}
/**
* Check if DSFA is required based on scope answers (Art. 35 Abs. 3 triggers)
* AND Bundesland-specific blacklist (Art. 35 Abs. 4).
*/
export function isDSFARequired(
scopeAnswers: ScopeProfilingAnswer[],
headquartersState?: string,
): {
required: boolean
triggers: string[]
blacklistMatches: string[]
authority?: string
} {
const triggers: string[] = []
if (getAnswer(scopeAnswers, 'data_art9') === true || getAnswer(scopeAnswers, 'data_art9') === 'yes') {
triggers.push('Besondere Datenkategorien (Art. 9 DSGVO)')
}
if (getAnswer(scopeAnswers, 'data_minors') === true || getAnswer(scopeAnswers, 'data_minors') === 'yes') {
triggers.push('Daten Minderjaehriger (Art. 8 DSGVO)')
}
if (getAnswer(scopeAnswers, 'proc_adm_scoring') === true || getAnswer(scopeAnswers, 'proc_adm_scoring') === 'yes') {
triggers.push('Automatisierte Einzelentscheidungen (Art. 22 DSGVO)')
}
if (getAnswer(scopeAnswers, 'proc_video_surveillance') === true || getAnswer(scopeAnswers, 'proc_video_surveillance') === 'yes') {
triggers.push('Systematische Ueberwachung (Art. 35 Abs. 3 lit. c)')
}
const vol = getAnswer(scopeAnswers, 'data_volume')
if (vol === '100000-1000000' || vol === '>1000000') {
triggers.push('Umfangreiche Datenverarbeitung (Art. 35 Abs. 3 lit. b)')
}
// Bundesland-Blacklist (Art. 35 Abs. 4)
let blacklistMatches: string[] = []
let authority: string | undefined
if (headquartersState) {
const { checkBlacklist, scopeAnswersToKeywords } = require('./bundesland-blacklists')
const keywords = scopeAnswersToKeywords(scopeAnswers)
const result = checkBlacklist(headquartersState, keywords)
blacklistMatches = result.matches.map((m: { description: string }) => m.description)
authority = result.authority
}
return {
required: triggers.length > 0 || blacklistMatches.length > 0,
triggers,
blacklistMatches,
authority,
}
}
@@ -245,13 +245,16 @@ function generateHTML(config: CookieBannerConfig, privacyPolicyUrl: string): str
const categoriesHTML = config.categories
.map((cat) => {
const isRequired = cat.isRequired
// COMPLIANCE: Only "required" categories may be pre-enabled (EuGH Planet49)
// Non-required categories must NEVER be defaultEnabled
const isEnabled = isRequired ? true : false
return `
<div class="cookie-banner-category" data-category="${cat.id}">
<div class="cookie-banner-category-info">
<div class="cookie-banner-category-name">${cat.name.de}</div>
<div class="cookie-banner-category-desc">${cat.description.de}</div>
</div>
<div class="cookie-banner-toggle ${cat.defaultEnabled ? 'active' : ''} ${isRequired ? 'disabled' : ''}"
<div class="cookie-banner-toggle ${isEnabled ? 'active' : ''} ${isRequired ? 'disabled' : ''}"
data-category="${cat.id}"
data-required="${isRequired}"></div>
</div>
@@ -286,10 +289,22 @@ function generateHTML(config: CookieBannerConfig, privacyPolicyUrl: string): str
</div>
</div>
<div class="cookie-banner-links">
<a href="${privacyPolicyUrl}" class="cookie-banner-link" target="_blank">
${config.texts.privacyPolicyLink.de}
</a>
<a href="${config.impressumUrl || '/impressum'}" class="cookie-banner-link" target="_blank">
Impressum
</a>
</div>
</div>
<!-- Cookie Settings Re-Open (§7(3) DSGVO — Widerruf so einfach wie Einwilligung) -->
<a href="#" id="cookieBannerReopen" class="cookie-settings-footer-link"
onclick="document.getElementById('cookieBanner').style.display='block';document.getElementById('cookieBannerOverlay').classList.add('active');return false;"
style="position:fixed;bottom:8px;left:8px;z-index:9990;font-size:11px;color:#6b7280;text-decoration:none;background:rgba(255,255,255,0.9);padding:4px 8px;border-radius:4px;border:1px solid #e5e7eb;">
Cookie-Einstellungen
</a>
`.trim()
}
@@ -310,6 +325,29 @@ function generateJS(config: CookieBannerConfig): string {
const CATEGORIES = ${JSON.stringify(categoryIds)};
const REQUIRED_CATEGORIES = ${JSON.stringify(requiredCategories)};
// Google Consent Mode v2 — PFLICHT seit Maerz 2024 fuer Google Services in EEA
// Sets default consent state to "denied" BEFORE any Google tags fire
if (typeof gtag === 'function') {
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
functionality_storage: 'granted',
security_storage: 'granted',
});
}
function updateGoogleConsentMode(consent) {
if (typeof gtag !== 'function') return;
gtag('consent', 'update', {
analytics_storage: consent.statistics ? 'granted' : 'denied',
ad_storage: consent.marketing ? 'granted' : 'denied',
ad_user_data: consent.marketing ? 'granted' : 'denied',
ad_personalization: consent.marketing ? 'granted' : 'denied',
});
}
function getConsent() {
const cookie = document.cookie.split('; ').find(row => row.startsWith(COOKIE_NAME + '='));
if (!cookie) return null;
@@ -327,6 +365,7 @@ function generateJS(config: CookieBannerConfig): string {
';expires=' + date.toUTCString() +
';path=/;SameSite=Lax';
window.dispatchEvent(new CustomEvent('cookieConsentUpdated', { detail: consent }));
updateGoogleConsentMode(consent);
}
function hasConsent(category) {
@@ -397,6 +436,31 @@ function generateJS(config: CookieBannerConfig): string {
overlay?.classList.remove('active');
}
// Script-Blocking: activate scripts with data-cookie-category ONLY after consent
function activateConsentedScripts() {
const consent = getConsent();
if (!consent) return;
// Find all blocked scripts (type="text/plain" with data-cookie-category)
document.querySelectorAll('script[data-cookie-category][type="text/plain"]').forEach(script => {
const category = script.getAttribute('data-cookie-category');
if (consent[category] === true) {
// Replace type to activate the script
const newScript = document.createElement('script');
if (script.src) newScript.src = script.src;
else newScript.textContent = script.textContent;
newScript.type = 'text/javascript';
script.parentNode.replaceChild(newScript, script);
}
});
// Also fire custom event for programmatic listeners
window.dispatchEvent(new CustomEvent('cookieConsentActivated', { detail: consent }));
}
// Run script activation after consent is saved
window.addEventListener('cookieConsentUpdated', activateConsentedScripts);
window.CookieConsent = {
getConsent,
saveConsent,
@@ -405,14 +469,32 @@ function generateJS(config: CookieBannerConfig): string {
document.getElementById('cookieBanner')?.classList.add('active');
document.getElementById('cookieBannerOverlay')?.classList.add('active');
},
hide: closeBanner
hide: closeBanner,
activateScripts: activateConsentedScripts,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBanner);
document.addEventListener('DOMContentLoaded', () => {
initBanner();
activateConsentedScripts();
});
} else {
initBanner();
activateConsentedScripts();
}
})();
/*
* USAGE: Script-Blocking
*
* Instead of:
* <script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
*
* Use:
* <script type="text/plain" data-cookie-category="statistics"
* src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
*
* The script will only execute AFTER the user consents to "statistics".
*/
`.trim()
}
@@ -328,7 +328,8 @@ export function isPolicyOverdue(policy: LoeschfristPolicy): boolean {
}
export function getActiveLegalHolds(policy: LoeschfristPolicy): LegalHold[] {
return policy.legalHolds.filter(h => h.status === 'ACTIVE')
const holds = Array.isArray(policy.legalHolds) ? policy.legalHolds : []
return holds.filter(h => h.status === 'ACTIVE')
}
export function getEffectiveDeletionTrigger(policy: LoeschfristPolicy): DeletionTriggerLevel {
@@ -85,6 +85,8 @@ export type TemplateType =
| 'tom_documentation'
| 'loeschkonzept'
| 'pflichtenregister'
// SOP (Migration 112)
| 'standard_operating_procedure'
export type Jurisdiction = 'DE' | 'AT' | 'CH' | 'EU' | 'US' | 'INTL'
+54 -66
View File
@@ -57,6 +57,19 @@ export const SDK_STEPS: SDKStep[] = [
isOptional: true,
visibleWhen: (state) => state.customerType === 'existing',
},
{
id: 'ai-act',
seq: 350,
phase: 1,
package: 'vorbereitung',
order: 4,
name: 'AI Act Klassifizierung',
nameShort: 'AI Act',
description: 'KI-Risikostufe (nur bei KI-Einsatz)',
url: '/sdk/ai-act',
checkpointId: 'CP-AI',
prerequisiteSteps: ['use-case-assessment'],
isOptional: true },
{
id: 'screening',
seq: 500,
@@ -65,39 +78,27 @@ export const SDK_STEPS: SDKStep[] = [
order: 5,
name: 'System Screening',
nameShort: 'Screening',
description: 'SBOM + Security Check',
description: 'SBOM + Vulnerability Scan (OSV.dev)',
url: '/sdk/screening',
checkpointId: 'CP-SCAN',
prerequisiteSteps: ['use-case-assessment'],
isOptional: false },
{
id: 'modules',
seq: 600,
phase: 1,
package: 'vorbereitung',
order: 6,
name: 'Compliance Modules',
nameShort: 'Module',
description: 'Abgleich welche Regulierungen gelten',
url: '/sdk/modules',
checkpointId: 'CP-MOD',
prerequisiteSteps: ['screening'],
isOptional: false },
isOptional: true },
// Modules entfernt — Regulierungen werden im Scope-Decision-Tab + Dashboard angezeigt
{
id: 'source-policy',
seq: 700,
phase: 1,
package: 'vorbereitung',
order: 7,
name: 'Source Policy',
name: 'Quellen-Verwaltung',
nameShort: 'Quellen',
description: 'Datenquellen-Governance & Whitelist',
description: 'RAG Quellen-Whitelist (Enterprise)',
url: '/sdk/source-policy',
checkpointId: 'CP-SPOL',
prerequisiteSteps: ['modules'],
isOptional: false },
prerequisiteSteps: ['use-case-assessment'],
isOptional: true },
// PAKET 2: ANALYSE (Assessment)
// PAKET 2: ANALYSE (Assessment) — Requirements → Controls → Risks → Checklist → Report
{
id: 'requirements',
seq: 1000,
@@ -106,10 +107,10 @@ export const SDK_STEPS: SDKStep[] = [
order: 1,
name: 'Requirements',
nameShort: 'Anforderungen',
description: 'Pr\u00fcfaspekte aus Regulierungen ableiten',
description: 'Pruefaspekte aus Regulierungen ableiten',
url: '/sdk/requirements',
checkpointId: 'CP-REQ',
prerequisiteSteps: ['source-policy'],
prerequisiteSteps: ['compliance-scope'],
isOptional: false },
{
id: 'controls',
@@ -119,72 +120,46 @@ export const SDK_STEPS: SDKStep[] = [
order: 2,
name: 'Controls',
nameShort: 'Controls',
description: 'Erforderliche Ma\u00dfnahmen ermitteln',
description: 'Technische & organisatorische Massnahmen',
url: '/sdk/controls',
checkpointId: 'CP-CTRL',
prerequisiteSteps: ['requirements'],
isOptional: false },
{
id: 'evidence',
id: 'risks',
seq: 1200,
phase: 1,
package: 'analyse',
order: 3,
name: 'Evidence',
nameShort: 'Nachweise',
description: 'Nachweise dokumentieren',
url: '/sdk/evidence',
checkpointId: 'CP-EVI',
name: 'Risk Matrix',
nameShort: 'Risiken',
description: 'Risikobewertung — wo sind Luecken?',
url: '/sdk/risks',
checkpointId: 'CP-RISK',
prerequisiteSteps: ['controls'],
isOptional: false },
{
id: 'risks',
id: 'audit-checklist',
seq: 1300,
phase: 1,
package: 'analyse',
order: 4,
name: 'Risk Matrix',
nameShort: 'Risiken',
description: 'Risikobewertung & Residual Risk',
url: '/sdk/risks',
checkpointId: 'CP-RISK',
prerequisiteSteps: ['evidence'],
name: 'Audit Checklist',
nameShort: 'Checklist',
description: 'Pruefbare Checkliste generieren',
url: '/sdk/audit-checklist',
checkpointId: 'CP-CHK',
prerequisiteSteps: ['risks'],
isOptional: false },
{
id: 'ai-act',
id: 'audit-report',
seq: 1400,
phase: 1,
package: 'analyse',
order: 5,
name: 'AI Act Klassifizierung',
nameShort: 'AI Act',
description: 'Risikostufe nach EU AI Act',
url: '/sdk/ai-act',
checkpointId: 'CP-AI',
prerequisiteSteps: ['risks'],
isOptional: false },
{
id: 'audit-checklist',
seq: 1500,
phase: 1,
package: 'analyse',
order: 6,
name: 'Audit Checklist',
nameShort: 'Checklist',
description: 'Pr\u00fcfliste generieren',
url: '/sdk/audit-checklist',
checkpointId: 'CP-CHK',
prerequisiteSteps: ['ai-act'],
isOptional: false },
{
id: 'audit-report',
seq: 1600,
phase: 1,
package: 'analyse',
order: 7,
name: 'Audit Report',
nameShort: 'Report',
description: 'Audit-Sitzungen & PDF-Report',
description: 'Zusammenfassender Audit-Report (PDF)',
url: '/sdk/audit-report',
checkpointId: 'CP-AREP',
prerequisiteSteps: ['audit-checklist'],
@@ -271,8 +246,8 @@ export const SDK_STEPS: SDKStep[] = [
phase: 2,
package: 'rechtliche-texte',
order: 1,
name: 'Einwilligungen',
nameShort: 'Einwilligungen',
name: 'Consent-Records',
nameShort: 'Consent-Records',
description: 'Datenpunktkatalog & DSI-Generator',
url: '/sdk/einwilligungen',
checkpointId: 'CP-CONS',
@@ -334,6 +309,19 @@ export const SDK_STEPS: SDKStep[] = [
isOptional: false },
// PAKET 5: BETRIEB (Operations)
{
id: 'evidence',
seq: 3900,
phase: 2,
package: 'betrieb',
order: 0,
name: 'Evidence',
nameShort: 'Nachweise',
description: 'Nachweise laufend dokumentieren',
url: '/sdk/evidence',
checkpointId: 'CP-EVI',
prerequisiteSteps: ['audit-report'],
isOptional: false },
{
id: 'dsr',
seq: 4000,
@@ -0,0 +1,130 @@
/**
* EU-Angemessenheitsbeschluesse (Art. 45 DSGVO)
*
* Laender mit Angemessenheitsbeschluss benoetigen KEINE SCC und KEIN TIA
* fuer Datenuebermittlungen. Die Liste wird von der EU-Kommission gefuehrt.
*
* WICHTIG: USA hat Sonderstatus Angemessenheit gilt NUR fuer Unternehmen,
* die nach dem EU-US Data Privacy Framework (DPF) zertifiziert sind.
* Nicht-zertifizierte US-Unternehmen brauchen weiterhin SCC + TIA.
*
* Quelle: https://commission.europa.eu/law/law-topic/data-protection/
* international-dimension-data-protection/adequacy-decisions_en
*/
export interface AdequacyDecision {
/** ISO 3166-1 alpha-2 Laendercode */
countryCode: string
/** Laendername (deutsch) */
countryName: string
/** Jahr des Angemessenheitsbeschlusses */
since: number
/** Einschraenkungen (z.B. nur bestimmte Sektoren) */
restriction?: string
/** Befristet? */
expires?: string
/** Sonderstatus (z.B. DPF-Zertifizierung erforderlich) */
requiresCertification?: boolean
/** Name der erforderlichen Zertifizierung */
certificationName?: string
/** Pruef-URL fuer die Zertifizierung */
certificationCheckUrl?: string
}
/**
* Vollstaendige Liste der Laender mit EU-Angemessenheitsbeschluss.
* Stand: Mai 2026
*/
export const ADEQUACY_DECISIONS: AdequacyDecision[] = [
{ countryCode: 'AD', countryName: 'Andorra', since: 2010 },
{ countryCode: 'AR', countryName: 'Argentinien', since: 2003 },
{ countryCode: 'FO', countryName: 'Faeroeer-Inseln', since: 2010 },
{ countryCode: 'GG', countryName: 'Guernsey', since: 2003 },
{ countryCode: 'IM', countryName: 'Isle of Man', since: 2004 },
{ countryCode: 'IL', countryName: 'Israel', since: 2011 },
{ countryCode: 'JP', countryName: 'Japan', since: 2019 },
{ countryCode: 'JE', countryName: 'Jersey', since: 2008 },
{
countryCode: 'CA', countryName: 'Kanada', since: 2001,
restriction: 'Nur Unternehmen, die dem Personal Information Protection and Electronic Documents Act (PIPEDA) unterliegen',
},
{ countryCode: 'NZ', countryName: 'Neuseeland', since: 2012 },
{ countryCode: 'KR', countryName: 'Republik Korea (Suedkorea)', since: 2022 },
{ countryCode: 'CH', countryName: 'Schweiz', since: 2000 },
{
countryCode: 'GB', countryName: 'Vereinigtes Koenigreich (UK)', since: 2021,
expires: 'Befristet, verlaengert bis 2029',
},
{ countryCode: 'UY', countryName: 'Uruguay', since: 2012 },
{
countryCode: 'US', countryName: 'Vereinigte Staaten (USA)', since: 2023,
restriction: 'Nur Unternehmen, die nach dem EU-US Data Privacy Framework (DPF) zertifiziert sind',
requiresCertification: true,
certificationName: 'EU-US Data Privacy Framework (DPF)',
certificationCheckUrl: 'https://www.dataprivacyframework.gov/list',
},
]
/** Set der EU/EWR-Laender (kein Angemessenheitsbeschluss noetig) */
export const EU_EEA_COUNTRIES = new Set([
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
// EWR (nicht EU, aber gleicher Datenschutzraum)
'IS', 'LI', 'NO',
])
/** Set der Laendercodes mit Angemessenheitsbeschluss */
export const ADEQUATE_COUNTRIES = new Set(
ADEQUACY_DECISIONS.map((d) => d.countryCode)
)
/**
* Prueft ob ein Land einen Angemessenheitsbeschluss hat.
* Gibt das Decision-Objekt zurueck oder null.
*/
export function getAdequacyDecision(countryCode: string): AdequacyDecision | null {
return ADEQUACY_DECISIONS.find((d) => d.countryCode === countryCode) || null
}
/**
* Bestimmt den Transfer-Status fuer ein Land.
*/
export function getTransferRequirement(countryCode: string): {
isEU: boolean
isAdequate: boolean
requiresSCC: boolean
requiresTIA: boolean
requiresCertification: boolean
explanation: string
} {
if (EU_EEA_COUNTRIES.has(countryCode)) {
return {
isEU: true, isAdequate: true,
requiresSCC: false, requiresTIA: false, requiresCertification: false,
explanation: 'EU-/EWR-Mitgliedstaat — keine zusaetzlichen Massnahmen erforderlich.',
}
}
const decision = getAdequacyDecision(countryCode)
if (decision) {
if (decision.requiresCertification) {
return {
isEU: false, isAdequate: true,
requiresSCC: false, requiresTIA: false, requiresCertification: true,
explanation: `Angemessenheitsbeschluss seit ${decision.since}. ${decision.restriction || ''} Pruefung der Zertifizierung unter: ${decision.certificationCheckUrl || ''}`,
}
}
return {
isEU: false, isAdequate: true,
requiresSCC: false, requiresTIA: false, requiresCertification: false,
explanation: `Angemessenheitsbeschluss der EU-Kommission seit ${decision.since}.${decision.restriction ? ` Einschraenkung: ${decision.restriction}` : ''}${decision.expires ? ` (${decision.expires})` : ''}`,
}
}
return {
isEU: false, isAdequate: false,
requiresSCC: true, requiresTIA: true, requiresCertification: false,
explanation: 'Kein Angemessenheitsbeschluss — EU-Standardvertragsklauseln (SCC) und Transfer Impact Assessment (TIA) erforderlich (Art. 46 Abs. 2 lit. c DSGVO, EuGH Schrems II).',
}
}
@@ -21,7 +21,8 @@ import {
// CONFIGURATION
// =============================================================================
const WB_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093'
// Use compliance backend proxy (Python) instead of Go SDK
const WB_API_BASE = '/api/sdk/v1/compliance'
const API_TIMEOUT = 30000
// =============================================================================
@@ -121,27 +122,27 @@ export async function fetchReports(filters?: ReportFilters): Promise<ReportListR
}
const queryString = params.toString()
const url = `${WB_API_BASE}/api/v1/admin/whistleblower/reports${queryString ? `?${queryString}` : ''}`
const url = `${WB_API_BASE}/whistleblower/reports${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<ReportListResponse>(url)
}
export async function fetchReport(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`
`${WB_API_BASE}/whistleblower/reports/${id}`
)
}
export async function updateReport(id: string, update: ReportUpdateRequest): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`,
`${WB_API_BASE}/whistleblower/reports/${id}`,
{ method: 'PUT', body: JSON.stringify(update) }
)
}
export async function deleteReport(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`,
`${WB_API_BASE}/whistleblower/reports/${id}`,
{ method: 'DELETE' }
)
}
@@ -154,7 +155,7 @@ export async function submitPublicReport(
data: PublicReportSubmission
): Promise<{ report: WhistleblowerReport; accessKey: string }> {
const response = await fetch(
`${WB_API_BASE}/api/v1/public/whistleblower/submit`,
`${WB_API_BASE}/whistleblower/submit`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -173,7 +174,7 @@ export async function fetchReportByAccessKey(
accessKey: string
): Promise<WhistleblowerReport> {
const response = await fetch(
`${WB_API_BASE}/api/v1/public/whistleblower/report/${accessKey}`,
`${WB_API_BASE}/whistleblower/check/${accessKey}`,
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
)
@@ -190,14 +191,14 @@ export async function fetchReportByAccessKey(
export async function acknowledgeReport(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/acknowledge`,
`${WB_API_BASE}/whistleblower/reports/${id}/acknowledge`,
{ method: 'POST' }
)
}
export async function startInvestigation(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/investigate`,
`${WB_API_BASE}/whistleblower/reports/${id}/investigate`,
{ method: 'POST' }
)
}
@@ -207,7 +208,7 @@ export async function addMeasure(
measure: Omit<WhistleblowerMeasure, 'id' | 'reportId' | 'completedAt'>
): Promise<WhistleblowerMeasure> {
return fetchWithTimeout<WhistleblowerMeasure>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/measures`,
`${WB_API_BASE}/whistleblower/reports/${id}/measures`,
{ method: 'POST', body: JSON.stringify(measure) }
)
}
@@ -217,7 +218,7 @@ export async function closeReport(
resolution: { reason: string; notes: string }
): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/close`,
`${WB_API_BASE}/whistleblower/reports/${id}/close`,
{ method: 'POST', body: JSON.stringify(resolution) }
)
}
@@ -232,14 +233,14 @@ export async function sendMessage(
role: 'reporter' | 'ombudsperson'
): Promise<AnonymousMessage> {
return fetchWithTimeout<AnonymousMessage>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages`,
`${WB_API_BASE}/whistleblower/reports/${reportId}/messages`,
{ method: 'POST', body: JSON.stringify({ senderRole: role, message }) }
)
}
export async function fetchMessages(reportId: string): Promise<AnonymousMessage[]> {
return fetchWithTimeout<AnonymousMessage[]>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages`
`${WB_API_BASE}/whistleblower/reports/${reportId}/messages`
)
}
@@ -269,7 +270,7 @@ export async function uploadAttachment(
}
const response = await fetch(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/attachments`,
`${WB_API_BASE}/whistleblower/reports/${reportId}/attachments`,
{
method: 'POST',
headers,
@@ -290,7 +291,7 @@ export async function uploadAttachment(
export async function deleteAttachment(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`,
`${WB_API_BASE}/whistleblower/attachments/${id}`,
{ method: 'DELETE' }
)
}
@@ -301,6 +302,6 @@ export async function deleteAttachment(id: string): Promise<void> {
export async function fetchWhistleblowerStatistics(): Promise<WhistleblowerStatistics> {
return fetchWithTimeout<WhistleblowerStatistics>(
`${WB_API_BASE}/api/v1/admin/whistleblower/statistics`
`${WB_API_BASE}/whistleblower/reports/stats`
)
}
@@ -63,6 +63,13 @@ _ROUTER_MODULES = [
"tom_mapping_routes",
"llm_audit_routes",
"assertion_routes",
"org_role_routes",
"document_review_routes",
"banner_analytics_routes",
"banner_ab_routes",
"compliance_report_routes",
"whistleblower_routes",
"tcf_routes",
]
_loaded_count = 0
@@ -15,6 +15,14 @@ from fastapi import APIRouter
from pydantic import BaseModel
from compliance.services.smtp_sender import send_email
from compliance.services.intake_extractor import extract_intake_flags_from_services, flags_to_ucca_intake
from compliance.services.relevance_filter import filter_controls
from compliance.services.website_compliance_checks import (
check_website_compliance as _check_website_compliance,
FollowUpQuestion,
to_string_list as _to_string_list,
risk_to_escalation as _risk_to_escalation,
)
logger = logging.getLogger(__name__)
@@ -77,21 +85,32 @@ async def analyze_url(req: AnalyzeRequest):
# Step 2: Classify via SDK LLM
classification = await _classify(client, text)
# Step 3: Assess via UCCA
assessment = await _assess(client, text, classification)
# Step 3: Detect services from HTML (deterministic, no LLM needed)
from compliance.services.service_registry import SERVICE_REGISTRY
detected_services = []
html_lower = raw_html.lower()
for pattern, meta in SERVICE_REGISTRY.items():
if re.search(pattern, html_lower):
detected_services.append(meta)
# Step 4: Determine role
# Step 4: Derive intake flags from DETECTED SERVICES (not from text!)
intake_flags = extract_intake_flags_from_services(detected_services)
# Step 5: Assess via UCCA with service-derived flags
assessment = await _assess(client, text, classification, intake_flags)
# Step 5: Determine role
esc_level = assessment.get("escalation_level", "E0")
role = ESCALATION_ROLES.get(esc_level, ESCALATION_ROLES["E0"])
# Step 5: Website compliance checks (§312k BGB etc.)
# Step 6: Website compliance checks (§312k BGB etc.)
site_findings, follow_ups = await _check_website_compliance(client, req.url, raw_html)
# Step 6: Merge findings
# Step 7: Merge and filter findings/controls
findings = assessment.get("triggered_rules", [])
controls = assessment.get("required_controls", [])
findings_str = _to_string_list(findings) + site_findings
controls_str = _to_string_list(controls)
controls_str = filter_controls(_to_string_list(controls), text, intake_flags)
# Escalate if website checks found issues
if site_findings and esc_level == "E0":
@@ -105,7 +124,7 @@ async def analyze_url(req: AnalyzeRequest):
email_result = send_email(
recipient=req.recipient,
subject=f"[{mode_label}] Compliance-Finding: {classification}{req.url[:60]}",
body_html=f"<div>{summary}</div>",
body_html=summary,
)
return AnalyzeResponse(
@@ -179,34 +198,24 @@ async def _classify(client: httpx.AsyncClient, text: str) -> str:
return "other"
async def _assess(client: httpx.AsyncClient, text: str, classification: str) -> dict:
async def _assess(client: httpx.AsyncClient, text: str, classification: str, intake_flags: dict | None = None) -> dict:
"""Run UCCA assessment via SDK. Returns flattened result dict."""
try:
# UCCA expects boolean intake flags, not string categories
# Use LLM-extracted flags if available, otherwise minimal defaults
if intake_flags:
ucca_intake = flags_to_ucca_intake(intake_flags)
else:
ucca_intake = {
"data_types": {"personal_data": True},
"purpose": {},
"automation": "manual",
"outputs": {},
}
resp = await client.post(f"{SDK_URL}/sdk/v1/ucca/assess", headers=SDK_HEADERS, json={
"use_case_text": text[:3000],
"domain": classification,
"data_types": {
"personal_data": True,
"customer_data": True,
"location_data": "tracking" in text.lower() or "standort" in text.lower(),
"images": False,
"biometric_data": "biometrisch" in text.lower(),
"minor_data": "kinder" in text.lower() or "minderjährig" in text.lower(),
},
"purpose": {
"marketing": "werbung" in text.lower() or "marketing" in text.lower(),
"analytics": "analyse" in text.lower() or "analytics" in text.lower(),
"profiling": "profil" in text.lower() or "personalis" in text.lower(),
"automation": False,
"customer_support": False,
},
"automation": "partially_automated",
"outputs": {
"content_generation": False,
"recommendations_to_users": "empfehl" in text.lower(),
"data_export": "export" in text.lower() or "uebertrag" in text.lower(),
},
**ucca_intake,
})
data = resp.json()
# Flatten: UCCA wraps result under "assessment" and "result"
@@ -227,126 +236,27 @@ async def _assess(client: httpx.AsyncClient, text: str, classification: str) ->
return {"risk_level": "unknown", "risk_score": 0, "escalation_level": "E0"}
async def _check_website_compliance(
client: httpx.AsyncClient, url: str, html: str,
) -> tuple[list[str], list[FollowUpQuestion]]:
"""Scan public website for consumer protection compliance (§312k BGB etc.)."""
findings: list[str] = []
follow_ups: list[FollowUpQuestion] = []
html_lower = html.lower()
base_domain = re.sub(r"https?://([^/]+).*", r"\1", url)
# --- §312k BGB: Kündigungsbutton ---
cancel_patterns = [
r'href="[^"]*(?:kuendig|kündig|cancel|vertrag.?beenden|abo.?beenden|mitgliedschaft.?beenden)[^"]*"',
r'(?:kündigen|kuendigen|vertrag beenden|abo beenden|mitgliedschaft kündigen)',
]
has_cancel_link = any(re.search(p, html_lower) for p in cancel_patterns)
# Also check common cancel URLs
cancel_urls_to_probe = [
f"https://{base_domain}/kuendigen",
f"https://{base_domain}/cancel",
f"https://{base_domain}/vertrag-kuendigen",
f"https://{base_domain}/abo-kuendigen",
f"https://{base_domain}/account/cancel",
]
if not has_cancel_link:
for probe_url in cancel_urls_to_probe:
try:
probe = await client.head(probe_url, follow_redirects=True, timeout=5.0)
if probe.status_code < 400:
has_cancel_link = True
break
except Exception:
continue
if not has_cancel_link:
findings.append(
"[§312k BGB] Kein oeffentlich sichtbarer Kuendigungsbutton gefunden. "
"Seit 01.07.2022 muessen online geschlossene Vertraege mit max. 2 Klicks kuendbar sein."
)
follow_ups.append(FollowUpQuestion(
id="cancel_button_312k",
question="Koennen Sie nach Login im Kundenbereich innerhalb von 2 Klicks Ihren Vertrag kuendigen?",
legal_basis="§ 312k BGB (Kuendigungsbutton), Omnibus-Richtlinie (EU) 2019/2161",
severity="high",
finding_if_no=(
"[§312k BGB] VERSTOSS: Kein funktionaler Kuendigungsbutton vorhanden. "
"Der Anbieter ist verpflichtet, einen leicht auffindbaren Kuendigungsbutton "
"bereitzustellen (max. 2 Klicks). Ein Zwang zur telefonischen Kuendigung "
"oder Kuendigung per Brief ist rechtswidrig."
),
))
# --- Impressumspflicht (§5 TMG / §18 MStV) ---
imprint_patterns = [
r'href="[^"]*(?:impressum|imprint|legal.?notice|about.?us/legal)[^"]*"',
r'>impressum<',
]
has_imprint = any(re.search(p, html_lower) for p in imprint_patterns)
if not has_imprint:
findings.append(
"[§5 TMG] Kein Impressum-Link auf der Seite gefunden. "
"Geschaeftsmaessige Online-Dienste muessen ein leicht erreichbares Impressum bereitstellen."
)
# --- Datenschutzerklaerung verlinkt? ---
privacy_patterns = [
r'href="[^"]*(?:datenschutz|privacy|dsgvo)[^"]*"',
r'>datenschutz<',
]
has_privacy = any(re.search(p, html_lower) for p in privacy_patterns)
if not has_privacy:
findings.append(
"[Art. 13 DSGVO] Kein Link zur Datenschutzerklaerung gefunden. "
"Nutzer muessen ueber die Verarbeitung personenbezogener Daten informiert werden."
)
# --- Cookie-Consent-Banner ---
cookie_patterns = [
r'(?:cookie.?consent|cookie.?banner|consent.?manager|didomi|cookiebot|onetrust|usercentrics)',
r'(?:gdpr|dsgvo).?(?:consent|einwilligung)',
]
has_cookie_consent = any(re.search(p, html_lower) for p in cookie_patterns)
if not has_cookie_consent:
follow_ups.append(FollowUpQuestion(
id="cookie_consent",
question="Wird beim ersten Besuch der Website ein Cookie-Consent-Banner angezeigt?",
legal_basis="§ 25 TDDDG (ehem. TTDSG), Art. 5(3) ePrivacy-Richtlinie",
severity="medium",
finding_if_no=(
"[§25 TDDDG] Kein Cookie-Consent-Banner erkannt. "
"Vor dem Setzen nicht-essentieller Cookies ist eine Einwilligung erforderlich."
),
))
return findings, follow_ups
# _check_website_compliance, _to_string_list, _risk_to_escalation
# → extracted to compliance/services/website_compliance_checks.py
def _to_string_list(items: list) -> list[str]:
"""Convert list of dicts or strings to list of strings."""
result = []
for item in (items or []):
if isinstance(item, dict):
# UCCA returns {code, category, description} or {id, name, description}
desc = item.get("description", item.get("name", item.get("code", str(item))))
code = item.get("code", item.get("id", ""))
result.append(f"[{code}] {desc}" if code else str(desc))
else:
result.append(str(item))
return result
def _risk_to_escalation(risk_level: str) -> str:
"""Map UCCA risk level to escalation level."""
mapping = {
"MINIMAL": "E0",
"LIMITED": "E1",
"HIGH": "E2",
"UNACCEPTABLE": "E3",
DOC_TYPE_LABELS = {
"privacy_policy": "Datenschutzerklaerung",
"cookie_banner": "Cookie-Banner",
"terms_of_service": "AGB",
"imprint": "Impressum",
"dpa": "Auftragsverarbeitung (AVV)",
"other": "Sonstiges",
}
RISK_COLORS = {
"MINIMAL": ("#16a34a", "Niedrig"),
"LOW": ("#ca8a04", "Gering"),
"LIMITED": ("#ea580c", "Mittel"),
"HIGH": ("#dc2626", "Hoch"),
"UNACCEPTABLE": ("#991b1b", "Kritisch"),
}
return mapping.get(risk_level.upper() if risk_level else "", "E0")
def _build_summary(
@@ -354,48 +264,54 @@ def _build_summary(
findings_str: list[str], controls_str: list[str],
mode: str = "post_launch",
) -> str:
"""Build a German manager summary, adapted to pre/post-launch context."""
"""Build HTML summary for email and frontend."""
risk = assessment.get("risk_level", "unbekannt")
score = assessment.get("risk_score", 0)
recommendation = assessment.get("recommendation", "")
dsfa = assessment.get("dsfa_recommended", False)
is_live = mode == "post_launch"
risk_color, risk_label = RISK_COLORS.get(risk, ("#6b7280", risk))
doc_label = DOC_TYPE_LABELS.get(classification, classification)
findings_text = "\n".join(f"- {f}" for f in findings_str[:5]) if findings_str else "Keine"
controls_text = "\n".join(f"- {c}" for c in controls_str[:5]) if controls_str else "Keine"
mode_header = (
"PRUEFUNG LIVE-WEBSITE — Das Dokument ist bereits oeffentlich zugaenglich."
mode_banner = (
'<div style="background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin-bottom:16px;">'
'<strong style="color:#991b1b;">LIVE-WEBSITE</strong> — Das Dokument ist bereits oeffentlich zugaenglich.</div>'
if is_live else
"INTERNE PRUEFUNG — Das Dokument ist noch nicht veroeffentlicht."
'<div style="background:#eff6ff;border-left:4px solid #3b82f6;padding:12px 16px;margin-bottom:16px;">'
'<strong style="color:#1e40af;">INTERNE PRUEFUNG</strong> — Dokument noch nicht veroeffentlicht.</div>'
)
parts = [
mode_header,
"",
f"Dokumenttyp: {classification}",
f"Quelle: {url}",
f"Risikobewertung: {risk} ({score}/100)",
f"Zustaendig: {role}",
f"DSFA empfohlen: {'Ja' if dsfa else 'Nein'}",
"",
f"Findings:\n{findings_text}",
"",
f"Erforderliche Massnahmen:\n{controls_text}",
]
findings_html = "".join(f'<li style="margin-bottom:4px;">{f}</li>' for f in findings_str[:8]) if findings_str else '<li style="color:#6b7280;">Keine</li>'
controls_html = "".join(f'<li style="margin-bottom:4px;">{c}</li>' for c in controls_str[:8]) if controls_str else '<li style="color:#6b7280;">Keine</li>'
warning = ""
if is_live and findings_str:
parts.extend([
"",
"ACHTUNG: Diese Maengel sind bereits oeffentlich sichtbar. "
"Sofortige Nachbesserung empfohlen um Abmahnrisiken zu minimieren.",
])
warning = (
'<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px 16px;margin-top:16px;">'
'<strong style="color:#dc2626;">⚠ ACHTUNG:</strong> Diese Maengel sind bereits oeffentlich sichtbar. '
'Sofortige Nachbesserung empfohlen um Abmahnrisiken zu minimieren.</div>'
)
elif not is_live and controls_str:
parts.extend([
"",
"Empfehlung: Implementieren Sie die erforderlichen Kontrollen vor der Veroeffentlichung.",
])
warning = (
'<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:12px 16px;margin-top:16px;">'
'Empfehlung: Implementieren Sie die erforderlichen Kontrollen vor der Veroeffentlichung.</div>'
)
if recommendation:
parts.extend(["", f"Weitere Empfehlung: {recommendation}"])
return "\n".join(parts)
rec_html = f'<p style="color:#475569;margin-top:12px;"><em>{recommendation}</em></p>' if recommendation else ""
return f"""
{mode_banner}
<table style="width:100%;border-collapse:collapse;margin-bottom:16px;">
<tr><td style="padding:6px 0;color:#64748b;width:180px;">Dokumenttyp</td><td style="padding:6px 0;font-weight:600;">{doc_label}</td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Quelle</td><td style="padding:6px 0;"><a href="{url}" style="color:#6366f1;">{url}</a></td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Risikobewertung</td><td style="padding:6px 0;"><span style="background:{risk_color};color:white;padding:2px 8px;border-radius:4px;font-size:13px;">{risk_label} ({score}/100)</span></td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Zustaendig</td><td style="padding:6px 0;font-weight:600;">{role}</td></tr>
<tr><td style="padding:6px 0;color:#64748b;">DSFA empfohlen</td><td style="padding:6px 0;">{'Ja' if dsfa else 'Nein'}</td></tr>
</table>
<h3 style="color:#1e293b;font-size:15px;margin:16px 0 8px;">Findings</h3>
<ul style="margin:0;padding-left:20px;color:#334155;">{findings_html}</ul>
<h3 style="color:#1e293b;font-size:15px;margin:16px 0 8px;">Erforderliche Massnahmen</h3>
<ul style="margin:0;padding-left:20px;color:#334155;">{controls_html}</ul>
{warning}
{rec_html}
"""
@@ -0,0 +1,94 @@
"""
Agent Compare Routes scan multiple websites and compare compliance posture.
POST /api/compliance/agent/compare
"""
import asyncio
import logging
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
class CompareRequest(BaseModel):
urls: list[str] # 2-5 URLs to compare
mode: str = "post_launch"
class SiteResult(BaseModel):
url: str
domain: str
risk_level: str = ""
risk_score: float = 0
findings_count: int = 0
services_count: int = 0
has_impressum: bool = False
has_datenschutz: bool = False
has_cookie_banner: bool = False
has_google_fonts: bool = False
tracking_before_consent: int = 0
classification: str = ""
scan_status: str = "pending"
class CompareResponse(BaseModel):
sites: list[SiteResult]
compared_at: str
@router.post("/compare", response_model=CompareResponse)
async def compare_websites(req: CompareRequest):
"""Scan multiple websites and compare their compliance posture."""
urls = req.urls[:5] # Max 5
async def scan_one(url: str) -> SiteResult:
domain = url.split("/")[2] if len(url.split("/")) > 2 else url
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
"http://localhost:8002/api/compliance/agent/scan",
json={"url": url, "mode": req.mode},
)
if resp.status_code != 200:
return SiteResult(url=url, domain=domain, scan_status="failed")
data = resp.json()
services = data.get("services", [])
findings = data.get("findings", [])
return SiteResult(
url=url,
domain=domain,
risk_level=data.get("risk_level", ""),
risk_score=data.get("risk_score", 0),
findings_count=len(findings),
services_count=len(services),
has_impressum=not any("IMPRESSUM" in f.get("code", "") for f in findings if isinstance(f, dict)),
has_datenschutz=not any("DATENSCHUTZ" in f.get("code", "") for f in findings if isinstance(f, dict)),
has_cookie_banner=data.get("chatbot_detected", False) or any(
s.get("id") == "cmp" for s in services if isinstance(s, dict)
),
has_google_fonts=any(
s.get("id") == "google_fonts" for s in services if isinstance(s, dict)
),
classification=data.get("classification", ""),
scan_status="completed",
)
except Exception as e:
logger.error("Compare scan failed for %s: %s", url, e)
return SiteResult(url=url, domain=domain, scan_status="error")
# Scan all in parallel
results = await asyncio.gather(*[scan_one(u) for u in urls])
return CompareResponse(
sites=list(results),
compared_at=datetime.now(timezone.utc).isoformat(),
)
@@ -0,0 +1,220 @@
"""
Agent History Routes persist and retrieve scan results.
GET /api/compliance/agent/scans list recent scans
GET /api/compliance/agent/scans/{id} get single scan
POST /api/compliance/agent/scans save a scan result
"""
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Query
from fastapi.responses import Response
from pydantic import BaseModel
from compliance.services.agent_pdf_export import generate_scan_pdf
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
DATABASE_URL = os.environ.get(
"COMPLIANCE_DATABASE_URL",
os.environ.get("DATABASE_URL", ""),
)
class SaveScanRequest(BaseModel):
url: str
scan_type: str = "scan"
analysis_mode: str = "post_launch"
result: dict # Full scan result JSON
class ScanHistoryItem(BaseModel):
id: str
url: str
scan_type: str
analysis_mode: str
risk_level: str | None = None
risk_score: float = 0
findings_count: int = 0
pages_scanned: int = 0
email_sent: bool = False
created_at: str
class ScanDetail(BaseModel):
id: str
url: str
scan_type: str
analysis_mode: str
result: dict
created_at: str
async def _get_pool():
"""Get or create database connection pool."""
import asyncpg
if not DATABASE_URL:
return None
try:
return await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=3)
except Exception as e:
logger.warning("DB connection failed: %s", e)
return None
@router.post("/scans")
async def save_scan(req: SaveScanRequest):
"""Save a scan result to the database."""
pool = await _get_pool()
if not pool:
return {"status": "skipped", "reason": "no database"}
scan_id = str(uuid.uuid4())
result = req.result
try:
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO compliance_agent_scans
(id, url, scan_type, analysis_mode, classification, risk_level,
risk_score, escalation_level, responsible_role, services,
findings, summary_html, pages_scanned, pages_list, email_sent,
created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
""",
uuid.UUID(scan_id),
req.url,
req.scan_type,
req.analysis_mode,
result.get("classification", ""),
result.get("risk_level", ""),
result.get("risk_score", 0),
result.get("escalation_level", ""),
result.get("responsible_role", ""),
json.dumps(result.get("services", [])),
json.dumps(result.get("findings", [])),
result.get("summary", result.get("summary_html", "")),
result.get("pages_scanned", 0),
json.dumps(result.get("pages_list", [])),
result.get("email_status") == "sent",
datetime.now(timezone.utc),
)
return {"status": "saved", "id": scan_id}
except Exception as e:
logger.error("Failed to save scan: %s", e)
return {"status": "error", "error": str(e)}
finally:
await pool.close()
@router.get("/scans", response_model=list[ScanHistoryItem])
async def list_scans(
limit: int = Query(20, le=100),
scan_type: str | None = None,
):
"""List recent scans."""
pool = await _get_pool()
if not pool:
return []
try:
async with pool.acquire() as conn:
query = """
SELECT id, url, scan_type, analysis_mode, risk_level, risk_score,
findings, pages_scanned, email_sent, created_at
FROM compliance_agent_scans
"""
params = []
if scan_type:
query += " WHERE scan_type = $1"
params.append(scan_type)
query += " ORDER BY created_at DESC LIMIT " + str(limit)
rows = await conn.fetch(query, *params)
return [
ScanHistoryItem(
id=str(r["id"]),
url=r["url"],
scan_type=r["scan_type"],
analysis_mode=r["analysis_mode"],
risk_level=r["risk_level"],
risk_score=r["risk_score"] or 0,
findings_count=len(json.loads(r["findings"] or "[]")),
pages_scanned=r["pages_scanned"] or 0,
email_sent=r["email_sent"] or False,
created_at=r["created_at"].isoformat() if r["created_at"] else "",
)
for r in rows
]
except Exception as e:
logger.error("Failed to list scans: %s", e)
return []
finally:
await pool.close()
@router.get("/scans/{scan_id}", response_model=ScanDetail)
async def get_scan(scan_id: str):
"""Get a single scan result."""
pool = await _get_pool()
if not pool:
return ScanDetail(id=scan_id, url="", scan_type="", analysis_mode="", result={}, created_at="")
try:
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT * FROM compliance_agent_scans WHERE id = $1
""", uuid.UUID(scan_id))
if not row:
return ScanDetail(id=scan_id, url="", scan_type="", analysis_mode="", result={}, created_at="")
return ScanDetail(
id=str(row["id"]),
url=row["url"],
scan_type=row["scan_type"],
analysis_mode=row["analysis_mode"],
result={
"classification": row["classification"],
"risk_level": row["risk_level"],
"risk_score": row["risk_score"],
"services": json.loads(row["services"] or "[]"),
"findings": json.loads(row["findings"] or "[]"),
"summary": row["summary_html"],
"pages_scanned": row["pages_scanned"],
"pages_list": json.loads(row["pages_list"] or "[]"),
},
created_at=row["created_at"].isoformat() if row["created_at"] else "",
)
except Exception as e:
logger.error("Failed to get scan: %s", e)
return ScanDetail(id=scan_id, url="", scan_type="", analysis_mode="", result={}, created_at="")
finally:
await pool.close()
@router.post("/scans/pdf")
async def export_scan_pdf(req: SaveScanRequest):
"""Generate a PDF report from scan results (no DB required)."""
try:
pdf_bytes = generate_scan_pdf({
"url": req.url,
"scan_type": req.scan_type,
"analysis_mode": req.analysis_mode,
**req.result,
})
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="compliance-report-{req.url.split("/")[2][:30]}.pdf"'},
)
except Exception as e:
logger.error("PDF generation failed: %s", e)
return {"error": str(e)}
@@ -0,0 +1,111 @@
"""
Agent Recurring Scan Routes schedule and run automated periodic scans.
POST /api/compliance/agent/monitored-urls add URL to monitoring
GET /api/compliance/agent/monitored-urls list monitored URLs
POST /api/compliance/agent/run-scheduled trigger all scheduled scans
"""
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
DATABASE_URL = os.environ.get(
"COMPLIANCE_DATABASE_URL",
os.environ.get("DATABASE_URL", ""),
)
# In-memory fallback when no DB available
_monitored_urls: list[dict] = []
class MonitoredURL(BaseModel):
url: str
scan_type: str = "scan" # scan, consent_test
frequency: str = "weekly" # daily, weekly, monthly
recipient: str = "dsb@breakpilot.local"
enabled: bool = True
@router.post("/monitored-urls")
async def add_monitored_url(req: MonitoredURL):
"""Add a URL to the monitoring list."""
entry = {
"id": str(uuid.uuid4()),
"url": req.url,
"scan_type": req.scan_type,
"frequency": req.frequency,
"recipient": req.recipient,
"enabled": req.enabled,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_scan_at": None,
}
_monitored_urls.append(entry)
logger.info("Added monitored URL: %s (%s)", req.url, req.frequency)
return {"status": "added", **entry}
@router.get("/monitored-urls")
async def list_monitored_urls():
"""List all monitored URLs."""
return {"urls": _monitored_urls}
@router.delete("/monitored-urls/{url_id}")
async def remove_monitored_url(url_id: str):
"""Remove a URL from monitoring."""
global _monitored_urls
_monitored_urls = [u for u in _monitored_urls if u["id"] != url_id]
return {"status": "removed"}
@router.post("/run-scheduled")
async def run_scheduled_scans():
"""Trigger all enabled scheduled scans. Called by cron/ZeroClaw."""
import httpx
results = []
backend_url = "http://localhost:8002"
for entry in _monitored_urls:
if not entry["enabled"]:
continue
url = entry["url"]
scan_type = entry["scan_type"]
logger.info("Running scheduled %s for %s", scan_type, url)
try:
async with httpx.AsyncClient(timeout=300.0) as client:
if scan_type == "consent_test":
resp = await client.post(
"http://bp-compliance-consent-tester:8094/scan",
json={"url": url},
)
else:
resp = await client.post(
f"{backend_url}/api/compliance/agent/scan",
json={"url": url, "mode": "post_launch", "recipient": entry["recipient"]},
)
entry["last_scan_at"] = datetime.now(timezone.utc).isoformat()
results.append({
"url": url,
"scan_type": scan_type,
"status": "completed" if resp.status_code == 200 else "failed",
"status_code": resp.status_code,
})
except Exception as e:
logger.error("Scheduled scan failed for %s: %s", url, e)
results.append({"url": url, "scan_type": scan_type, "status": "error", "error": str(e)})
return {"scans_triggered": len(results), "results": results}
@@ -73,6 +73,7 @@ def build_scan_summary(
f"Findings: {n_findings} ({high} mit hoher Prioritaet)",
])
<<<<<<< HEAD
# DSI Documents section — grouped with their findings
if discovered_docs:
parts.extend(["", f"Rechtliche Dokumente ({len(discovered_docs)})"])
@@ -108,6 +109,27 @@ def build_scan_summary(
marker = "!!" if sev == "HIGH" else "!" if sev == "MEDIUM" else "i"
parts.append(f" [{marker}] {txt}")
elif findings:
=======
# DSI Documents section
if discovered_docs:
parts.extend([
"",
f"Rechtliche Dokumente gefunden: {len(discovered_docs)}",
])
for doc in discovered_docs:
pct = doc.completeness_pct if hasattr(doc, 'completeness_pct') else 0
fc = doc.findings_count if hasattr(doc, 'findings_count') else 0
wc = doc.word_count if hasattr(doc, 'word_count') else 0
status = "OK" if pct >= 80 else "LUECKENHAFT" if pct >= 50 else "MANGELHAFT"
dt = doc.doc_type if hasattr(doc, 'doc_type') else "unknown"
title = doc.title if hasattr(doc, 'title') else "?"
parts.append(
f" [{status}] {title} ({dt}, {wc} Woerter, "
f"{pct}% vollstaendig, {fc} Maengel)"
)
if findings:
>>>>>>> feat/zeroclaw-compliance-agent
parts.append("")
for f in findings[:20]:
sev = f.severity if hasattr(f, 'severity') else "?"
@@ -123,6 +145,7 @@ def build_scan_summary(
])
return "\n".join(parts)
<<<<<<< HEAD
async def fetch_dse_text(url: str, scanned_pages: list[str]) -> str:
@@ -161,3 +184,5 @@ async def fetch_dse_html(url: str, scanned_pages: list[str]) -> str:
return resp.text
except Exception:
return ""
=======
>>>>>>> feat/zeroclaw-compliance-agent
@@ -23,9 +23,13 @@ from compliance.services.mandatory_content_checker import (
check_mandatory_documents, check_dse_mandatory_content, MandatoryFinding,
)
from compliance.services.legal_basis_validator import validate_legal_bases
<<<<<<< HEAD
from compliance.api.agent_scan_helpers import (
add_corrections, build_scan_summary, fetch_dse_text, fetch_dse_html,
)
=======
from compliance.api.agent_scan_helpers import add_corrections, build_scan_summary
>>>>>>> feat/zeroclaw-compliance-agent
logger = logging.getLogger(__name__)
@@ -79,7 +83,10 @@ class ScanFinding(BaseModel):
severity: str
text: str
correction: str = ""
<<<<<<< HEAD
doc_title: str = ""
=======
>>>>>>> feat/zeroclaw-compliance-agent
text_reference: TextReferenceModel | None = None
@@ -219,17 +226,69 @@ async def _execute_scan(req: ScanRequest, scan_id: str = "") -> ScanResponse:
else:
scan = await scan_website(req.url)
<<<<<<< HEAD
logger.info("Scanned %d pages, found %d services", len(scan.pages_scanned), len(scan.detected_services))
_progress(f"Schritt 2/7: Rechtliche Dokumente suchen... ({len(scan.pages_scanned)} Seiten gescannt)")
=======
# Step 1: Scan website — try Playwright first (JS-rendered), fallback to httpx
playwright_htmls: dict[str, str] = {}
try:
async with httpx.AsyncClient(timeout=120.0) as pw_client:
pw_resp = await pw_client.post(
"http://bp-compliance-consent-tester:8094/website-scan",
json={"url": req.url, "max_pages": 15, "click_nav": True},
)
if pw_resp.status_code == 200:
pw_data = pw_resp.json()
playwright_htmls = pw_data.get("page_htmls", {})
logger.info("Playwright scan: %d pages, %d scripts",
pw_data.get("pages_count", 0), len(pw_data.get("external_scripts", [])))
except Exception as e:
logger.warning("Playwright scanner unavailable, falling back to httpx: %s", e)
# Use Playwright results if available, otherwise fall back to httpx scanner
if playwright_htmls:
# Build ScanResult from Playwright data
from compliance.services.website_scanner import ScanResult, DetectedService, _detect_services, _detect_ai_mentions
from compliance.services.service_registry import SERVICE_REGISTRY
scan = ScanResult()
scan.pages_scanned = list(playwright_htmls.keys())
for page_url, html in playwright_htmls.items():
_detect_services(html, page_url, scan)
_detect_ai_mentions(html, page_url, scan)
# Deduplicate
seen = set()
unique = []
for svc in scan.detected_services:
if svc.id not in seen:
seen.add(svc.id)
unique.append(svc)
scan.detected_services = unique
scan.chatbot_detected = any(s.category == "chatbot" for s in scan.detected_services)
if scan.chatbot_detected:
scan.chatbot_provider = next(s.name for s in scan.detected_services if s.category == "chatbot")
else:
scan = await scan_website(req.url)
logger.info("Scanned %d pages, found %d services", len(scan.pages_scanned), len(scan.detected_services))
>>>>>>> feat/zeroclaw-compliance-agent
# Step 1b: DSI Discovery — find all legal documents on the website
discovered_docs: list[DiscoveredDocument] = []
dsi_findings: list[ScanFinding] = []
try:
<<<<<<< HEAD
async with httpx.AsyncClient(timeout=300.0) as dsi_client:
dsi_resp = await dsi_client.post(
"http://bp-compliance-consent-tester:8094/dsi-discovery",
json={"url": req.url, "max_documents": 30},
=======
async with httpx.AsyncClient(timeout=180.0) as dsi_client:
dsi_resp = await dsi_client.post(
"http://bp-compliance-consent-tester:8094/dsi-discovery",
json={"url": req.url, "max_documents": 20},
>>>>>>> feat/zeroclaw-compliance-agent
)
if dsi_resp.status_code == 200:
dsi_data = dsi_resp.json()
@@ -241,12 +300,17 @@ async def _execute_scan(req: ScanRequest, scan_id: str = "") -> ScanResponse:
)
for doc in dsi_data.get("documents", []):
doc_type = classify_document_type(doc["title"], doc["url"])
<<<<<<< HEAD
doc_text = doc.get("full_text", "") or doc.get("text_preview", "")
logger.info("DSI check: '%s' type=%s text_len=%d full_text_len=%d preview_len=%d",
doc["title"][:50], doc_type, len(doc_text),
len(doc.get("full_text", "")), len(doc.get("text_preview", "")))
doc_findings = check_document_completeness(
doc_text, doc_type, doc["title"], doc["url"],
=======
doc_findings = check_document_completeness(
doc.get("text_preview", ""), doc_type, doc["title"], doc["url"],
>>>>>>> feat/zeroclaw-compliance-agent
)
# Count completeness
score_finding = next((f for f in doc_findings if "SCORE" in f.get("code", "")), None)
@@ -268,6 +332,7 @@ async def _execute_scan(req: ScanRequest, scan_id: str = "") -> ScanResponse:
if "SCORE" not in df.get("code", ""):
dsi_findings.append(ScanFinding(
code=df["code"], severity=df["severity"], text=df["text"],
<<<<<<< HEAD
doc_title=doc["title"],
))
except Exception as e:
@@ -296,6 +361,24 @@ async def _execute_scan(req: ScanRequest, scan_id: str = "") -> ScanResponse:
pass
if not dse_text:
dse_text = await fetch_dse_text(req.url, scan.pages_scanned)
=======
))
except Exception as e:
logger.warning("DSI discovery failed: %s", e)
# Step 2: Fetch privacy policy text (from Playwright HTMLs or httpx)
dse_text = ""
for page_url, html in playwright_htmls.items():
if re.search(r"datenschutz|privacy|dsgvo", page_url, re.IGNORECASE):
import re as _re
clean = _re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=_re.DOTALL | _re.IGNORECASE)
clean = _re.sub(r"<[^>]+>", " ", clean)
clean = _re.sub(r"\s+", " ", clean).strip()
dse_text = clean[:4000]
break
if not dse_text:
dse_text = await _fetch_dse_text(req.url, scan.pages_scanned)
>>>>>>> feat/zeroclaw-compliance-agent
# Step 3: Extract services mentioned in DSE via LLM + text fallback
dse_services = await extract_dse_services(dse_text) if dse_text else []
@@ -320,11 +403,18 @@ async def _execute_scan(req: ScanRequest, scan_id: str = "") -> ScanResponse:
dse_html = html
break
if not dse_html:
<<<<<<< HEAD
dse_html = await fetch_dse_html(req.url, scan.pages_scanned)
dse_sections = parse_dse(dse_html, req.url) if dse_html else []
logger.info("Parsed %d DSE sections", len(dse_sections))
_progress("Schritt 4/7: SOLL/IST Vergleich...")
=======
dse_html = await _fetch_dse_html(req.url, scan.pages_scanned)
dse_sections = parse_dse(dse_html, req.url) if dse_html else []
logger.info("Parsed %d DSE sections", len(dse_sections))
>>>>>>> feat/zeroclaw-compliance-agent
# Step 5: SOLL/IST comparison
detected_dicts = [_service_to_dict(s) for s in scan.detected_services]
comparison = compare_services(detected_dicts, dse_services)
@@ -363,7 +453,10 @@ async def _execute_scan(req: ScanRequest, scan_id: str = "") -> ScanResponse:
# Step 8c: Add DSI document findings
findings.extend(dsi_findings)
<<<<<<< HEAD
_progress(f"Schritt 5/7: Korrekturen generieren... ({len(findings)} Findings)")
=======
>>>>>>> feat/zeroclaw-compliance-agent
# Step 9: Generate corrections for pre-launch mode
if not is_live and findings:
await add_corrections(findings, dse_text)
@@ -400,6 +493,24 @@ async def _execute_scan(req: ScanRequest, scan_id: str = "") -> ScanResponse:
async def _fetch_dse_html(url: str, scanned_pages: list[str]) -> str:
"""Fetch the raw HTML of the privacy policy page (for structured parsing)."""
import re
dse_url = None
for page in scanned_pages:
if re.search(r"datenschutz|privacy|dsgvo", page, re.IGNORECASE):
dse_url = page
break
if not dse_url:
dse_url = url
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(dse_url, headers={"User-Agent": "BreakPilot-Compliance-Agent/1.0"})
return resp.text
except Exception:
return ""
def _service_to_dict(svc: DetectedService) -> dict:
return {
"id": svc.id, "name": svc.name, "category": svc.category,
@@ -0,0 +1,120 @@
"""
FastAPI routes for Banner A/B Testing.
Endpoints:
GET /banner/ab/{site_config_id}/variants list variants
POST /banner/ab/{site_config_id}/variants create variant
PUT /banner/ab/variants/{variant_id} update variant
DELETE /banner/ab/variants/{variant_id} delete variant
GET /banner/ab/{site_config_id}/stats per-variant stats
GET /banner/ab/assign assign variant for device
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .tenant_utils import get_tenant_id as _get_tenant_id
from compliance.services.banner_ab_service import BannerABService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/banner/ab", tags=["banner-ab-testing"])
class VariantCreate(BaseModel):
variant_name: str
variant_key: str = "A"
traffic_percent: int = 50
is_control: bool = False
banner_title: Optional[str] = None
banner_description: Optional[str] = None
position: Optional[str] = None
style: Optional[str] = None
primary_color: Optional[str] = None
show_decline_all: Optional[bool] = None
theme_overrides: Optional[dict] = None
class VariantUpdate(BaseModel):
variant_name: Optional[str] = None
traffic_percent: Optional[int] = None
is_control: Optional[bool] = None
banner_title: Optional[str] = None
banner_description: Optional[str] = None
position: Optional[str] = None
style: Optional[str] = None
primary_color: Optional[str] = None
show_decline_all: Optional[bool] = None
is_active: Optional[bool] = None
@router.get("/{site_config_id}/variants")
def list_variants(
site_config_id: str,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerABService(db)
return service.list_variants(tenant_id, site_config_id)
@router.post("/{site_config_id}/variants")
def create_variant(
site_config_id: str,
body: VariantCreate,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerABService(db)
return service.create_variant(tenant_id, site_config_id, body.model_dump())
@router.put("/variants/{variant_id}")
def update_variant(
variant_id: str,
body: VariantUpdate,
db: Session = Depends(get_db),
):
service = BannerABService(db)
result = service.update_variant(variant_id, body.model_dump(exclude_none=True))
if not result:
raise HTTPException(404, "Variant not found")
return result
@router.delete("/variants/{variant_id}")
def delete_variant(
variant_id: str,
db: Session = Depends(get_db),
):
service = BannerABService(db)
if not service.delete_variant(variant_id):
raise HTTPException(404, "Variant not found")
return {"deleted": True}
@router.get("/{site_config_id}/stats")
def variant_stats(
site_config_id: str,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerABService(db)
return service.get_variant_stats(tenant_id, site_config_id)
@router.get("/assign")
def assign_variant(
site_config_id: str = Query(...),
device_fingerprint: str = Query(...),
db: Session = Depends(get_db),
):
service = BannerABService(db)
variant = service.assign_variant(site_config_id, device_fingerprint)
if not variant:
return {"variant": None, "message": "No active A/B test"}
return {"variant": variant}
@@ -0,0 +1,67 @@
"""
FastAPI routes for Banner Consent Analytics.
Endpoints:
GET /banner/analytics/{site_id}/overview high-level stats
GET /banner/analytics/{site_id}/time-series opt-in rate over time
GET /banner/analytics/{site_id}/categories acceptance per category
GET /banner/analytics/{site_id}/devices mobile/desktop/tablet breakdown
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .tenant_utils import get_tenant_id as _get_tenant_id
from compliance.services.banner_analytics_service import BannerAnalyticsService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/banner/analytics", tags=["banner-analytics"])
@router.get("/{site_id}/overview")
def analytics_overview(
site_id: str,
days: int = Query(30, le=365),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerAnalyticsService(db)
return service.get_overview_stats(tenant_id, site_id, days)
@router.get("/{site_id}/time-series")
def analytics_time_series(
site_id: str,
period: str = Query("daily"),
days: int = Query(30, le=365),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerAnalyticsService(db)
return service.get_time_series(tenant_id, site_id, period, days)
@router.get("/{site_id}/categories")
def analytics_categories(
site_id: str,
days: int = Query(30, le=365),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerAnalyticsService(db)
return service.get_category_breakdown(tenant_id, site_id, days)
@router.get("/{site_id}/devices")
def analytics_devices(
site_id: str,
days: int = Query(30, le=365),
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
service = BannerAnalyticsService(db)
return service.get_device_breakdown(tenant_id, site_id, days)
@@ -74,6 +74,7 @@ async def record_consent(
device_fingerprint=body.device_fingerprint,
categories=body.categories,
vendors=body.vendors,
vendor_consents=body.vendor_consents,
ip_address=body.ip_address,
user_agent=body.user_agent,
consent_string=body.consent_string,

Some files were not shown because too many files have changed in this diff Show More