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