Compare commits
8 Commits
f528b8e7a9
...
cfc130a544
| Author | SHA1 | Date | |
|---|---|---|---|
| cfc130a544 | |||
| 0ccc6c4047 | |||
| 5ff65b3402 | |||
| 290254056e | |||
| 7dccdf4695 | |||
| 8e0645481a | |||
| 918a9d8092 | |||
| 0c0dd4e3a6 |
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Agent Analyze API Proxy
|
||||
* POST /api/sdk/v1/agent/analyze → backend-compliance /api/compliance/agent/analyze
|
||||
*/
|
||||
|
||||
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/analyze`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': '00000000-0000-0000-0000-000000000001',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(120000), // 2 min — LLM can be slow
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: `Backend: ${response.status}`, detail: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Agent analyze proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { AnalysisResult } from '../_hooks/useAgentAnalysis'
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
privacy_policy: 'DSE',
|
||||
cookie_banner: 'Cookie',
|
||||
terms_of_service: 'AGB',
|
||||
imprint: 'Impressum',
|
||||
dpa: 'AVV',
|
||||
other: 'Sonstig',
|
||||
}
|
||||
|
||||
const RISK_DOT: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
history: AnalysisResult[]
|
||||
onSelect: (result: AnalysisResult) => void
|
||||
}
|
||||
|
||||
export function AnalysisHistory({ history, onSelect }: Props) {
|
||||
if (history.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Letzte Analysen</h3>
|
||||
<div className="space-y-2">
|
||||
{history.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelect(item)}
|
||||
className="w-full text-left p-3 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${RISK_DOT[item.risk_level] || 'bg-gray-400'}`} />
|
||||
<span className="text-xs font-medium text-gray-500 w-16">
|
||||
{DOC_TYPE_LABELS[item.classification] || item.classification}
|
||||
</span>
|
||||
<span className="text-sm text-gray-700 truncate flex-1">
|
||||
{new URL(item.url).hostname}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(item.analyzed_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { AnalysisResult as AnalysisResultType } from '../_hooks/useAgentAnalysis'
|
||||
|
||||
const RISK_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||
low: { bg: 'bg-green-100', text: 'text-green-800', label: 'Niedrig' },
|
||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Mittel' },
|
||||
high: { bg: 'bg-orange-100', text: 'text-orange-800', label: 'Hoch' },
|
||||
critical: { bg: 'bg-red-100', text: 'text-red-800', label: 'Kritisch' },
|
||||
unknown: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Unbekannt' },
|
||||
}
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
privacy_policy: 'Datenschutzerklaerung',
|
||||
cookie_banner: 'Cookie-Banner',
|
||||
terms_of_service: 'AGB',
|
||||
imprint: 'Impressum',
|
||||
dpa: 'Auftragsverarbeitung (AVV)',
|
||||
other: 'Sonstiges',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
result: AnalysisResultType
|
||||
}
|
||||
|
||||
export function AnalysisResult({ result }: Props) {
|
||||
const risk = RISK_COLORS[result.risk_level] || RISK_COLORS.unknown
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{DOC_TYPE_LABELS[result.classification] || result.classification}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate max-w-md">{result.url}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${risk.bg} ${risk.text}`}>
|
||||
{risk.label} ({result.risk_score}/100)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Role Assignment */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-600" 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>
|
||||
<span className="text-sm font-medium text-purple-900">
|
||||
Zugewiesen an: <strong>{result.responsible_role}</strong>
|
||||
</span>
|
||||
<span className="text-xs text-purple-600 ml-auto">
|
||||
Eskalationsstufe {result.escalation_level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{result.summary && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Zusammenfassung</h4>
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{result.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Findings */}
|
||||
{result.findings.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Findings ({result.findings.length})</h4>
|
||||
<ul className="space-y-1">
|
||||
{result.findings.map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<span className="text-orange-500 mt-0.5">!</span>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Controls */}
|
||||
{result.required_controls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Erforderliche Massnahmen</h4>
|
||||
<ul className="space-y-1">
|
||||
{result.required_controls.map((c, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<span className="text-blue-500 mt-0.5">✓</span>
|
||||
{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Status */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 pt-2 border-t">
|
||||
<span className={result.email_status === 'sent' ? 'text-green-600' : 'text-yellow-600'}>
|
||||
{result.email_status === 'sent' ? '✉ Email gesendet' : '✉ Email ausstehend'}
|
||||
</span>
|
||||
<span className="ml-auto text-xs">
|
||||
{new Date(result.analyzed_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface AnalysisResult {
|
||||
url: string
|
||||
classification: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
escalation_level: string
|
||||
responsible_role: string
|
||||
findings: string[]
|
||||
required_controls: string[]
|
||||
summary: string
|
||||
email_status: string
|
||||
analyzed_at: string
|
||||
}
|
||||
|
||||
const ESCALATION_ROLES: Record<string, string> = {
|
||||
E0: 'Kein Handlungsbedarf',
|
||||
E1: 'Teamleitung Datenschutz',
|
||||
E2: 'Datenschutzbeauftragter (DSB)',
|
||||
E3: 'DSB + Rechtsabteilung',
|
||||
}
|
||||
|
||||
const SDK_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-ID': '00000000-0000-0000-0000-000000000001',
|
||||
}
|
||||
|
||||
export function useAgentAnalysis() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<AnalysisResult | null>(null)
|
||||
const [history, setHistory] = useState<AnalysisResult[]>([])
|
||||
|
||||
async function analyze(url: string) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
// Step 1: Fetch and classify
|
||||
const fetchRes = await fetch('/api/sdk/v1/agent/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
throw new Error(`Analyse fehlgeschlagen: ${fetchRes.status}`)
|
||||
}
|
||||
|
||||
const data = await fetchRes.json()
|
||||
const analysisResult: AnalysisResult = {
|
||||
url,
|
||||
classification: data.classification || 'unknown',
|
||||
risk_level: data.risk_level || 'unknown',
|
||||
risk_score: data.risk_score || 0,
|
||||
escalation_level: data.escalation_level || 'E0',
|
||||
responsible_role: ESCALATION_ROLES[data.escalation_level] || ESCALATION_ROLES.E0,
|
||||
findings: data.findings || [],
|
||||
required_controls: data.required_controls || [],
|
||||
summary: data.summary || '',
|
||||
email_status: data.email_status || 'pending',
|
||||
analyzed_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
setResult(analysisResult)
|
||||
setHistory(prev => [analysisResult, ...prev].slice(0, 20))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { analyze, loading, error, result, history }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useAgentAnalysis } from './_hooks/useAgentAnalysis'
|
||||
import { AnalysisResult } from './_components/AnalysisResult'
|
||||
import { AnalysisHistory } from './_components/AnalysisHistory'
|
||||
|
||||
export default function AgentPage() {
|
||||
const [url, setUrl] = useState('')
|
||||
const { analyze, loading, error, result, history } = useAgentAnalysis()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!url.trim()) return
|
||||
analyze(url.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Analysiere Webseiten auf DSGVO-Konformitaet. Der Agent holt das Dokument,
|
||||
klassifiziert es, bewertet das Risiko und weist die Aufgabe der zustaendigen Rolle zu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* URL Input */}
|
||||
<form onSubmit={handleSubmit} className="flex gap-3">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/datenschutz"
|
||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !url.trim()}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Analysiere...
|
||||
</>
|
||||
) : (
|
||||
'Analysieren'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<AnalysisResult result={result} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<AnalysisHistory
|
||||
history={history}
|
||||
onSelect={r => {
|
||||
setUrl(r.url)
|
||||
analyze(r.url)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface DeadlineConfig {
|
||||
gracePeriodDays: number
|
||||
reminderDays: number[]
|
||||
suspendOnExpiry: boolean
|
||||
}
|
||||
|
||||
export function DeadlineTab() {
|
||||
// Phase 4: Deadline management — backend service pending (Core integration)
|
||||
const config: DeadlineConfig = {
|
||||
gracePeriodDays: 30,
|
||||
reminderDays: [28, 21, 14, 7],
|
||||
suspendOnExpiry: true,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Fristen & Erinnerungen</h2>
|
||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs">In Vorbereitung</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
Das Fristen-System wird automatisch Erinnerungen an Nutzer senden, die neue Pflichtdokumente
|
||||
noch nicht akzeptiert haben. Nach Ablauf der Frist wird der Account gesperrt bis die Zustimmung erfolgt.
|
||||
Die E-Mail-Zustellung wird ueber den Core-Service in Production bereitgestellt.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700">Nachfrist</h3>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{config.gracePeriodDays} Tage</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Nach Veroeffentlichung eines Pflichtdokuments</p>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700">Erinnerungen</h3>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{config.reminderDays.length}x</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Tag {config.reminderDays.join(', ')} nach Veroeffentlichung
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700">Auto-Sperrung</h3>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{config.suspendOnExpiry ? 'Aktiv' : 'Inaktiv'}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Account wird nach Fristablauf gesperrt</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-3">Erinnerungs-Timeline</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 30 }, (_, i) => {
|
||||
const day = 30 - i
|
||||
const isReminder = config.reminderDays.includes(day)
|
||||
const isDeadline = day === 0
|
||||
return (
|
||||
<div key={i} className="flex-1 relative group">
|
||||
<div className={`h-2 rounded-sm ${
|
||||
isDeadline ? 'bg-red-500' : isReminder ? 'bg-yellow-400' : 'bg-slate-100'
|
||||
}`} />
|
||||
{isReminder && (
|
||||
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] text-yellow-600 whitespace-nowrap">
|
||||
Tag {day}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex-none w-3 h-2 bg-red-500 rounded-sm" title="Sperrung" />
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-[10px] text-slate-400">
|
||||
<span>Veroeffentlichung</span>
|
||||
<span>Sperrung</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link href="/sdk/email-templates" className="text-sm text-purple-600 hover:underline">
|
||||
E-Mail-Templates konfigurieren →
|
||||
</Link>
|
||||
<Link href="/sdk/dsr" className="text-sm text-purple-600 hover:underline">
|
||||
Betroffenenrechte verwalten →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
const INTEGRATIONS = [
|
||||
{
|
||||
id: 'matrix',
|
||||
name: 'Matrix Kommunikation',
|
||||
description: 'Sichere, verschluesselte Kommunikation mit Betroffenen ueber Matrix-Protokoll. Wird in Production ueber den Core Communication Service bereitgestellt.',
|
||||
status: 'planned',
|
||||
icon: '💬',
|
||||
},
|
||||
{
|
||||
id: 'jitsi',
|
||||
name: 'Jitsi Video-Meetings',
|
||||
description: 'DSGVO-konforme Video-Konsultationen mit Betroffenen fuer komplexe Datenschutzanfragen. Wird ueber den Core Jitsi Service bereitgestellt.',
|
||||
status: 'planned',
|
||||
icon: '📹',
|
||||
},
|
||||
{
|
||||
id: 'oauth',
|
||||
name: 'OAuth 2.0 Client-Verwaltung',
|
||||
description: 'Verwaltung von OAuth-Clients fuer API-Zugriff auf Consent-Endpunkte. Authorization Code Flow mit PKCE-Support.',
|
||||
status: 'planned',
|
||||
icon: '🔑',
|
||||
},
|
||||
{
|
||||
id: '2fa',
|
||||
name: 'Zwei-Faktor-Authentifizierung',
|
||||
description: 'TOTP-basierte Zwei-Faktor-Authentifizierung fuer Admin-Zugang. Recovery-Codes fuer Notfallzugriff.',
|
||||
status: 'planned',
|
||||
icon: '🛡️',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
name: 'Benachrichtigungssystem',
|
||||
description: 'In-App und E-Mail Benachrichtigungen fuer Consent-Aenderungen, DSR-Fristen und Dokument-Updates. Praeferenz-Verwaltung pro Nutzer.',
|
||||
status: 'planned',
|
||||
icon: '🔔',
|
||||
},
|
||||
]
|
||||
|
||||
export function IntegrationStubs() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Integrationen</h2>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">Production-Anbindung</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
Diese Dienste werden in Production ueber die Core-Services bereitgestellt und sind
|
||||
im SDK vorbereitet.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{INTEGRATIONS.map(integration => (
|
||||
<div key={integration.id} className="border border-slate-200 rounded-lg p-4 bg-slate-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">{integration.icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-slate-800">{integration.name}</h3>
|
||||
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px]">Geplant</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{integration.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,18 +2,28 @@
|
||||
|
||||
import type { Document, Version } from '../_types'
|
||||
|
||||
const STATUS_STYLES: Record<string, { label: string; color: string }> = {
|
||||
draft: { label: 'Entwurf', color: 'bg-gray-100 text-gray-700' },
|
||||
review: { label: 'Pruefung', color: 'bg-yellow-100 text-yellow-700' },
|
||||
approved: { label: 'Genehmigt', color: 'bg-blue-100 text-blue-700' },
|
||||
published: { label: 'Publiziert', color: 'bg-green-100 text-green-700' },
|
||||
archived: { label: 'Archiviert', color: 'bg-gray-100 text-gray-400' },
|
||||
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
export function VersionsTab({
|
||||
loading,
|
||||
documents,
|
||||
versions,
|
||||
selectedDocument,
|
||||
setSelectedDocument,
|
||||
loading, documents, versions, selectedDocument, setSelectedDocument,
|
||||
onSubmitReview, onApprove, onReject, onPublish,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
versions: Version[]
|
||||
selectedDocument: string
|
||||
setSelectedDocument: (id: string) => void
|
||||
onSubmitReview?: (versionId: string) => void
|
||||
onApprove?: (versionId: string) => void
|
||||
onReject?: (versionId: string, comment: string) => void
|
||||
onPublish?: (versionId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -27,73 +37,69 @@ export function VersionsTab({
|
||||
>
|
||||
<option value="">Dokument auswaehlen...</option>
|
||||
{documents.map((doc) => (
|
||||
<option key={doc.id} value={doc.id}>
|
||||
{doc.name}
|
||||
</option>
|
||||
<option key={doc.id} value={doc.id}>{doc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedDocument && (
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neue Version
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedDocument ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Bitte waehlen Sie ein Dokument aus
|
||||
</div>
|
||||
<div className="text-center py-12 text-slate-500">Bitte waehlen Sie ein Dokument aus</div>
|
||||
) : loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Versionen vorhanden
|
||||
</div>
|
||||
<div className="text-center py-12 text-slate-500">Keine Versionen vorhanden</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
version.status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: version.status === 'draft'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{version.status}
|
||||
</span>
|
||||
{versions.map((version) => {
|
||||
const style = STATUS_STYLES[version.status] || STATUS_STYLES.draft
|
||||
return (
|
||||
<div key={version.id} className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${style.color}`}>{style.label}</span>
|
||||
</div>
|
||||
<h3 className="text-slate-700">{version.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||
{version.published_at && ` | Publiziert: ${new Date(version.published_at).toLocaleDateString('de-DE')}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
{version.status === 'draft' && onSubmitReview && (
|
||||
<button onClick={() => onSubmitReview(version.id)}
|
||||
className="px-3 py-1.5 text-sm text-white bg-yellow-500 hover:bg-yellow-600 rounded-lg">
|
||||
Zur Pruefung
|
||||
</button>
|
||||
)}
|
||||
{version.status === 'review' && onApprove && (
|
||||
<button onClick={() => onApprove(version.id)}
|
||||
className="px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg">
|
||||
Genehmigen
|
||||
</button>
|
||||
)}
|
||||
{version.status === 'review' && onReject && (
|
||||
<button onClick={() => { const c = prompt('Ablehnungsgrund:'); if (c) onReject(version.id, c) }}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-500 hover:bg-red-600 rounded-lg">
|
||||
Ablehnen
|
||||
</button>
|
||||
)}
|
||||
{version.status === 'approved' && onPublish && (
|
||||
<button onClick={() => onPublish(version.id)}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
|
||||
Publizieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-slate-700">{version.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
|
||||
Bearbeiten
|
||||
</button>
|
||||
{version.status === 'draft' && (
|
||||
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
|
||||
Veroeffentlichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -277,6 +277,45 @@ export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
localStorage.setItem('sdk-email-templates', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
// Document version workflow actions (via admin consent proxy → legal-documents backend)
|
||||
async function submitVersionForReview(versionId: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/submit-review`, {
|
||||
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||
})
|
||||
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||
} catch (err) { console.error('Submit failed:', err) }
|
||||
}
|
||||
|
||||
async function approveVersion(versionId: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/approve`, {
|
||||
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||
})
|
||||
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||
} catch (err) { console.error('Approve failed:', err) }
|
||||
}
|
||||
|
||||
async function rejectVersion(versionId: string, comment: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}) },
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||
} catch (err) { console.error('Reject failed:', err) }
|
||||
}
|
||||
|
||||
async function publishVersion(versionId: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/publish`, {
|
||||
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||
})
|
||||
if (res.ok) { if (selectedDocument) await loadVersions(selectedDocument); await loadDocuments() }
|
||||
} catch (err) { console.error('Publish failed:', err) }
|
||||
}
|
||||
|
||||
return {
|
||||
documents, versions, loading, error, setError,
|
||||
consentStats, dsrCounts, dsrOverview,
|
||||
@@ -286,6 +325,7 @@ export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
submitVersionForReview, approveVersion, rejectVersion, publishVersion,
|
||||
authToken, setAuthToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const API_BASE = '/api/admin/consent'
|
||||
|
||||
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
|
||||
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats' | 'deadlines' | 'integrations'
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
|
||||
@@ -25,6 +25,8 @@ import { GdprTab } from './_components/GdprTab'
|
||||
import { StatsTab } from './_components/StatsTab'
|
||||
import { ConsentTemplateCreateModal } from './_components/ConsentTemplateCreateModal'
|
||||
import { EmailTemplateEditModal, EmailTemplatePreviewModal } from './_components/EmailTemplateModals'
|
||||
import { DeadlineTab } from './_components/DeadlineTab'
|
||||
import { IntegrationStubs } from './_components/IntegrationStubs'
|
||||
|
||||
export default function ConsentManagementPage() {
|
||||
const { state } = useSDK()
|
||||
@@ -45,6 +47,7 @@ export default function ConsentManagementPage() {
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
submitVersionForReview, approveVersion, rejectVersion, publishVersion,
|
||||
authToken, setAuthToken,
|
||||
} = useConsentData(activeTab, selectedDocument)
|
||||
|
||||
@@ -54,6 +57,8 @@ export default function ConsentManagementPage() {
|
||||
{ id: 'emails', label: 'E-Mail Vorlagen' },
|
||||
{ id: 'gdpr', label: 'DSGVO Prozesse' },
|
||||
{ id: 'stats', label: 'Statistiken' },
|
||||
{ id: 'deadlines', label: 'Fristen' },
|
||||
{ id: 'integrations', label: 'Integrationen' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -128,6 +133,10 @@ export default function ConsentManagementPage() {
|
||||
versions={versions}
|
||||
selectedDocument={selectedDocument}
|
||||
setSelectedDocument={setSelectedDocument}
|
||||
onSubmitReview={submitVersionForReview}
|
||||
onApprove={approveVersion}
|
||||
onReject={rejectVersion}
|
||||
onPublish={publishVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -157,6 +166,10 @@ export default function ConsentManagementPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && <StatsTab consentStats={consentStats} />}
|
||||
|
||||
{activeTab === 'deadlines' && <DeadlineTab />}
|
||||
|
||||
{activeTab === 'integrations' && <IntegrationStubs />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,20 +13,21 @@ export function LoadingSpinner() {
|
||||
)
|
||||
}
|
||||
|
||||
export { PublicFormConfig as SettingsTabContent } from './PublicFormConfig'
|
||||
|
||||
export function SettingsTab() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<SettingsTabContent />
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-2">Workflow-Konfiguration</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
SLA-Fristen, automatische Zuweisungen und Eskalationsregeln
|
||||
werden in Production ueber den Core-Service konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
DSR-Portal-Einstellungen, E-Mail-Vorlagen und Workflow-Konfiguration
|
||||
werden in einer spaeteren Version verfuegbar sein.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface PublicFormSettings {
|
||||
enabled: boolean
|
||||
formUrl: string
|
||||
allowedTypes: string[]
|
||||
requireIdentity: boolean
|
||||
customCss: string
|
||||
}
|
||||
|
||||
const DSR_TYPES = [
|
||||
{ value: 'access', label: 'Auskunft (Art. 15)' },
|
||||
{ value: 'rectification', label: 'Berichtigung (Art. 16)' },
|
||||
{ value: 'erasure', label: 'Loeschung (Art. 17)' },
|
||||
{ value: 'restriction', label: 'Einschraenkung (Art. 18)' },
|
||||
{ value: 'portability', label: 'Datenportabilitaet (Art. 20)' },
|
||||
{ value: 'objection', label: 'Widerspruch (Art. 21)' },
|
||||
]
|
||||
|
||||
export function PublicFormConfig() {
|
||||
const [settings, setSettings] = useState<PublicFormSettings>({
|
||||
enabled: false,
|
||||
formUrl: '',
|
||||
allowedTypes: ['access', 'erasure', 'portability'],
|
||||
requireIdentity: true,
|
||||
customCss: '',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">Oeffentliches DSR-Formular</h3>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={settings.enabled}
|
||||
onChange={e => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
className="rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm text-slate-600">Aktiviert</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!settings.enabled ? (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm text-gray-600">
|
||||
Das oeffentliche DSR-Formular ermoeglicht Betroffenen, Datenschutzanfragen direkt
|
||||
ueber Ihre Website einzureichen — ohne Anmeldung. Aktivieren Sie es, um den
|
||||
Embed-Code zu generieren.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Erlaubte Anfragetypen</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{DSR_TYPES.map(type => (
|
||||
<label key={type.value} className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox"
|
||||
checked={settings.allowedTypes.includes(type.value)}
|
||||
onChange={e => {
|
||||
const types = e.target.checked
|
||||
? [...settings.allowedTypes, type.value]
|
||||
: settings.allowedTypes.filter(t => t !== type.value)
|
||||
setSettings({ ...settings, allowedTypes: types })
|
||||
}}
|
||||
className="rounded border-gray-300 text-purple-600" />
|
||||
{type.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={settings.requireIdentity}
|
||||
onChange={e => setSettings({ ...settings, requireIdentity: e.target.checked })}
|
||||
className="rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm text-slate-600">Identitaetsnachweis erforderlich</span>
|
||||
</label>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">Embed-Code</h4>
|
||||
<pre className="text-xs font-mono bg-white border border-slate-200 rounded p-3 overflow-x-auto">
|
||||
{`<iframe
|
||||
src="https://ihre-domain.breakpilot.ai/dsr/public-form"
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
title="Datenschutzanfrage"
|
||||
></iframe>`}
|
||||
</pre>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Embed-Code wird nach Anbindung an Production generiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,11 +15,16 @@ interface EditorTabProps {
|
||||
onPublish: () => void
|
||||
onPreview: () => void
|
||||
onBack: () => void
|
||||
onSubmitForReview?: () => void
|
||||
onApprove?: (comment?: string) => void
|
||||
onReject?: (comment: string) => void
|
||||
onSendTest?: (email: string) => void
|
||||
}
|
||||
|
||||
export function EditorTab({
|
||||
template, version, subject, html, previewHtml, saving,
|
||||
onSubjectChange, onHtmlChange, onSave, onPublish, onPreview, onBack,
|
||||
onSubmitForReview, onApprove, onReject, onSendTest,
|
||||
}: EditorTabProps) {
|
||||
if (!template) {
|
||||
return (
|
||||
@@ -46,30 +51,56 @@ export function EditorTab({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Version speichern'}
|
||||
</button>
|
||||
{version && version.status !== 'published' && (
|
||||
<button
|
||||
onClick={onPublish}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Save — always available for draft/review */}
|
||||
{(!version || version.status === 'draft' || version.status === 'review') && (
|
||||
<button onClick={onSave} disabled={saving}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
|
||||
{saving ? 'Speichern...' : 'Version speichern'}
|
||||
</button>
|
||||
)}
|
||||
{/* Submit for Review — only for draft */}
|
||||
{version && version.status === 'draft' && onSubmitForReview && (
|
||||
<button onClick={onSubmitForReview} disabled={saving}
|
||||
className="px-3 py-1.5 bg-yellow-500 text-white rounded-lg text-sm hover:bg-yellow-600 disabled:opacity-50">
|
||||
Zur Pruefung einreichen
|
||||
</button>
|
||||
)}
|
||||
{/* Approve — only for review status (DSB) */}
|
||||
{version && version.status === 'review' && onApprove && (
|
||||
<button onClick={() => onApprove()} disabled={saving}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50">
|
||||
Genehmigen
|
||||
</button>
|
||||
)}
|
||||
{/* Reject — only for review status (DSB) */}
|
||||
{version && version.status === 'review' && onReject && (
|
||||
<button onClick={() => { const c = prompt('Ablehnungsgrund:'); if (c) onReject(c) }} disabled={saving}
|
||||
className="px-3 py-1.5 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600 disabled:opacity-50">
|
||||
Ablehnen
|
||||
</button>
|
||||
)}
|
||||
{/* Publish — only for approved */}
|
||||
{version && version.status === 'approved' && (
|
||||
<button onClick={onPublish} disabled={saving}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50">
|
||||
Publizieren
|
||||
</button>
|
||||
)}
|
||||
{/* Preview + Test — always when version exists */}
|
||||
{version && (
|
||||
<button
|
||||
onClick={onPreview}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
<>
|
||||
<button onClick={onPreview}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50">
|
||||
Vorschau
|
||||
</button>
|
||||
{onSendTest && (
|
||||
<button onClick={() => { const e = prompt('Test-E-Mail an:'); if (e) onSendTest(e) }}
|
||||
className="px-3 py-1.5 border border-blue-300 text-blue-700 rounded-lg text-sm hover:bg-blue-50">
|
||||
Test senden
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SendLog,
|
||||
Settings,
|
||||
TabId,
|
||||
TemplateApproval,
|
||||
TemplateType,
|
||||
TemplateVersion,
|
||||
getHeaders,
|
||||
@@ -194,6 +195,72 @@ export function useEmailTemplates(activeTab: TabId) {
|
||||
}
|
||||
}, [settingsForm])
|
||||
|
||||
// Workflow actions
|
||||
const submitForReview = useCallback(async () => {
|
||||
if (!editorVersion) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/submit`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated = await res.json()
|
||||
setEditorVersion(updated)
|
||||
await loadTemplates()
|
||||
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||
}, [editorVersion, loadTemplates])
|
||||
|
||||
const approveVersion = useCallback(async (comment?: string) => {
|
||||
if (!editorVersion) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/approve`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
body: JSON.stringify({ comment: comment || '' }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated = await res.json()
|
||||
setEditorVersion(updated)
|
||||
await loadTemplates()
|
||||
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||
}, [editorVersion, loadTemplates])
|
||||
|
||||
const rejectVersion = useCallback(async (comment: string) => {
|
||||
if (!editorVersion) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/reject`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated = await res.json()
|
||||
setEditorVersion(updated)
|
||||
await loadTemplates()
|
||||
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||
}, [editorVersion, loadTemplates])
|
||||
|
||||
const sendTestEmail = useCallback(async (recipientEmail: string) => {
|
||||
if (!editorVersion) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/send-test`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
body: JSON.stringify({ recipient: recipientEmail }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return await res.json()
|
||||
} catch (e: any) { setError(e.message) }
|
||||
}, [editorVersion])
|
||||
|
||||
const loadApprovalHistory = useCallback(async (versionId: string): Promise<TemplateApproval[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/approvals`, { headers: getHeaders() })
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return Array.isArray(data) ? data : data.approvals || []
|
||||
} catch { return [] }
|
||||
}, [])
|
||||
|
||||
const initializeDefaults = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/initialize`, {
|
||||
@@ -222,6 +289,8 @@ export function useEmailTemplates(activeTab: TabId) {
|
||||
setSettingsForm,
|
||||
// Actions
|
||||
openEditor, saveVersion, publishVersion, loadPreview,
|
||||
submitForReview, approveVersion, rejectVersion,
|
||||
sendTestEmail, loadApprovalHistory,
|
||||
saveSettings2, initializeDefaults,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ export interface SendLog {
|
||||
sent_at: string | null
|
||||
}
|
||||
|
||||
export interface TemplateApproval {
|
||||
id: string
|
||||
version_id: string
|
||||
action: string // submitted, approved, rejected
|
||||
actor_id: string
|
||||
actor_name?: string
|
||||
comment?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
sender_name: string
|
||||
sender_email: string
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function EmailTemplatesPage() {
|
||||
setEditorSubject, setEditorHtml,
|
||||
setSettingsForm,
|
||||
openEditor, saveVersion, publishVersion, loadPreview,
|
||||
submitForReview, approveVersion, rejectVersion,
|
||||
sendTestEmail, loadApprovalHistory,
|
||||
saveSettings2, initializeDefaults,
|
||||
} = useEmailTemplates(activeTab)
|
||||
|
||||
@@ -68,6 +70,10 @@ export default function EmailTemplatesPage() {
|
||||
onPublish={publishVersion}
|
||||
onPreview={loadPreview}
|
||||
onBack={() => setActiveTab('templates')}
|
||||
onSubmitForReview={submitForReview}
|
||||
onApprove={approveVersion}
|
||||
onReject={rejectVersion}
|
||||
onSendTest={sendTestEmail}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Agent Analyze Routes — combined endpoint that fetches a URL, classifies it,
|
||||
assesses DSGVO compliance, and sends a notification email.
|
||||
|
||||
POST /api/compliance/agent/analyze
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from compliance.services.smtp_sender import send_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
|
||||
|
||||
SDK_URL = os.environ.get("AI_SDK_URL", "http://bp-compliance-ai-sdk:8090")
|
||||
TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
USER_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
ESCALATION_ROLES = {
|
||||
"E0": "Kein Handlungsbedarf",
|
||||
"E1": "Teamleitung Datenschutz",
|
||||
"E2": "Datenschutzbeauftragter (DSB)",
|
||||
"E3": "DSB + Rechtsabteilung",
|
||||
}
|
||||
|
||||
SDK_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Tenant-ID": TENANT_ID,
|
||||
"X-User-ID": USER_ID,
|
||||
}
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
url: str
|
||||
recipient: str = "dsb@breakpilot.local"
|
||||
|
||||
|
||||
class AnalyzeResponse(BaseModel):
|
||||
url: str
|
||||
classification: str
|
||||
risk_level: str
|
||||
risk_score: float
|
||||
escalation_level: str
|
||||
responsible_role: str
|
||||
findings: list[str]
|
||||
required_controls: list[str]
|
||||
summary: str
|
||||
email_status: str
|
||||
analyzed_at: str
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=AnalyzeResponse)
|
||||
async def analyze_url(req: AnalyzeRequest):
|
||||
"""Fetch URL, classify, assess compliance, and notify responsible role."""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
# Step 1: Fetch and clean
|
||||
text = await _fetch_and_clean(client, req.url)
|
||||
|
||||
# Step 2: Classify via SDK LLM
|
||||
classification = await _classify(client, text)
|
||||
|
||||
# Step 3: Assess via UCCA
|
||||
assessment = await _assess(client, text, classification)
|
||||
|
||||
# Step 4: Determine role
|
||||
esc_level = assessment.get("escalation_level", "E0")
|
||||
role = ESCALATION_ROLES.get(esc_level, ESCALATION_ROLES["E0"])
|
||||
|
||||
# Step 5: Build summary
|
||||
findings = assessment.get("triggered_rules", [])
|
||||
controls = assessment.get("required_controls", [])
|
||||
summary = _build_summary(req.url, classification, assessment, role)
|
||||
|
||||
# Step 6: Send notification
|
||||
email_result = send_email(
|
||||
recipient=req.recipient,
|
||||
subject=f"Compliance-Finding: {classification} — {req.url[:60]}",
|
||||
body_html=f"<div>{summary}</div>",
|
||||
)
|
||||
|
||||
return AnalyzeResponse(
|
||||
url=req.url,
|
||||
classification=classification,
|
||||
risk_level=assessment.get("risk_level", "unknown"),
|
||||
risk_score=assessment.get("risk_score", 0),
|
||||
escalation_level=esc_level,
|
||||
responsible_role=role,
|
||||
findings=findings if isinstance(findings, list) else [str(findings)],
|
||||
required_controls=controls if isinstance(controls, list) else [str(controls)],
|
||||
summary=summary,
|
||||
email_status=email_result.get("status", "failed"),
|
||||
analyzed_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> str:
|
||||
"""Fetch URL and strip HTML to plain text."""
|
||||
resp = await client.get(url, follow_redirects=True, headers={
|
||||
"User-Agent": "BreakPilot-Compliance-Agent/1.0",
|
||||
})
|
||||
html = resp.text
|
||||
# Strip script/style blocks, then all tags
|
||||
clean = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
clean = re.sub(r"<[^>]+>", " ", clean)
|
||||
clean = re.sub(r" ", " ", clean)
|
||||
clean = re.sub(r"\s+", " ", clean).strip()
|
||||
return clean[:4000]
|
||||
|
||||
|
||||
async def _classify(client: httpx.AsyncClient, text: str) -> str:
|
||||
"""Classify document type via SDK LLM chat."""
|
||||
try:
|
||||
resp = await client.post(f"{SDK_URL}/sdk/v1/llm/chat", headers=SDK_HEADERS, json={
|
||||
"messages": [
|
||||
{"role": "system", "content": (
|
||||
"/no_think\n"
|
||||
"Klassifiziere das Dokument in GENAU EINE Kategorie: "
|
||||
"privacy_policy, cookie_banner, terms_of_service, imprint, dpa, other. "
|
||||
"Antworte NUR mit dem Kategorienamen, nichts anderes. Kein Denken, keine Erklaerung."
|
||||
)},
|
||||
{"role": "user", "content": text[:2000]},
|
||||
],
|
||||
})
|
||||
data = resp.json()
|
||||
# Qwen 3.5 may use think mode — content can be in message.content or response
|
||||
raw = (
|
||||
data.get("response", "")
|
||||
or data.get("content", "")
|
||||
or (data.get("message", {}) or {}).get("content", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
# Strip Qwen think tags if present
|
||||
raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
|
||||
logger.info("Classification raw response: %s", raw[:200])
|
||||
for cat in ["privacy_policy", "cookie_banner", "terms_of_service", "imprint", "dpa"]:
|
||||
if cat in raw:
|
||||
return cat
|
||||
# Also check German terms
|
||||
if "datenschutz" in raw:
|
||||
return "privacy_policy"
|
||||
if "cookie" in raw:
|
||||
return "cookie_banner"
|
||||
if "impressum" in raw:
|
||||
return "imprint"
|
||||
return "other"
|
||||
except Exception as e:
|
||||
logger.warning("Classification failed: %s", e)
|
||||
return "other"
|
||||
|
||||
|
||||
async def _assess(client: httpx.AsyncClient, text: str, classification: str) -> dict:
|
||||
"""Run UCCA assessment via SDK. Returns flattened result dict."""
|
||||
try:
|
||||
# UCCA expects boolean intake flags, not string categories
|
||||
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(),
|
||||
},
|
||||
})
|
||||
data = resp.json()
|
||||
# Flatten: UCCA wraps result under "assessment" and "result"
|
||||
assessment = data.get("assessment", data.get("result", data))
|
||||
result = data.get("result", {})
|
||||
return {
|
||||
"risk_level": assessment.get("risk_level", result.get("risk_level", "unknown")),
|
||||
"risk_score": assessment.get("risk_score", result.get("risk_score", 0)),
|
||||
"escalation_level": _risk_to_escalation(assessment.get("risk_level", "")),
|
||||
"triggered_rules": assessment.get("triggered_rules", result.get("triggered_rules", [])),
|
||||
"required_controls": assessment.get("required_controls", result.get("required_controls", [])),
|
||||
"summary": result.get("summary", ""),
|
||||
"recommendation": result.get("recommendation", ""),
|
||||
"dsfa_recommended": assessment.get("dsfa_recommended", False),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Assessment failed: %s", e)
|
||||
return {"risk_level": "unknown", "risk_score": 0, "escalation_level": "E0"}
|
||||
|
||||
|
||||
def _risk_to_escalation(risk_level: str) -> str:
|
||||
"""Map UCCA risk level to escalation level."""
|
||||
mapping = {
|
||||
"MINIMAL": "E0",
|
||||
"LIMITED": "E1",
|
||||
"HIGH": "E2",
|
||||
"UNACCEPTABLE": "E3",
|
||||
}
|
||||
return mapping.get(risk_level.upper() if risk_level else "", "E0")
|
||||
|
||||
|
||||
def _build_summary(url: str, classification: str, assessment: dict, role: str) -> str:
|
||||
"""Build a German manager summary."""
|
||||
risk = assessment.get("risk_level", "unbekannt")
|
||||
score = assessment.get("risk_score", 0)
|
||||
findings = assessment.get("triggered_rules", [])
|
||||
controls = assessment.get("required_controls", [])
|
||||
recommendation = assessment.get("recommendation", "")
|
||||
dsfa = assessment.get("dsfa_recommended", False)
|
||||
|
||||
findings_text = "\n".join(f"- {f}" for f in findings[:5]) if findings else "Keine"
|
||||
controls_text = "\n".join(f"- {c}" for c in controls[:5]) if controls else "Keine"
|
||||
|
||||
parts = [
|
||||
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}",
|
||||
]
|
||||
if recommendation:
|
||||
parts.extend(["", f"Empfehlung: {recommendation}"])
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Agent Notification Routes — endpoint for the ZeroClaw compliance agent
|
||||
to send notification emails via SMTP.
|
||||
|
||||
POST /api/compliance/agent/notify
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from compliance.services.smtp_sender import send_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
|
||||
|
||||
|
||||
class NotifyRequest(BaseModel):
|
||||
recipient: str
|
||||
subject: str
|
||||
body_html: str
|
||||
role: str
|
||||
escalation_id: str | None = None
|
||||
|
||||
|
||||
class NotifyResponse(BaseModel):
|
||||
status: str
|
||||
recipient: str
|
||||
subject: str
|
||||
role: str
|
||||
sent_at: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@router.post("/notify", response_model=NotifyResponse)
|
||||
async def send_agent_notification(req: NotifyRequest):
|
||||
"""Send a compliance notification email on behalf of the agent."""
|
||||
result = send_email(
|
||||
recipient=req.recipient,
|
||||
subject=req.subject,
|
||||
body_html=_build_email_body(req),
|
||||
)
|
||||
|
||||
return NotifyResponse(
|
||||
status=result["status"],
|
||||
recipient=req.recipient,
|
||||
subject=req.subject,
|
||||
role=req.role,
|
||||
sent_at=datetime.now(timezone.utc).isoformat(),
|
||||
error=result.get("error"),
|
||||
)
|
||||
|
||||
|
||||
def _build_email_body(req: NotifyRequest) -> str:
|
||||
"""Wrap the agent's HTML body with a standard email frame."""
|
||||
return f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #1a1a2e; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
|
||||
<h2 style="margin: 0; font-size: 18px;">BreakPilot Compliance Agent</h2>
|
||||
</div>
|
||||
<div style="padding: 24px; border: 1px solid #e2e8f0; border-top: none;">
|
||||
<p style="color: #64748b; font-size: 13px; margin-top: 0;">
|
||||
Zugewiesen an: <strong>{req.role}</strong>
|
||||
{f' | Eskalation: {req.escalation_id}' if req.escalation_id else ''}
|
||||
</p>
|
||||
{req.body_html}
|
||||
</div>
|
||||
<div style="background: #f8fafc; padding: 12px 24px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="color: #94a3b8; font-size: 11px; margin: 0;">
|
||||
Automatisch generiert vom BreakPilot Compliance Agent (ZeroClaw + Qwen)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
SMTP Sender — sends real emails via SMTP (e.g., to Mailpit for dev).
|
||||
|
||||
Uses standard smtplib. Configuration via environment variables:
|
||||
SMTP_HOST (default: localhost)
|
||||
SMTP_PORT (default: 1025)
|
||||
SMTP_FROM_NAME (default: BreakPilot Compliance)
|
||||
SMTP_FROM_ADDR (default: compliance@breakpilot.local)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMTP_HOST = os.environ.get("SMTP_HOST", "localhost")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "1025"))
|
||||
SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "BreakPilot Compliance")
|
||||
SMTP_FROM_ADDR = os.environ.get("SMTP_FROM_ADDR", "compliance@breakpilot.local")
|
||||
|
||||
|
||||
def send_email(
|
||||
recipient: str,
|
||||
subject: str,
|
||||
body_html: str,
|
||||
from_addr: str | None = None,
|
||||
from_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Send an email via SMTP. Returns dict with status and message_id."""
|
||||
sender_addr = from_addr or SMTP_FROM_ADDR
|
||||
sender_name = from_name or SMTP_FROM_NAME
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{sender_name} <{sender_addr}>"
|
||||
msg["To"] = recipient
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as server:
|
||||
server.sendmail(sender_addr, [recipient], msg.as_string())
|
||||
logger.info("Email sent to %s: %s", recipient, subject)
|
||||
return {"status": "sent", "recipient": recipient, "subject": subject}
|
||||
except Exception as e:
|
||||
logger.error("Failed to send email to %s: %s", recipient, e)
|
||||
return {"status": "failed", "recipient": recipient, "error": str(e)}
|
||||
@@ -41,6 +41,10 @@ from compliance.api.screening_routes import router as screening_router
|
||||
# Company Profile
|
||||
from compliance.api.company_profile_routes import router as company_profile_router
|
||||
|
||||
# Agent (ZeroClaw compliance agent)
|
||||
from compliance.api.agent_notification_routes import router as agent_notify_router
|
||||
from compliance.api.agent_analyze_routes import router as agent_analyze_router
|
||||
|
||||
# Middleware
|
||||
from middleware import (
|
||||
RequestIDMiddleware,
|
||||
@@ -135,6 +139,10 @@ app.include_router(screening_router, prefix="/api")
|
||||
# Company Profile (CRUD with audit logging)
|
||||
app.include_router(company_profile_router, prefix="/api")
|
||||
|
||||
# Agent (ZeroClaw compliance agent → analyze + email via SMTP)
|
||||
app.include_router(agent_notify_router, prefix="/api")
|
||||
app.include_router(agent_analyze_router, prefix="/api")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for agent notification endpoint."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestAgentNotify:
|
||||
"""Tests for POST /api/compliance/agent/notify."""
|
||||
|
||||
@patch("compliance.services.smtp_sender.smtplib.SMTP")
|
||||
def test_send_notification_success(self, mock_smtp, client):
|
||||
mock_instance = mock_smtp.return_value.__enter__.return_value
|
||||
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "dsb@firma.de",
|
||||
"subject": "Test Finding",
|
||||
"body_html": "<p>Test body</p>",
|
||||
"role": "Datenschutzbeauftragter",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "sent"
|
||||
assert data["recipient"] == "dsb@firma.de"
|
||||
assert data["role"] == "Datenschutzbeauftragter"
|
||||
assert data["sent_at"] is not None
|
||||
mock_instance.sendmail.assert_called_once()
|
||||
|
||||
@patch("compliance.services.smtp_sender.smtplib.SMTP")
|
||||
def test_send_notification_with_escalation(self, mock_smtp, client):
|
||||
mock_smtp.return_value.__enter__.return_value
|
||||
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "legal@firma.de",
|
||||
"subject": "Escalation E3",
|
||||
"body_html": "<h2>Urgent</h2>",
|
||||
"role": "DSB + Rechtsabteilung",
|
||||
"escalation_id": "esc-123",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "sent"
|
||||
assert data["role"] == "DSB + Rechtsabteilung"
|
||||
|
||||
def test_send_notification_invalid_email(self, client):
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "not-an-email",
|
||||
"subject": "Test",
|
||||
"body_html": "<p>Test</p>",
|
||||
"role": "DSB",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_send_notification_missing_fields(self, client):
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "dsb@firma.de",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
@patch("compliance.services.smtp_sender.smtplib.SMTP")
|
||||
def test_send_notification_smtp_failure(self, mock_smtp, client):
|
||||
mock_smtp.return_value.__enter__.side_effect = ConnectionRefusedError("SMTP down")
|
||||
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "dsb@firma.de",
|
||||
"subject": "Test",
|
||||
"body_html": "<p>Test</p>",
|
||||
"role": "DSB",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "SMTP down" in data["error"]
|
||||
@@ -0,0 +1,56 @@
|
||||
# ZeroClaw Compliance Agent Demo
|
||||
|
||||
Autonomer Compliance-Agent der Web-Dokumente (Cookie-Banner, Datenschutzerklaerungen) analysiert und die Ergebnisse an die zustaendige Rolle weiterleitet.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
ZeroClaw Agent (Rust, Mac Mini)
|
||||
│
|
||||
├── LLM: Qwen 3.5:35b-a3b (Ollama, localhost:11434)
|
||||
│
|
||||
├── Compliance SDK (Go/Gin, localhost:8093)
|
||||
│ ├── /sdk/v1/llm/chat → Dokumentklassifizierung
|
||||
│ ├── /sdk/v1/ucca/assess → Risikobewertung
|
||||
│ └── /sdk/v1/ucca/escalations → Eskalation + Rollenzuweisung
|
||||
│
|
||||
├── Backend (Python/FastAPI, localhost:8002)
|
||||
│ └── /api/compliance/agent/notify → Email-Benachrichtigung
|
||||
│
|
||||
└── Mailpit (SMTP localhost:1025, Web localhost:8025)
|
||||
└── Fiktive Email-Zustellung
|
||||
```
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- ZeroClaw v0.7.3+ (`brew install zeroclaw`)
|
||||
- Ollama mit `qwen3.5:35b-a3b` Modell
|
||||
- Alle Compliance-Services laufen (SDK, Backend, Mailpit)
|
||||
|
||||
## Demo ausfuehren
|
||||
|
||||
```bash
|
||||
# 1. ZeroClaw mit Ollama verbinden (einmalig)
|
||||
zeroclaw onboard --quick --provider ollama --model qwen3.5:35b-a3b
|
||||
|
||||
# 2. SOP ausfuehren
|
||||
zeroclaw agent -m "Analysiere die Datenschutzerklaerung von https://www.google.com/intl/de/policies/privacy/"
|
||||
|
||||
# 3. Ergebnis pruefen
|
||||
open http://localhost:8025 # Mailpit Web-UI
|
||||
```
|
||||
|
||||
## E2E Test
|
||||
|
||||
```bash
|
||||
bash zeroclaw/tests/test_sop_workflow.sh
|
||||
```
|
||||
|
||||
## SOP-Workflow (6 Schritte)
|
||||
|
||||
1. **Fetch** — URL holen, HTML strippen
|
||||
2. **Classify** — Dokumenttyp bestimmen (privacy_policy, cookie_banner, etc.)
|
||||
3. **Assess** — DSGVO-Risikobewertung via UCCA
|
||||
4. **Summarize** — Manager-Report auf Deutsch
|
||||
5. **Assign** — Zustaendige Rolle bestimmen (E0-E3 Mapping)
|
||||
6. **Notify** — Email an DSB/Teamleitung senden
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# fetch-and-analyze.sh — Fetch a URL and extract clean text for compliance analysis.
|
||||
#
|
||||
# Usage: bash fetch-and-analyze.sh <url> [max_chars]
|
||||
#
|
||||
# Outputs clean text to stdout, truncated to max_chars (default: 4000).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
URL="${1:?Usage: fetch-and-analyze.sh <url> [max_chars]}"
|
||||
MAX_CHARS="${2:-4000}"
|
||||
|
||||
# Fetch page with reasonable timeout and user agent
|
||||
HTML=$(curl -sL --max-time 30 \
|
||||
-H "User-Agent: Mozilla/5.0 (compatible; BreakPilot-Compliance-Agent/1.0)" \
|
||||
"$URL" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$HTML" ]; then
|
||||
echo "ERROR: Could not fetch $URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Strip HTML: remove style/script blocks, then all tags, normalize whitespace
|
||||
CLEAN=$(echo "$HTML" \
|
||||
| sed 's/<style[^>]*>[^<]*<\/style>//gi' \
|
||||
| sed 's/<script[^>]*>[^<]*<\/script>//gi' \
|
||||
| sed 's/<[^>]*>//g' \
|
||||
| sed 's/ / /g; s/&/\&/g; s/</</g; s/>/>/g; s/"/"/g' \
|
||||
| tr -s '[:space:]' ' ' \
|
||||
| sed 's/^ //; s/ $//')
|
||||
|
||||
# Truncate to max chars
|
||||
echo "$CLEAN" | head -c "$MAX_CHARS"
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# send-notification.sh — Send a notification email via Mailpit SMTP.
|
||||
#
|
||||
# Usage: bash send-notification.sh <recipient> <subject> <body_text>
|
||||
#
|
||||
# Uses Mailpit's SMTP on localhost:1025 via Python smtplib (one-liner).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RECIPIENT="${1:?Usage: send-notification.sh <recipient> <subject> <body_text>}"
|
||||
SUBJECT="${2:?Missing subject}"
|
||||
BODY="${3:?Missing body text}"
|
||||
|
||||
SMTP_HOST="${SMTP_HOST:-localhost}"
|
||||
SMTP_PORT="${SMTP_PORT:-1025}"
|
||||
FROM_ADDR="${SMTP_FROM_ADDR:-compliance-agent@breakpilot.local}"
|
||||
FROM_NAME="${SMTP_FROM_NAME:-BreakPilot Compliance Agent}"
|
||||
|
||||
python3 -c "
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = '${FROM_NAME} <${FROM_ADDR}>'
|
||||
msg['To'] = '${RECIPIENT}'
|
||||
msg['Subject'] = '${SUBJECT}'
|
||||
msg.attach(MIMEText('''${BODY}''', 'html', 'utf-8'))
|
||||
|
||||
with smtplib.SMTP('${SMTP_HOST}', ${SMTP_PORT}) as server:
|
||||
server.sendmail('${FROM_ADDR}', '${RECIPIENT}', msg.as_string())
|
||||
|
||||
print('Email sent to ${RECIPIENT}')
|
||||
"
|
||||
@@ -0,0 +1,98 @@
|
||||
## Context
|
||||
|
||||
Du bist ein Compliance-Analyst-Agent. Du analysierst Web-Dokumente (Cookie-Banner, Datenschutzerklaerungen) auf DSGVO-Konformitaet mithilfe des BreakPilot Compliance SDK.
|
||||
|
||||
### Endpunkte
|
||||
|
||||
- **Compliance SDK:** http://localhost:8093
|
||||
- **Backend:** http://localhost:8002
|
||||
- **Mailpit SMTP:** localhost:1025
|
||||
- **Mailpit Web:** http://localhost:8025
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
Alle SDK-Anfragen benoetigen diese Header:
|
||||
- `X-Tenant-ID: 9282a473-5c95-4b3a-bf78-0ecc0ec71d3e`
|
||||
- `X-User-ID: 00000000-0000-0000-0000-000000000001`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Fetch Document
|
||||
|
||||
Hole die Ziel-URL und extrahiere den Text:
|
||||
|
||||
```bash
|
||||
curl -sL "$URL" | sed 's/<style[^>]*>.*<\/style>//g; s/<script[^>]*>.*<\/script>//g; s/<[^>]*>//g; s/ / /g; s/&/\&/g; s/ */ /g' | head -c 4000
|
||||
```
|
||||
|
||||
Speichere das Ergebnis als `$DOCUMENT_TEXT`.
|
||||
|
||||
### 2. Classify Document
|
||||
|
||||
Sende den Text an das SDK zur Klassifizierung:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8093/sdk/v1/llm/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Tenant-ID: 9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" \
|
||||
-H "X-User-ID: 00000000-0000-0000-0000-000000000001" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{"role": "system", "content": "Klassifiziere das folgende Dokument in GENAU EINE Kategorie: privacy_policy, cookie_banner, terms_of_service, imprint, dpa, other. Antworte NUR mit dem Kategorienamen."},
|
||||
{"role": "user", "content": "'"$DOCUMENT_TEXT"'"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. Analyze Compliance
|
||||
|
||||
Fuehre eine UCCA-Bewertung durch:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8093/sdk/v1/ucca/assess \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Tenant-ID: 9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" \
|
||||
-H "X-User-ID: 00000000-0000-0000-0000-000000000001" \
|
||||
-d '{
|
||||
"use_case_text": "'"$DOCUMENT_TEXT"'",
|
||||
"domain": "'"$CLASSIFICATION"'",
|
||||
"data_categories": ["personal_data", "tracking", "cookies", "third_party_sharing"]
|
||||
}'
|
||||
```
|
||||
|
||||
Notiere: `risk_score`, `risk_level`, `escalation_level`, `triggered_rules`, `required_controls`.
|
||||
|
||||
### 4. Prepare Summary
|
||||
|
||||
Erstelle einen Manager-Report auf Deutsch mit:
|
||||
- **Dokumenttyp:** (aus Schritt 2)
|
||||
- **Quelle:** (URL)
|
||||
- **Risikobewertung:** (risk_level + risk_score aus Schritt 3)
|
||||
- **Wesentliche Findings:** (triggered_rules zusammengefasst)
|
||||
- **Erforderliche Massnahmen:** (required_controls zusammengefasst)
|
||||
- **Empfehlung:** (Handlungsempfehlung basierend auf escalation_level)
|
||||
|
||||
### 5. Determine Responsible Role
|
||||
|
||||
Basierend auf dem `escalation_level` aus Schritt 3:
|
||||
- **E0** → Kein Handlungsbedarf, automatische Compliance
|
||||
- **E1** → Teamleitung Datenschutz
|
||||
- **E2** → Datenschutzbeauftragter (DSB)
|
||||
- **E3** → DSB + Rechtsabteilung (gemeinsame Entscheidung)
|
||||
|
||||
### 6. Send Notification Email
|
||||
|
||||
Sende eine Benachrichtigung an die zustaendige Rolle:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8002/api/compliance/agent/notify \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"recipient": "dsb@breakpilot.local",
|
||||
"subject": "Compliance-Finding: '"$CLASSIFICATION"' — '"$URL"'",
|
||||
"body_html": "'"$MANAGER_SUMMARY_HTML"'",
|
||||
"role": "'"$RESPONSIBLE_ROLE"'"
|
||||
}'
|
||||
```
|
||||
|
||||
Pruefe das Ergebnis in Mailpit: http://localhost:8025
|
||||
@@ -0,0 +1,15 @@
|
||||
[sop]
|
||||
name = "compliance-analyst"
|
||||
description = "Fetch a web document (cookie banner, privacy policy), analyze for DSGVO compliance via BreakPilot SDK, assign to responsible role, notify via email"
|
||||
version = "1.0.0"
|
||||
priority = "normal"
|
||||
execution_mode = "supervised"
|
||||
max_concurrent = 1
|
||||
cooldown_secs = 60
|
||||
|
||||
[[triggers]]
|
||||
type = "manual"
|
||||
|
||||
[[triggers]]
|
||||
type = "webhook"
|
||||
path = "/sop/compliance-analyst"
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test_sop_workflow.sh — End-to-end test for the compliance-analyst SOP.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Compliance SDK running on localhost:8093
|
||||
# - Backend running on localhost:8002
|
||||
# - Ollama running on localhost:11434 with qwen model
|
||||
# - Mailpit running (SMTP on 1025, Web on 8025)
|
||||
# - ZeroClaw installed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SDK="http://localhost:8093"
|
||||
BACKEND="http://localhost:8002"
|
||||
OLLAMA="http://localhost:11434"
|
||||
MAILPIT="http://localhost:8025"
|
||||
TENANT="9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
USER_ID="00000000-0000-0000-0000-000000000001"
|
||||
|
||||
red() { printf '\033[31m✗ %s\033[0m\n' "$*"; }
|
||||
green() { printf '\033[32m✓ %s\033[0m\n' "$*"; }
|
||||
|
||||
echo "═══ Compliance Agent SOP — E2E Test ═══"
|
||||
echo ""
|
||||
|
||||
# Step 1: Health checks
|
||||
echo "── Step 1: Service Health ──"
|
||||
curl -sf "$SDK/health" >/dev/null && green "SDK healthy" || red "SDK unreachable"
|
||||
curl -sf "$BACKEND/health" >/dev/null && green "Backend healthy" || red "Backend unreachable"
|
||||
curl -sf "$OLLAMA/api/tags" >/dev/null && green "Ollama running" || red "Ollama unreachable"
|
||||
|
||||
# Step 2: Test document fetch
|
||||
echo ""
|
||||
echo "── Step 2: Document Fetch ──"
|
||||
TEXT=$(bash "$(dirname "$0")/../scripts/fetch-and-analyze.sh" "https://www.google.com/intl/de/policies/privacy/" 2000)
|
||||
CHARS=${#TEXT}
|
||||
if [ "$CHARS" -gt 100 ]; then
|
||||
green "Fetched $CHARS chars from Google Privacy Policy"
|
||||
else
|
||||
red "Fetch returned too little text ($CHARS chars)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3: Test LLM classification
|
||||
echo ""
|
||||
echo "── Step 3: LLM Classification ──"
|
||||
CLASSIFY_RESULT=$(curl -sf -X POST "$SDK/sdk/v1/llm/chat" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Tenant-ID: $TENANT" \
|
||||
-H "X-User-ID: $USER_ID" \
|
||||
-d "{
|
||||
\"messages\": [
|
||||
{\"role\": \"system\", \"content\": \"Klassifiziere: privacy_policy, cookie_banner, terms_of_service, imprint, dpa, other. Antworte NUR mit dem Kategorienamen.\"},
|
||||
{\"role\": \"user\", \"content\": $(echo "$TEXT" | head -c 1000 | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')}
|
||||
]
|
||||
}" 2>&1) || true
|
||||
|
||||
if echo "$CLASSIFY_RESULT" | grep -qi "privacy_policy\|cookie\|terms\|imprint\|dpa"; then
|
||||
green "Classification: $(echo "$CLASSIFY_RESULT" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("response","").strip()[:50])' 2>/dev/null || echo "$CLASSIFY_RESULT" | head -c 50)"
|
||||
else
|
||||
echo " Classification result: $(echo "$CLASSIFY_RESULT" | head -c 100)"
|
||||
red "Classification did not return expected category (may still be valid)"
|
||||
fi
|
||||
|
||||
# Step 4: Test notification endpoint
|
||||
echo ""
|
||||
echo "── Step 4: Agent Notification ──"
|
||||
NOTIFY_RESULT=$(curl -sf -X POST "$BACKEND/api/compliance/agent/notify" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"recipient": "dsb@breakpilot.local",
|
||||
"subject": "E2E Test: Compliance-Finding",
|
||||
"body_html": "<h2>Test-Benachrichtigung</h2><p>Automatischer E2E-Test des Compliance-Agent SOP.</p>",
|
||||
"role": "Datenschutzbeauftragter"
|
||||
}' 2>&1) || true
|
||||
|
||||
if echo "$NOTIFY_RESULT" | grep -qi "sent\|success\|ok"; then
|
||||
green "Notification sent"
|
||||
else
|
||||
echo " Notify result: $(echo "$NOTIFY_RESULT" | head -c 100)"
|
||||
red "Notification endpoint returned unexpected result"
|
||||
fi
|
||||
|
||||
# Step 5: Check Mailpit
|
||||
echo ""
|
||||
echo "── Step 5: Mailpit Check ──"
|
||||
MAIL_COUNT=$(curl -sf "$MAILPIT/api/v1/messages" 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("total",0))' 2>/dev/null || echo "0")
|
||||
if [ "$MAIL_COUNT" -gt 0 ]; then
|
||||
green "Mailpit has $MAIL_COUNT message(s)"
|
||||
else
|
||||
red "No messages in Mailpit (check SMTP connectivity)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "═══ E2E Test Complete ═══"
|
||||
Reference in New Issue
Block a user