refactor: Consolidate standalone services into admin-v2, add new SDK modules

Remove standalone services (ai-compliance-sdk root, developer-portal,
dsms-gateway, dsms-node, night-scheduler) and legacy compliance/dsgvo pages.
Add new SDK pipeline modules (academy, document-crawler, dsb-portal,
incidents, whistleblower, reporting, sso, multi-tenant, industry-templates).
Add drafting engine, legal corpus files (AT/CH/DE), pitch-deck,
blog and Förderantrag pages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-15 09:05:18 +01:00
parent 626f4966e2
commit 70f2b0ae64
396 changed files with 43163 additions and 80397 deletions

View File

@@ -65,7 +65,7 @@ export function Header({ title, description }: HeaderProps) {
{/* User Area */}
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">Admin v2</span>
<span className="text-sm text-slate-500">Admin Lehrer KI</span>
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
A
</div>

View File

@@ -51,6 +51,16 @@ const categoryIcons: Record<string, React.ReactNode> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
globe: (
<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>
),
'code-2': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
}
const metaIcons: Record<string, React.ReactNode> = {
@@ -138,7 +148,7 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
{!collapsed && (
<Link href="/dashboard" className="font-bold text-lg">
Admin v2
Admin Lehrer KI
</Link>
)}
<button
@@ -184,7 +194,7 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
{/* Categories */}
<div className="px-2 space-y-1">
{visibleCategories.map((category) => {
const categoryHref = category.id === 'compliance-sdk' ? '/dashboard/catalog-manager' : `/${category.id === 'compliance' ? 'compliance' : category.id}`
const categoryHref = category.id === 'compliance-sdk' ? '/sdk' : `/${category.id}`
const isCategoryActive = category.id === 'compliance-sdk'
? category.modules.some(m => pathname.startsWith(m.href))
: pathname.startsWith(categoryHref)

View File

@@ -15,6 +15,7 @@ interface Message {
interface ComplianceAdvisorWidgetProps {
currentStep?: string
enableDraftingEngine?: boolean
}
// =============================================================================
@@ -58,8 +59,15 @@ const EXAMPLE_QUESTIONS: Record<string, string[]> = {
// COMPONENT
// =============================================================================
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftingEngine = false }: ComplianceAdvisorWidgetProps) {
// Feature-flag: If Drafting Engine enabled, render DraftingEngineWidget instead
if (enableDraftingEngine) {
const { DraftingEngineWidget } = require('./DraftingEngineWidget')
return <DraftingEngineWidget currentStep={currentStep} enableDraftingEngine />
}
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [isTyping, setIsTyping] = useState(false)
@@ -152,6 +160,11 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
setMessages((prev) =>
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
)
// Auto-scroll during streaming
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
})
}
setIsTyping(false)
@@ -214,7 +227,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
aria-label="Compliance Advisor oeffnen"
>
<svg
@@ -235,7 +248,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
}
return (
<div className="fixed bottom-6 right-6 w-[400px] h-[500px] bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200">
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[500px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -259,25 +272,55 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
<div className="text-xs text-white/80">KI-gestuetzter Assistent</div>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Schliessen"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-white/80 hover:text-white transition-colors"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isExpanded ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5"
/>
)}
</svg>
</button>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Schliessen"
>
<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"
/>
</svg>
</button>
</div>
</div>
{/* Messages Area */}

View File

@@ -392,7 +392,7 @@ export function DocumentUploadSection({
onOpenInEditor(doc)
} else {
// Default: navigate to workflow editor
router.push(`/compliance/workflow?documentType=${documentType}&documentId=${doc.id}`)
router.push(`/sdk/workflow?documentType=${documentType}&documentId=${doc.id}`)
}
}, [documentType, onOpenInEditor, router])

View File

@@ -0,0 +1,300 @@
'use client'
/**
* DraftEditor - Split-Pane Editor fuer Compliance-Dokument-Entwuerfe
*
* Links (2/3): Gerenderter Draft mit Section-Headern
* Rechts (1/3): Chat-Panel fuer iterative Verfeinerung
* Oben: Document-Type Label, Depth-Level Badge, Constraint-Compliance
*/
import { useState, useRef, useCallback } from 'react'
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
import type {
DraftRevision,
ConstraintCheckResult,
ValidationResult,
} from '@/lib/sdk/drafting-engine/types'
interface DraftEditorProps {
draft: DraftRevision
documentType: ScopeDocumentType | null
constraintCheck: ConstraintCheckResult | null
validationResult: ValidationResult | null
isTyping: boolean
onAccept: () => void
onValidate: () => void
onClose: () => void
onRefine: (instruction: string) => void
}
export function DraftEditor({
draft,
documentType,
constraintCheck,
validationResult,
isTyping,
onAccept,
onValidate,
onClose,
onRefine,
}: DraftEditorProps) {
const [refineInput, setRefineInput] = useState('')
const [activeSection, setActiveSection] = useState<string | null>(null)
const contentRef = useRef<HTMLDivElement>(null)
const handleRefine = useCallback(() => {
if (!refineInput.trim() || isTyping) return
onRefine(refineInput.trim())
setRefineInput('')
}, [refineInput, isTyping, onRefine])
const handleRefineKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleRefine()
}
}
const docLabel = documentType
? DOCUMENT_TYPE_LABELS[documentType]?.split(' (')[0] || documentType
: 'Dokument'
return (
<div className="fixed inset-0 bg-gray-900/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-3 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<div>
<div className="font-semibold text-sm">{docLabel} - Entwurf</div>
<div className="text-xs text-white/70">
{draft.sections.length} Sections | Erstellt {new Date(draft.createdAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Constraint Badge */}
{constraintCheck && (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
constraintCheck.allowed
? 'bg-green-500/20 text-green-100'
: 'bg-red-500/20 text-red-100'
}`}>
{constraintCheck.allowed ? 'Constraints OK' : 'Constraint-Verletzung'}
</span>
)}
{/* Validation Badge */}
{validationResult && (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
validationResult.passed
? 'bg-green-500/20 text-green-100'
: 'bg-amber-500/20 text-amber-100'
}`}>
{validationResult.passed ? 'Validiert' : `${validationResult.errors.length} Fehler`}
</span>
)}
<button
onClick={onClose}
className="text-white/80 hover:text-white transition-colors p-1"
aria-label="Editor schliessen"
>
<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" />
</svg>
</button>
</div>
</div>
{/* Adjustment Warnings */}
{constraintCheck && constraintCheck.adjustments.length > 0 && (
<div className="px-6 py-2 bg-amber-50 border-b border-amber-200 shrink-0">
{constraintCheck.adjustments.map((adj, i) => (
<p key={i} className="text-xs text-amber-700 flex items-start gap-1">
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0" 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
{adj}
</p>
))}
</div>
)}
{/* Main Content: 2/3 Editor + 1/3 Chat */}
<div className="flex-1 flex overflow-hidden">
{/* Left: Draft Content (2/3) */}
<div className="w-2/3 border-r border-gray-200 overflow-y-auto" ref={contentRef}>
{/* Section Navigation */}
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-2 flex items-center gap-1 overflow-x-auto z-10">
{draft.sections.map((section) => (
<button
key={section.id}
onClick={() => {
setActiveSection(section.id)
document.getElementById(`section-${section.id}`)?.scrollIntoView({ behavior: 'smooth' })
}}
className={`px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap transition-colors ${
activeSection === section.id
? 'bg-blue-100 text-blue-700'
: 'text-gray-500 hover:bg-gray-100'
}`}
>
{section.title}
</button>
))}
</div>
{/* Sections */}
<div className="p-6 space-y-6">
{draft.sections.map((section) => (
<div
key={section.id}
id={`section-${section.id}`}
className={`rounded-lg border transition-colors ${
activeSection === section.id
? 'border-blue-300 bg-blue-50/30'
: 'border-gray-200 bg-white'
}`}
>
<div className="px-4 py-2.5 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">{section.title}</h3>
{section.schemaField && (
<span className="text-xs text-gray-400 font-mono">{section.schemaField}</span>
)}
</div>
<div className="px-4 py-3">
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">
{section.content}
</div>
</div>
</div>
))}
</div>
</div>
{/* Right: Refinement Chat (1/3) */}
<div className="w-1/3 flex flex-col bg-gray-50">
<div className="px-4 py-3 border-b border-gray-200 bg-white">
<h3 className="text-sm font-semibold text-gray-900">Verfeinerung</h3>
<p className="text-xs text-gray-500">Geben Sie Anweisungen zur Verbesserung</p>
</div>
{/* Validation Summary (if present) */}
{validationResult && (
<div className="px-4 py-3 border-b border-gray-100">
<div className="space-y-1.5">
{validationResult.errors.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-red-600">
<span className="w-2 h-2 rounded-full bg-red-500" />
{validationResult.errors.length} Fehler
</div>
)}
{validationResult.warnings.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-amber-600">
<span className="w-2 h-2 rounded-full bg-amber-500" />
{validationResult.warnings.length} Warnungen
</div>
)}
{validationResult.suggestions.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-blue-600">
<span className="w-2 h-2 rounded-full bg-blue-500" />
{validationResult.suggestions.length} Vorschlaege
</div>
)}
</div>
</div>
)}
{/* Refinement Area */}
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-3">
<p className="text-xs text-gray-500">
Beschreiben Sie, was geaendert werden soll. Der Agent erstellt eine ueberarbeitete Version unter Beachtung der Scope-Constraints.
</p>
{/* Quick Refinement Buttons */}
<div className="space-y-1.5">
{[
'Mehr Details hinzufuegen',
'Platzhalter ausfuellen',
'Rechtliche Referenzen ergaenzen',
'Sprache vereinfachen',
].map((suggestion) => (
<button
key={suggestion}
onClick={() => onRefine(suggestion)}
disabled={isTyping}
className="w-full text-left px-3 py-1.5 text-xs bg-white hover:bg-blue-50 border border-gray-200 rounded-md transition-colors text-gray-600 disabled:opacity-50"
>
{suggestion}
</button>
))}
</div>
</div>
</div>
{/* Refinement Input */}
<div className="border-t border-gray-200 p-3 bg-white">
<div className="flex gap-2">
<input
type="text"
value={refineInput}
onChange={(e) => setRefineInput(e.target.value)}
onKeyDown={handleRefineKeyDown}
placeholder="Anweisung eingeben..."
disabled={isTyping}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
/>
<button
onClick={handleRefine}
disabled={!refineInput.trim() || isTyping}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed 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 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="border-t border-gray-200 px-6 py-3 bg-white flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<button
onClick={onValidate}
disabled={isTyping}
className="px-4 py-2 text-sm font-medium text-green-700 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 disabled:opacity-50 transition-colors"
>
Validieren
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors"
>
Abbrechen
</button>
<button
onClick={onAccept}
disabled={isTyping}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Draft akzeptieren
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,417 @@
'use client'
/**
* DraftingEngineWidget - Erweitert den ComplianceAdvisor um 4 Modi
*
* Mode-Indicator Pills: Explain / Ask / Draft / Validate
* Document-Type Selector aus requiredDocuments der ScopeDecision
* Feature-Flag enableDraftingEngine fuer schrittweises Rollout
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSDK } from '@/lib/sdk/context'
import { useDraftingEngine } from '@/lib/sdk/drafting-engine/use-drafting-engine'
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
import type { AgentMode } from '@/lib/sdk/drafting-engine/types'
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
import { DraftEditor } from './DraftEditor'
import { ValidationReport } from './ValidationReport'
interface DraftingEngineWidgetProps {
currentStep?: string
enableDraftingEngine?: boolean
}
const MODE_CONFIG: Record<AgentMode, { label: string; color: string; activeColor: string; icon: string }> = {
explain: { label: 'Explain', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-purple-100 text-purple-700 ring-1 ring-purple-300', icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
ask: { label: 'Ask', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-amber-100 text-amber-700 ring-1 ring-amber-300', icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
draft: { label: 'Draft', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-blue-100 text-blue-700 ring-1 ring-blue-300', icon: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z' },
validate: { label: 'Validate', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-green-100 text-green-700 ring-1 ring-green-300', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
}
const EXAMPLE_QUESTIONS: Record<AgentMode, string[]> = {
explain: [
'Was ist ein Verarbeitungsverzeichnis?',
'Wann brauche ich eine DSFA?',
'Was sind TOM nach Art. 32 DSGVO?',
],
ask: [
'Welche Luecken hat mein Compliance-Profil?',
'Was fehlt noch fuer die Zertifizierung?',
'Welche Dokumente muss ich noch erstellen?',
],
draft: [
'Erstelle einen VVT-Eintrag fuer unseren Hauptprozess',
'Erstelle TOM fuer unsere Cloud-Infrastruktur',
'Erstelle eine Datenschutzerklaerung',
],
validate: [
'Pruefe die Konsistenz meiner Dokumente',
'Stimmen VVT und TOM ueberein?',
'Gibt es Luecken bei den Loeschfristen?',
],
}
export function DraftingEngineWidget({
currentStep = 'default',
enableDraftingEngine = true,
}: DraftingEngineWidgetProps) {
const { state } = useSDK()
const engine = useDraftingEngine()
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [inputValue, setInputValue] = useState('')
const [showDraftEditor, setShowDraftEditor] = useState(false)
const [showValidationReport, setShowValidationReport] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
// Available document types from scope decision
const availableDocumentTypes: ScopeDocumentType[] =
state.complianceScope?.decision?.requiredDocuments
?.filter(d => d.required)
.map(d => d.documentType as ScopeDocumentType) ?? ['vvt', 'tom', 'lf']
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [engine.messages])
// Open draft editor when a new draft arrives
useEffect(() => {
if (engine.currentDraft) {
setShowDraftEditor(true)
}
}, [engine.currentDraft])
// Open validation report when new results arrive
useEffect(() => {
if (engine.validationResult) {
setShowValidationReport(true)
}
}, [engine.validationResult])
const handleSendMessage = useCallback(
(content: string) => {
if (!content.trim()) return
setInputValue('')
engine.sendMessage(content)
},
[engine]
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage(inputValue)
}
}
const exampleQuestions = EXAMPLE_QUESTIONS[engine.currentMode]
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
aria-label="Drafting Engine oeffnen"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)
}
// Draft Editor full-screen overlay
if (showDraftEditor && engine.currentDraft) {
return (
<DraftEditor
draft={engine.currentDraft}
documentType={engine.activeDocumentType}
constraintCheck={engine.constraintCheck}
onAccept={() => {
engine.acceptDraft()
setShowDraftEditor(false)
}}
onValidate={() => {
engine.validateDraft()
}}
onClose={() => setShowDraftEditor(false)}
onRefine={(instruction: string) => {
engine.requestDraft(instruction)
}}
validationResult={engine.validationResult}
isTyping={engine.isTyping}
/>
)
}
return (
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[420px] h-[560px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<div className="font-semibold text-sm">Drafting Engine</div>
<div className="text-xs text-white/80">Compliance-Dokumententwurf</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-white/80 hover:text-white transition-colors p-1"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isExpanded ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
)}
</svg>
</button>
<button
onClick={() => {
engine.clearMessages()
setIsOpen(false)
}}
className="text-white/80 hover:text-white transition-colors p-1"
aria-label="Schliessen"
>
<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" />
</svg>
</button>
</div>
</div>
{/* Mode Pills */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-gray-100 bg-white">
{(Object.keys(MODE_CONFIG) as AgentMode[]).map((mode) => {
const config = MODE_CONFIG[mode]
const isActive = engine.currentMode === mode
return (
<button
key={mode}
onClick={() => engine.setMode(mode)}
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium transition-all ${isActive ? config.activeColor : config.color} hover:opacity-80`}
>
<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={config.icon} />
</svg>
{config.label}
</button>
)
})}
</div>
{/* Document Type Selector (visible in draft/validate mode) */}
{(engine.currentMode === 'draft' || engine.currentMode === 'validate') && (
<div className="px-3 py-2 border-b border-gray-100 bg-gray-50/50">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 shrink-0">Dokument:</span>
<select
value={engine.activeDocumentType || ''}
onChange={(e) => engine.setDocumentType(e.target.value as ScopeDocumentType)}
className="flex-1 text-xs border border-gray-200 rounded-md px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-purple-400"
>
<option value="">Dokumenttyp waehlen...</option>
{availableDocumentTypes.map((dt) => (
<option key={dt} value={dt}>
{DOCUMENT_TYPE_LABELS[dt] || dt}
</option>
))}
</select>
</div>
</div>
)}
{/* Error Banner */}
{engine.error && (
<div className="mx-3 mt-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-700 flex items-center justify-between">
<span>{engine.error}</span>
<button onClick={() => engine.clearMessages()} className="text-red-500 hover:text-red-700 ml-2">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Validation Report Inline */}
{showValidationReport && engine.validationResult && (
<div className="mx-3 mt-2 max-h-48 overflow-y-auto">
<ValidationReport
result={engine.validationResult}
onClose={() => setShowValidationReport(false)}
compact
/>
</div>
)}
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
{engine.messages.length === 0 ? (
<div className="text-center py-6">
<div className="w-14 h-14 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-3">
<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={MODE_CONFIG[engine.currentMode].icon} />
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 mb-1">
{engine.currentMode === 'explain' && 'Fragen beantworten'}
{engine.currentMode === 'ask' && 'Luecken erkennen'}
{engine.currentMode === 'draft' && 'Dokumente entwerfen'}
{engine.currentMode === 'validate' && 'Konsistenz pruefen'}
</h3>
<p className="text-xs text-gray-500 mb-4">
{engine.currentMode === 'explain' && 'Stellen Sie Fragen zu DSGVO, AI Act und Compliance.'}
{engine.currentMode === 'ask' && 'Identifiziert Luecken in Ihrem Compliance-Profil.'}
{engine.currentMode === 'draft' && 'Erstellt strukturierte Compliance-Dokumente.'}
{engine.currentMode === 'validate' && 'Prueft Cross-Dokument-Konsistenz.'}
</p>
<div className="text-left space-y-2">
<p className="text-xs font-medium text-gray-700 mb-2">Beispiele:</p>
{exampleQuestions.map((q, idx) => (
<button
key={idx}
onClick={() => handleSendMessage(q)}
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
>
{q}
</button>
))}
</div>
{/* Quick Actions for Draft/Validate */}
{engine.currentMode === 'draft' && engine.activeDocumentType && (
<button
onClick={() => engine.requestDraft()}
className="mt-4 px-4 py-2 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Draft fuer {DOCUMENT_TYPE_LABELS[engine.activeDocumentType]?.split(' (')[0] || engine.activeDocumentType} erstellen
</button>
)}
{engine.currentMode === 'validate' && (
<button
onClick={() => engine.validateDraft()}
className="mt-4 px-4 py-2 bg-green-600 text-white text-xs font-medium rounded-lg hover:bg-green-700 transition-colors"
>
Validierung starten
</button>
)}
</div>
) : (
<>
{engine.messages.map((message, idx) => (
<div
key={idx}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 ${
message.role === 'user'
? 'bg-indigo-600 text-white'
: 'bg-white border border-gray-200 text-gray-800'
}`}
>
<p className={`text-sm ${message.role === 'assistant' ? 'whitespace-pre-wrap' : ''}`}>
{message.content}
</p>
{/* Draft ready indicator */}
{message.metadata?.hasDraft && engine.currentDraft && (
<button
onClick={() => setShowDraftEditor(true)}
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-md text-xs text-blue-700 hover:bg-blue-100 transition-colors"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Im Editor oeffnen
</button>
)}
{/* Validation ready indicator */}
{message.metadata?.hasValidation && engine.validationResult && (
<button
onClick={() => setShowValidationReport(true)}
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 bg-green-50 border border-green-200 rounded-md text-xs text-green-700 hover:bg-green-100 transition-colors"
>
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Validierungsbericht anzeigen
</button>
)}
</div>
</div>
))}
{engine.isTyping && (
<div className="flex justify-start">
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
engine.currentMode === 'draft'
? 'Anweisung fuer den Entwurf...'
: engine.currentMode === 'validate'
? 'Validierungsfrage...'
: 'Frage eingeben...'
}
disabled={engine.isTyping}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
/>
{engine.isTyping ? (
<button
onClick={engine.stopGeneration}
className="px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="Generierung stoppen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 6h12v12H6z" />
</svg>
</button>
) : (
<button
onClick={() => handleSendMessage(inputValue)}
disabled={!inputValue.trim()}
className="px-3 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed 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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -363,8 +363,8 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
? 'right-4 bottom-6'
: 'left-4 bottom-6'
return (
<>
@@ -411,7 +411,7 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
{isDesktopCollapsed && (
<button
onClick={toggleDesktopSidebar}
className={`hidden xl:flex fixed right-6 bottom-20 z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all items-center justify-center group`}
className={`hidden xl:flex fixed right-6 bottom-6 z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all items-center justify-center group`}
aria-label="SDK Pipeline Navigation oeffnen"
title="Pipeline anzeigen"
>

View File

@@ -781,6 +781,87 @@ export const STEP_EXPLANATIONS = {
},
],
},
'incidents': {
title: 'Incident Management',
description: 'Erfassen, bewerten und melden Sie Datenschutzverletzungen nach Art. 33/34 DSGVO',
explanation: 'Das Incident Management ermoeglicht die strukturierte Erfassung und Bearbeitung von Datenschutzverletzungen. Es umfasst die Ersterfassung des Vorfalls, eine automatische Risikobewertung zur Bestimmung der Meldepflicht, einen 72-Stunden-Countdown fuer die Meldung an die Aufsichtsbehoerde, die Generierung des Meldeformulars sowie die Dokumentation aller Sofort- und Langfristmassnahmen.',
tips: [
{
icon: 'warning' as const,
title: '72-Stunden-Frist',
description: 'Art. 33 DSGVO: Die Aufsichtsbehoerde muss innerhalb von 72 Stunden nach Bekanntwerden einer meldepflichtigen Datenpanne informiert werden.',
},
{
icon: 'info' as const,
title: 'Risikobewertung',
description: 'Nicht jede Datenpanne ist meldepflichtig. Die Risikobewertung hilft automatisch zu bestimmen, ob eine Meldung an die Aufsichtsbehoerde oder Betroffene erforderlich ist.',
},
{
icon: 'lightbulb' as const,
title: 'Massnahmen dokumentieren',
description: 'Dokumentieren Sie sowohl Sofortmassnahmen (Eindaemmung) als auch langfristige Massnahmen (Praevention). Dies ist fuer Audits essentiell.',
},
{
icon: 'success' as const,
title: 'Lessons Learned',
description: 'Schliessen Sie jeden Vorfall mit einer Ursachenanalyse und Lessons Learned ab, um kuenftige Vorfaelle zu vermeiden.',
},
],
},
'whistleblower': {
title: 'Hinweisgebersystem',
description: 'Anonymes Meldesystem gemaess Hinweisgeberschutzgesetz (HinSchG)',
explanation: 'Das Hinweisgebersystem ermoeglicht anonyme Meldungen von Missstaenden gemaess dem Hinweisgeberschutzgesetz (HinSchG). Unternehmen ab 50 Mitarbeitern sind gesetzlich verpflichtet, einen internen Meldekanal bereitzustellen. Das System bietet ein oeffentliches Meldeformular (ohne Login), einen anonymen Rueckkanal ueber Zugangscodes, Fallmanagement fuer die Ombudsperson und revisionssichere Dokumentation.',
tips: [
{
icon: 'warning' as const,
title: 'Gesetzliche Pflicht',
description: 'Ab 50 Mitarbeitern ist ein interner Meldekanal Pflicht (§ 12 HinSchG). Bussgeld bei Verstoessen: bis zu 50.000 EUR.',
},
{
icon: 'info' as const,
title: '7-Tage-Frist',
description: 'Eingangsbestaetigung muss innerhalb von 7 Tagen erfolgen. Rueckmeldung an den Hinweisgeber innerhalb von 3 Monaten.',
},
{
icon: 'lightbulb' as const,
title: 'Anonymitaet schuetzen',
description: 'Die Identitaet des Hinweisgebers darf nur mit dessen Einwilligung offengelegt werden. Das System unterstuetzt vollstaendig anonyme Meldungen.',
},
{
icon: 'success' as const,
title: 'Massnahmen-Tracking',
description: 'Dokumentieren Sie alle ergriffenen Massnahmen. Dies dient als Nachweis fuer die Aufsichtsbehoerde.',
},
],
},
'academy': {
title: 'Compliance Academy',
description: 'Schulen Sie Ihre Mitarbeiter in Datenschutz, IT-Sicherheit und KI-Kompetenz',
explanation: 'Die Compliance Academy bietet eine integrierte Schulungsplattform fuer Mitarbeiter-Compliance-Trainings. Sie umfasst vorgefertigte Kurse zu DSGVO-Grundlagen, IT-Sicherheit, AI Literacy und Hinweisgeberschutz. Mitarbeiter absolvieren Lektionen mit Videos und Texten, beantworten Quiz-Fragen und erhalten nach erfolgreichem Abschluss ein Zertifikat. Administratoren koennen den Fortschritt aller Mitarbeiter nachverfolgen und Erinnerungen fuer jaehrliche Auffrischungen einrichten.',
tips: [
{
icon: 'warning' as const,
title: 'DSGVO-Schulungspflicht',
description: 'Art. 39 Abs. 1 lit. b DSGVO: Der DSB muss die Sensibilisierung und Schulung der Mitarbeiter sicherstellen. Nachweisbare Schulungen sind Pflicht.',
},
{
icon: 'info' as const,
title: 'Jaehrliche Auffrischung',
description: 'Compliance-Schulungen sollten mindestens jaehrlich wiederholt werden. Das System erinnert automatisch an faellige Auffrischungen.',
},
{
icon: 'lightbulb' as const,
title: 'Zertifikate als Nachweis',
description: 'Jeder abgeschlossene Kurs generiert ein PDF-Zertifikat. Dies dient als Audit-Nachweis fuer die Schulungspflicht.',
},
{
icon: 'success' as const,
title: 'Quiz-Pflicht',
description: 'Nach jeder Lektion muss ein Quiz bestanden werden. So wird sichergestellt, dass die Inhalte verstanden wurden.',
},
],
},
}
export default StepHeader

View File

@@ -0,0 +1,220 @@
'use client'
/**
* ValidationReport - Strukturierte Anzeige von Validierungsergebnissen
*
* Errors (Scope-Violations) in Rot
* Warnings (Inkonsistenzen) in Amber
* Suggestions in Blau
*/
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
import type { ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
interface ValidationReportProps {
result: ValidationResult
onClose: () => void
/** Compact mode for inline display in widget */
compact?: boolean
}
const SEVERITY_CONFIG = {
error: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-700',
icon: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
label: 'Fehler',
dotColor: 'bg-red-500',
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-200',
text: 'text-amber-700',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z',
label: 'Warnungen',
dotColor: 'bg-amber-500',
},
suggestion: {
bg: 'bg-blue-50',
border: 'border-blue-200',
text: 'text-blue-700',
icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
label: 'Vorschlaege',
dotColor: 'bg-blue-500',
},
}
function FindingCard({ finding, compact }: { finding: ValidationFinding; compact?: boolean }) {
const config = SEVERITY_CONFIG[finding.severity]
const docLabel = DOCUMENT_TYPE_LABELS[finding.documentType]?.split(' (')[0] || finding.documentType
if (compact) {
return (
<div className={`flex items-start gap-2 px-2.5 py-1.5 ${config.bg} rounded-md border ${config.border}`}>
<span className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${config.dotColor}`} />
<div className="min-w-0">
<p className={`text-xs font-medium ${config.text}`}>{finding.title}</p>
<p className="text-xs text-gray-500 truncate">{finding.description}</p>
</div>
</div>
)
}
return (
<div className={`${config.bg} rounded-lg border ${config.border} p-3`}>
<div className="flex items-start gap-2">
<svg className={`w-4 h-4 mt-0.5 shrink-0 ${config.text}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={config.icon} />
</svg>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className={`text-sm font-medium ${config.text}`}>{finding.title}</h4>
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">{docLabel}</span>
</div>
<p className="text-xs text-gray-600 mt-1">{finding.description}</p>
{finding.crossReferenceType && (
<p className="text-xs text-gray-500 mt-1">
Cross-Referenz: {DOCUMENT_TYPE_LABELS[finding.crossReferenceType]?.split(' (')[0] || finding.crossReferenceType}
</p>
)}
{finding.legalReference && (
<p className="text-xs text-gray-500 mt-1 font-mono">{finding.legalReference}</p>
)}
{finding.suggestion && (
<div className="mt-2 flex items-start gap-1.5 px-2.5 py-1.5 bg-white/60 rounded border border-gray-100">
<svg className="w-3.5 h-3.5 mt-0.5 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<p className="text-xs text-gray-600">{finding.suggestion}</p>
</div>
)}
</div>
</div>
</div>
)
}
export function ValidationReport({ result, onClose, compact }: ValidationReportProps) {
const totalFindings = result.errors.length + result.warnings.length + result.suggestions.length
if (compact) {
return (
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b border-gray-100">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${result.passed ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-xs font-medium text-gray-700">
{result.passed ? 'Validierung bestanden' : 'Validierung fehlgeschlagen'}
</span>
<span className="text-xs text-gray-400">
({totalFindings} {totalFindings === 1 ? 'Fund' : 'Funde'})
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-2 space-y-1.5 max-h-36 overflow-y-auto">
{result.errors.map((f) => <FindingCard key={f.id} finding={f} compact />)}
{result.warnings.map((f) => <FindingCard key={f.id} finding={f} compact />)}
{result.suggestions.map((f) => <FindingCard key={f.id} finding={f} compact />)}
</div>
</div>
)
}
return (
<div className="space-y-4">
{/* Summary Header */}
<div className={`rounded-lg border p-4 ${result.passed ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${result.passed ? 'bg-green-100' : 'bg-red-100'}`}>
<svg className={`w-5 h-5 ${result.passed ? 'text-green-600' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={result.passed ? 'M5 13l4 4L19 7' : 'M6 18L18 6M6 6l12 12'} />
</svg>
</div>
<div>
<h3 className={`text-sm font-semibold ${result.passed ? 'text-green-800' : 'text-red-800'}`}>
{result.passed ? 'Validierung bestanden' : 'Validierung fehlgeschlagen'}
</h3>
<p className="text-xs text-gray-500">
Level {result.scopeLevel} | {new Date(result.timestamp).toLocaleString('de-DE')}
</p>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-3">
{result.errors.length > 0 && (
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-xs font-medium text-red-700">{result.errors.length}</span>
</div>
)}
{result.warnings.length > 0 && (
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span className="text-xs font-medium text-amber-700">{result.warnings.length}</span>
</div>
)}
{result.suggestions.length > 0 && (
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-xs font-medium text-blue-700">{result.suggestions.length}</span>
</div>
)}
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 ml-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
{/* Errors */}
{result.errors.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-red-700 uppercase tracking-wide mb-2">
Fehler ({result.errors.length})
</h4>
<div className="space-y-2">
{result.errors.map((f) => <FindingCard key={f.id} finding={f} />)}
</div>
</div>
)}
{/* Warnings */}
{result.warnings.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-amber-700 uppercase tracking-wide mb-2">
Warnungen ({result.warnings.length})
</h4>
<div className="space-y-2">
{result.warnings.map((f) => <FindingCard key={f.id} finding={f} />)}
</div>
</div>
)}
{/* Suggestions */}
{result.suggestions.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-blue-700 uppercase tracking-wide mb-2">
Vorschlaege ({result.suggestions.length})
</h4>
<div className="space-y-2">
{result.suggestions.map((f) => <FindingCard key={f.id} finding={f} />)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { useState } from 'react'
import { Certificate } from '@/lib/sdk/academy/types'
import { downloadCertificatePDF } from '@/lib/sdk/academy/api'
interface CertificateViewerProps {
certificate: Certificate
onClose?: () => void
}
export default function CertificateViewer({ certificate, onClose }: CertificateViewerProps) {
const [downloading, setDownloading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleDownloadPDF = async () => {
setDownloading(true)
setError(null)
try {
const blob = await downloadCertificatePDF(certificate.id)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `zertifikat-${certificate.id.slice(0, 8)}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} catch (err) {
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
} finally {
setDownloading(false)
}
}
const issuedDate = new Date(certificate.issuedAt).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
})
const validDate = new Date(certificate.validUntil).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
})
const isExpired = new Date(certificate.validUntil) < new Date()
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{/* Certificate Preview */}
<div className="relative bg-gradient-to-br from-indigo-50 via-white to-purple-50 p-8">
{/* Decorative border */}
<div className="absolute inset-4 border-2 border-indigo-200 rounded-lg pointer-events-none" />
<div className="absolute inset-5 border border-indigo-100 rounded-lg pointer-events-none" />
<div className="relative text-center space-y-4">
{/* Company */}
<p className="text-sm text-gray-400 tracking-widest uppercase">BreakPilot Compliance</p>
{/* Title */}
<h2 className="text-2xl font-bold text-gray-900 tracking-wide">SCHULUNGSZERTIFIKAT</h2>
{/* Decorative line */}
<div className="mx-auto w-24 h-0.5 bg-indigo-500" />
{/* Body */}
<p className="text-sm text-gray-500">Hiermit wird bescheinigt, dass</p>
<p className="text-xl font-bold text-gray-900">{certificate.userName}</p>
<p className="text-sm text-gray-500">die folgende Compliance-Schulung erfolgreich abgeschlossen hat:</p>
<p className="text-lg font-semibold text-indigo-600">{certificate.courseName}</p>
{/* Score */}
{certificate.score > 0 && (
<p className="text-sm text-gray-500">
Testergebnis: <span className="font-semibold text-gray-700">{certificate.score}%</span>
</p>
)}
{/* Dates */}
<div className="flex justify-between items-center px-8 pt-4 text-xs text-gray-400">
<span>Abschlussdatum: {issuedDate}</span>
<span className={isExpired ? 'text-red-500 font-medium' : ''}>
Gueltig bis: {validDate}
{isExpired && ' (abgelaufen)'}
</span>
</div>
{/* Certificate ID */}
<p className="text-xs text-gray-300">
Zertifikats-Nr.: {certificate.id.slice(0, 12)}
</p>
</div>
</div>
{/* Actions */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<div className="text-xs text-gray-400">
Elektronisch erstellt - ohne Unterschrift gueltig
</div>
<div className="flex gap-3">
{onClose && (
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
Schliessen
</button>
)}
<button
onClick={handleDownloadPDF}
disabled={downloading}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 rounded-lg transition-colors flex items-center gap-2"
>
{downloading ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Wird erstellt...
</>
) : (
<>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
</div>
{/* Error */}
{error && (
<div className="px-6 py-3 bg-red-50 border-t border-red-200 text-sm text-red-600">
{error}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,413 @@
'use client'
import { useState, useEffect } from 'react'
interface AuditLogEntry {
id: string
action: string
entity_type: string
entity_id?: string
old_value?: any
new_value?: any
user_email?: string
created_at: string
}
interface BlockedContentEntry {
id: string
url: string
domain: string
block_reason: string
rule_id?: string
details?: any
created_at: string
}
interface AuditTabProps {
apiBase: string
}
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
create: { label: 'Erstellt', color: 'bg-green-100 text-green-700' },
update: { label: 'Aktualisiert', color: 'bg-blue-100 text-blue-700' },
delete: { label: 'Geloescht', color: 'bg-red-100 text-red-700' },
}
const ENTITY_LABELS: Record<string, string> = {
source_policy: 'Policy',
allowed_source: 'Quelle',
operation_permission: 'Operation',
pii_rule: 'PII-Regel',
}
const BLOCK_REASON_LABELS: Record<string, { label: string; color: string }> = {
not_whitelisted: { label: 'Nicht in Whitelist', color: 'bg-amber-100 text-amber-700' },
pii_detected: { label: 'PII erkannt', color: 'bg-red-100 text-red-700' },
license_violation: { label: 'Lizenzverletzung', color: 'bg-orange-100 text-orange-700' },
training_forbidden: { label: 'Training verboten', color: 'bg-slate-800 text-white' },
}
export function AuditTab({ apiBase }: AuditTabProps) {
const [activeView, setActiveView] = useState<'changes' | 'blocked'>('changes')
// Audit logs
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([])
const [auditLoading, setAuditLoading] = useState(true)
const [auditTotal, setAuditTotal] = useState(0)
// Blocked content
const [blockedContent, setBlockedContent] = useState<BlockedContentEntry[]>([])
const [blockedLoading, setBlockedLoading] = useState(true)
const [blockedTotal, setBlockedTotal] = useState(0)
// Filters
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [entityFilter, setEntityFilter] = useState('')
// Export
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (activeView === 'changes') {
fetchAuditLogs()
} else {
fetchBlockedContent()
}
}, [activeView, dateFrom, dateTo, entityFilter])
const fetchAuditLogs = async () => {
try {
setAuditLoading(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
if (entityFilter) params.append('entity_type', entityFilter)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/policy-audit?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setAuditLogs(data.logs || [])
setAuditTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setAuditLoading(false)
}
}
const fetchBlockedContent = async () => {
try {
setBlockedLoading(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/blocked-content?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setBlockedContent(data.blocked || [])
setBlockedTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setBlockedLoading(false)
}
}
const exportReport = async () => {
try {
setExporting(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
params.append('format', 'download')
const res = await fetch(`${apiBase}/v1/admin/compliance-report?${params}`)
if (!res.ok) throw new Error('Fehler beim Export')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `compliance-report-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setExporting(false)
}
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* View Toggle & Filters */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex gap-2">
<button
onClick={() => setActiveView('changes')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeView === 'changes'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Aenderungshistorie
</button>
<button
onClick={() => setActiveView('blocked')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeView === 'blocked'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Blockierte URLs
</button>
</div>
<div className="flex-1 flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2">
<label className="text-sm text-slate-600">Von:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-slate-600">Bis:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
{activeView === 'changes' && (
<select
value={entityFilter}
onChange={(e) => setEntityFilter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
>
<option value="">Alle Typen</option>
<option value="source_policy">Policies</option>
<option value="allowed_source">Quellen</option>
<option value="operation_permission">Operations</option>
<option value="pii_rule">PII-Regeln</option>
</select>
)}
<button
onClick={exportReport}
disabled={exporting}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2 ml-auto"
>
<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>
{exporting ? 'Exportiere...' : 'JSON Export'}
</button>
</div>
</div>
{/* Changes View */}
{activeView === 'changes' && (
<>
{auditLoading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
) : auditLogs.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Eintraege vorhanden</h3>
<p className="text-sm text-slate-500">
Aenderungen werden hier protokolliert.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 text-sm text-slate-600">
{auditTotal} Eintraege gesamt
</div>
<div className="divide-y divide-slate-100">
{auditLogs.map((log) => {
const actionConfig = ACTION_LABELS[log.action] || { label: log.action, color: 'bg-slate-100 text-slate-700' }
return (
<div key={log.id} className="px-4 py-4 hover:bg-slate-50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${actionConfig.color}`}>
{actionConfig.label}
</span>
<span className="text-sm text-slate-700">
{ENTITY_LABELS[log.entity_type] || log.entity_type}
</span>
{log.entity_id && (
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-500">
{log.entity_id.substring(0, 8)}...
</code>
)}
</div>
<div className="text-xs text-slate-500">
{formatDate(log.created_at)}
</div>
</div>
{log.user_email && (
<div className="mt-2 text-xs text-slate-500">
Benutzer: {log.user_email}
</div>
)}
{(log.old_value || log.new_value) && (
<div className="mt-2 flex gap-4 text-xs">
{log.old_value && (
<div className="flex-1 p-2 bg-red-50 rounded">
<div className="text-red-600 font-medium mb-1">Vorher:</div>
<pre className="text-red-700 overflow-x-auto">
{typeof log.old_value === 'string' ? log.old_value : JSON.stringify(log.old_value, null, 2)}
</pre>
</div>
)}
{log.new_value && (
<div className="flex-1 p-2 bg-green-50 rounded">
<div className="text-green-600 font-medium mb-1">Nachher:</div>
<pre className="text-green-700 overflow-x-auto">
{typeof log.new_value === 'string' ? log.new_value : JSON.stringify(log.new_value, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</>
)}
{/* Blocked Content View */}
{activeView === 'blocked' && (
<>
{blockedLoading ? (
<div className="text-center py-12 text-slate-500">Lade blockierte URLs...</div>
) : blockedContent.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-green-300 mb-4" 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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine blockierten URLs</h3>
<p className="text-sm text-slate-500">
Alle gecrawlten URLs waren in der Whitelist.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 text-sm text-slate-600">
{blockedTotal} blockierte URLs gesamt
</div>
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">URL</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Grund</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Zeitpunkt</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{blockedContent.map((entry) => {
const reasonConfig = BLOCK_REASON_LABELS[entry.block_reason] || {
label: entry.block_reason,
color: 'bg-slate-100 text-slate-700',
}
return (
<tr key={entry.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="text-sm text-slate-700 truncate max-w-md" title={entry.url}>
{entry.url}
</div>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded">{entry.domain}</code>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${reasonConfig.color}`}>
{reasonConfig.label}
</span>
</td>
<td className="px-4 py-3 text-xs text-slate-500">
{formatDate(entry.created_at)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
{/* Auditor Info Box */}
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-blue-800">Fuer Auditoren</h3>
<p className="text-sm text-blue-700 mt-1">
Dieses Audit-Log ist unveraenderlich und protokolliert alle Aenderungen an der Quellen-Policy.
Jeder Eintrag enthaelt:
</p>
<ul className="text-sm text-blue-700 mt-2 list-disc list-inside">
<li>Zeitstempel der Aenderung</li>
<li>Art der Aenderung (Erstellen/Aendern/Loeschen)</li>
<li>Betroffene Entitaet und ID</li>
<li>Vorheriger und neuer Wert</li>
<li>E-Mail des Benutzers (falls angemeldet)</li>
</ul>
<p className="text-sm text-blue-600 mt-2 font-medium">
Der JSON-Export ist fuer die externe Pruefung und Archivierung geeignet.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,271 @@
'use client'
import { useState, useEffect } from 'react'
interface OperationPermission {
id: string
source_id: string
operation: string
is_allowed: boolean
requires_citation: boolean
notes?: string
}
interface SourceWithOperations {
id: string
domain: string
name: string
license: string
is_active: boolean
operations: OperationPermission[]
}
interface OperationsMatrixTabProps {
apiBase: string
}
const OPERATIONS = [
{ id: 'lookup', name: 'Lookup', description: 'Inhalt anzeigen/durchsuchen', icon: '🔍' },
{ id: 'rag', name: 'RAG', description: 'Retrieval Augmented Generation', icon: '🤖' },
{ id: 'training', name: 'Training', description: 'KI-Training (VERBOTEN)', icon: '🚫' },
{ id: 'export', name: 'Export', description: 'Daten exportieren', icon: '📤' },
]
export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
const [sources, setSources] = useState<SourceWithOperations[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updating, setUpdating] = useState<string | null>(null)
useEffect(() => {
fetchMatrix()
}, [])
const fetchMatrix = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/operations-matrix`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setSources(data.sources || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const togglePermission = async (
source: SourceWithOperations,
operationId: string,
field: 'is_allowed' | 'requires_citation'
) => {
// Find the permission
const permission = source.operations.find((op) => op.operation === operationId)
if (!permission) return
// Block enabling training
if (operationId === 'training' && field === 'is_allowed' && !permission.is_allowed) {
setError('Training mit externen Daten ist VERBOTEN und kann nicht aktiviert werden.')
return
}
const updateId = `${permission.id}-${field}`
setUpdating(updateId)
try {
const newValue = !permission[field]
const res = await fetch(`${apiBase}/v1/admin/operations/${permission.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: newValue }),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.message || errData.error || 'Fehler beim Aktualisieren')
}
fetchMatrix()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setUpdating(null)
}
}
if (loading) {
return <div className="text-center py-12 text-slate-500">Lade Operations-Matrix...</div>
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Legend */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<h3 className="font-medium text-slate-900 mb-3">Legende</h3>
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-green-100 text-green-700 rounded">
<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>
</span>
<span className="text-slate-600">Erlaubt</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 rounded">
<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>
</span>
<span className="text-slate-600">Verboten</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-amber-100 text-amber-700 rounded text-xs">
Cite
</span>
<span className="text-slate-600">Zitation erforderlich</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-slate-800 text-white rounded">
<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>
</span>
<span className="text-slate-600">System-gesperrt (Training)</span>
</div>
</div>
</div>
{/* Matrix Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-x-auto">
<table className="w-full min-w-[800px]">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Quelle</th>
{OPERATIONS.map((op) => (
<th key={op.id} className="text-center px-4 py-3">
<div className="flex flex-col items-center gap-1">
<span className="text-lg">{op.icon}</span>
<span className="text-xs font-medium text-slate-500 uppercase">{op.name}</span>
<span className="text-xs text-slate-400 font-normal">{op.description}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sources.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Keine Quellen vorhanden
</td>
</tr>
) : (
sources.map((source) => (
<tr key={source.id} className={`hover:bg-slate-50 ${!source.is_active ? 'opacity-50' : ''}`}>
<td className="px-4 py-3">
<div>
<div className="font-medium text-slate-800">{source.name}</div>
<code className="text-xs text-slate-500">{source.domain}</code>
</div>
</td>
{OPERATIONS.map((op) => {
const permission = source.operations.find((p) => p.operation === op.id)
const isTraining = op.id === 'training'
const isAllowed = permission?.is_allowed ?? false
const requiresCitation = permission?.requires_citation ?? false
const isUpdating = updating === `${permission?.id}-is_allowed` || updating === `${permission?.id}-requires_citation`
return (
<td key={op.id} className="px-4 py-3 text-center">
<div className="flex flex-col items-center gap-2">
{/* Is Allowed Toggle */}
<button
onClick={() => togglePermission(source, op.id, 'is_allowed')}
disabled={isTraining || isUpdating || !source.is_active}
className={`w-10 h-10 flex items-center justify-center rounded transition-colors ${
isTraining
? 'bg-slate-800 text-white cursor-not-allowed'
: isAllowed
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-red-100 text-red-700 hover:bg-red-200'
} ${isUpdating ? 'opacity-50' : ''}`}
title={isTraining ? 'Training ist system-weit gesperrt' : isAllowed ? 'Klicken zum Deaktivieren' : 'Klicken zum Aktivieren'}
>
{isTraining ? (
<svg className="w-5 h-5" 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>
) : isAllowed ? (
<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>
) : (
<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" />
</svg>
)}
</button>
{/* Citation Required Toggle (only for allowed non-training ops) */}
{isAllowed && !isTraining && (
<button
onClick={() => togglePermission(source, op.id, 'requires_citation')}
disabled={isUpdating || !source.is_active}
className={`px-2 py-1 text-xs rounded transition-colors ${
requiresCitation
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
} ${isUpdating ? 'opacity-50' : ''}`}
title={requiresCitation ? 'Zitation erforderlich - Klicken zum Aendern' : 'Klicken um Zitation zu erfordern'}
>
{requiresCitation ? 'Cite ✓' : 'Cite'}
</button>
)}
</div>
</td>
)
})}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Training Warning */}
<div className="mt-6 bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-red-100 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 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>
</div>
<div>
<h3 className="font-semibold text-red-800">Training-Operation: System-gesperrt</h3>
<p className="text-sm text-red-700 mt-1">
Das Training von KI-Modellen mit gecrawlten externen Daten ist aufgrund von Urheberrechts- und
Datenschutzbestimmungen grundsaetzlich verboten. Diese Einschraenkung ist im System hart kodiert
und kann nicht ueber diese Oberflaeche geaendert werden.
</p>
<p className="text-sm text-red-600 mt-2 font-medium">
Ausnahmen erfordern eine schriftliche Genehmigung des DSB und eine rechtliche Pruefung.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,562 @@
'use client'
import { useState, useEffect } from 'react'
interface PIIRule {
id: string
name: string
rule_type: string
pattern: string
severity: string
is_active: boolean
created_at: string
updated_at: string
}
interface PIIMatch {
rule_id: string
rule_name: string
rule_type: string
severity: string
match: string
start_index: number
end_index: number
}
interface PIITestResult {
has_pii: boolean
matches: PIIMatch[]
should_block: boolean
block_level: string
}
interface PIIRulesTabProps {
apiBase: string
onUpdate?: () => void
}
const RULE_TYPES = [
{ value: 'regex', label: 'Regex (Muster)' },
{ value: 'keyword', label: 'Keyword (Stichwort)' },
]
const SEVERITIES = [
{ value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' },
{ value: 'redact', label: 'Schwärzen', color: 'bg-orange-100 text-orange-700' },
{ value: 'block', label: 'Blockieren', color: 'bg-red-100 text-red-700' },
]
export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const [rules, setRules] = useState<PIIRule[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Test panel
const [testText, setTestText] = useState('')
const [testResult, setTestResult] = useState<PIITestResult | null>(null)
const [testing, setTesting] = useState(false)
// Edit modal
const [editingRule, setEditingRule] = useState<PIIRule | null>(null)
const [isNewRule, setIsNewRule] = useState(false)
const [saving, setSaving] = useState(false)
// New rule form
const [newRule, setNewRule] = useState({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
})
useEffect(() => {
fetchRules()
}, [])
const fetchRules = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setRules(data.rules || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createRule = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newRule),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewRule({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
})
setIsNewRule(false)
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const updateRule = async () => {
if (!editingRule) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${editingRule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingRule(null)
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const deleteRule = async (id: string) => {
if (!confirm('Regel wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const toggleRuleStatus = async (rule: PIIRule) => {
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const runTest = async () => {
if (!testText) return
try {
setTesting(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText }),
})
if (!res.ok) throw new Error('Fehler beim Testen')
const data = await res.json()
setTestResult(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setTesting(false)
}
}
const getSeverityBadge = (severity: string) => {
const config = SEVERITIES.find((s) => s.value === severity)
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}>
{config?.label || severity}
</span>
)
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Test Panel */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<h3 className="font-semibold text-slate-900 mb-4">PII-Test</h3>
<p className="text-sm text-slate-600 mb-4">
Testen Sie, ob ein Text personenbezogene Daten (PII) enthaelt.
</p>
<textarea
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder="Geben Sie hier einen Text zum Testen ein...
Beispiel:
Kontaktieren Sie mich unter max.mustermann@example.com oder
rufen Sie mich an unter +49 170 1234567.
Meine IBAN ist DE89 3704 0044 0532 0130 00."
rows={6}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
<div className="flex justify-between items-center mt-4">
<button
onClick={() => {
setTestText('')
setTestResult(null)
}}
className="text-sm text-slate-500 hover:text-slate-700"
>
Zuruecksetzen
</button>
<button
onClick={runTest}
disabled={testing || !testText}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{testing ? 'Teste...' : 'Testen'}
</button>
</div>
{/* Test Results */}
{testResult && (
<div className={`mt-4 p-4 rounded-lg ${testResult.should_block ? 'bg-red-50 border border-red-200' : testResult.has_pii ? 'bg-amber-50 border border-amber-200' : 'bg-green-50 border border-green-200'}`}>
<div className="flex items-center gap-2 mb-3">
{testResult.should_block ? (
<>
<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>
<span className="font-medium text-red-800">Blockiert - Kritische PII gefunden</span>
</>
) : testResult.has_pii ? (
<>
<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>
<span className="font-medium text-amber-800">Warnung - PII gefunden ({testResult.matches.length} Treffer)</span>
</>
) : (
<>
<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>
<span className="font-medium text-green-800">Keine PII gefunden</span>
</>
)}
</div>
{testResult.matches.length > 0 && (
<div className="space-y-2">
{testResult.matches.map((match, idx) => (
<div key={idx} className="flex items-center gap-3 text-sm bg-white bg-opacity-50 rounded px-3 py-2">
{getSeverityBadge(match.severity)}
<span className="text-slate-700 font-medium">{match.rule_name}</span>
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
{match.match.length > 30 ? match.match.substring(0, 30) + '...' : match.match}
</code>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Rules List Header */}
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
<button
onClick={() => setIsNewRule(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Regel
</button>
</div>
{/* Rules Table */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Regeln...</div>
) : rules.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Regeln vorhanden</h3>
<p className="text-sm text-slate-500">
Fuegen Sie PII-Erkennungsregeln hinzu.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Muster</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Severity</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rules.map((rule) => (
<tr key={rule.id} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm font-medium text-slate-800">{rule.name}</td>
<td className="px-4 py-3">
<span className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded">
{rule.rule_type}
</span>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded max-w-xs truncate block">
{rule.pattern.length > 40 ? rule.pattern.substring(0, 40) + '...' : rule.pattern}
</code>
</td>
<td className="px-4 py-3">{getSeverityBadge(rule.severity)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggleRuleStatus(rule)}
className={`text-xs px-2 py-1 rounded ${
rule.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{rule.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingRule(rule)}
className="text-purple-600 hover:text-purple-700 mr-3"
>
Bearbeiten
</button>
<button
onClick={() => deleteRule(rule.id)}
className="text-red-600 hover:text-red-700"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Rule Modal */}
{isNewRule && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue PII-Regel</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newRule.name}
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
placeholder="z.B. Deutsche Telefonnummern"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ *</label>
<select
value={newRule.rule_type}
onChange={(e) => setNewRule({ ...newRule, rule_type: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
<textarea
value={newRule.pattern}
onChange={(e) => setNewRule({ ...newRule, pattern: e.target.value })}
placeholder={newRule.rule_type === 'regex' ? 'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...' : 'Keywords getrennt durch Komma, z.B. password,secret,api_key'}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity *</label>
<select
value={newRule.severity}
onChange={(e) => setNewRule({ ...newRule, severity: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setIsNewRule(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={createRule}
disabled={saving || !newRule.name || !newRule.pattern}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Rule Modal */}
{editingRule && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">PII-Regel bearbeiten</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={editingRule.name}
onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={editingRule.rule_type}
onChange={(e) => setEditingRule({ ...editingRule, rule_type: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
<textarea
value={editingRule.pattern}
onChange={(e) => setEditingRule({ ...editingRule, pattern: e.target.value })}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity</label>
<select
value={editingRule.severity}
onChange={(e) => setEditingRule({ ...editingRule, severity: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="edit_is_active"
checked={editingRule.is_active}
onChange={(e) => setEditingRule({ ...editingRule, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="edit_is_active" className="text-sm text-slate-700">
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setEditingRule(null)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={updateRule}
disabled={saving}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,525 @@
'use client'
import { useState, useEffect } from 'react'
interface AllowedSource {
id: string
policy_id: string
domain: string
name: string
license: string
legal_basis?: string
citation_template?: string
trust_boost: number
is_active: boolean
created_at: string
updated_at: string
}
interface SourcesTabProps {
apiBase: string
onUpdate?: () => void
}
const LICENSES = [
{ value: 'DL-DE-BY-2.0', label: 'Datenlizenz Deutschland' },
{ value: 'CC-BY', label: 'Creative Commons BY' },
{ value: 'CC-BY-SA', label: 'Creative Commons BY-SA' },
{ value: 'CC0', label: 'Public Domain' },
{ value: '§5 UrhG', label: 'Amtliche Werke (§5 UrhG)' },
]
const BUNDESLAENDER = [
{ value: '', label: 'Bundesebene' },
{ value: 'NI', label: 'Niedersachsen' },
{ value: 'BY', label: 'Bayern' },
{ value: 'BW', label: 'Baden-Wuerttemberg' },
{ value: 'NW', label: 'Nordrhein-Westfalen' },
{ value: 'HE', label: 'Hessen' },
{ value: 'SN', label: 'Sachsen' },
{ value: 'BE', label: 'Berlin' },
{ value: 'HH', label: 'Hamburg' },
]
export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const [sources, setSources] = useState<AllowedSource[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [searchTerm, setSearchTerm] = useState('')
const [licenseFilter, setLicenseFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
// Edit modal
const [editingSource, setEditingSource] = useState<AllowedSource | null>(null)
const [isNewSource, setIsNewSource] = useState(false)
const [saving, setSaving] = useState(false)
// New source form
const [newSource, setNewSource] = useState({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '', // Will be set from policies
})
useEffect(() => {
fetchSources()
}, [licenseFilter, statusFilter])
const fetchSources = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (licenseFilter) params.append('license', licenseFilter)
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false')
const res = await fetch(`${apiBase}/v1/admin/sources?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setSources(data.sources || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createSource = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSource),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewSource({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '',
})
setIsNewSource(false)
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const updateSource = async () => {
if (!editingSource) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources/${editingSource.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingSource),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingSource(null)
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const deleteSource = async (id: string) => {
if (!confirm('Quelle wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const toggleSourceStatus = async (source: AllowedSource) => {
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${source.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !source.is_active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const filteredSources = sources.filter((source) => {
if (searchTerm) {
const term = searchTerm.toLowerCase()
if (!source.domain.toLowerCase().includes(term) && !source.name.toLowerCase().includes(term)) {
return false
}
}
return true
})
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Filters & Actions */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Domain oder Name suchen..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<select
value={licenseFilter}
onChange={(e) => setLicenseFilter(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Alle Lizenzen</option>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="all">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<button
onClick={() => setIsNewSource(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Quelle
</button>
</div>
{/* Sources Table */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Quellen...</div>
) : filteredSources.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Quellen gefunden</h3>
<p className="text-sm text-slate-500">
Fuegen Sie neue Quellen zur Whitelist hinzu.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Lizenz</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Trust</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredSources.map((source) => (
<tr key={source.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<code className="text-sm bg-slate-100 px-2 py-1 rounded">{source.domain}</code>
</td>
<td className="px-4 py-3 text-sm text-slate-700">{source.name}</td>
<td className="px-4 py-3">
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
{source.license}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{(source.trust_boost * 100).toFixed(0)}%
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleSourceStatus(source)}
className={`text-xs px-2 py-1 rounded ${
source.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{source.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingSource(source)}
className="text-purple-600 hover:text-purple-700 mr-3"
>
Bearbeiten
</button>
<button
onClick={() => deleteSource(source.id)}
className="text-red-600 hover:text-red-700"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Source Modal */}
{isNewSource && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue Quelle hinzufuegen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Domain *</label>
<input
type="text"
value={newSource.domain}
onChange={(e) => setNewSource({ ...newSource, domain: e.target.value })}
placeholder="z.B. nibis.de"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newSource.name}
onChange={(e) => setNewSource({ ...newSource, name: e.target.value })}
placeholder="z.B. NiBiS Bildungsserver"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Lizenz *</label>
<select
value={newSource.license}
onChange={(e) => setNewSource({ ...newSource, license: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<input
type="text"
value={newSource.legal_basis}
onChange={(e) => setNewSource({ ...newSource, legal_basis: e.target.value })}
placeholder="z.B. §5 UrhG (Amtliche Werke)"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={newSource.trust_boost}
onChange={(e) => setNewSource({ ...newSource, trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(newSource.trust_boost * 100).toFixed(0)}%
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setIsNewSource(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={createSource}
disabled={saving || !newSource.domain || !newSource.name}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Source Modal */}
{editingSource && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quelle bearbeiten</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Domain</label>
<input
type="text"
value={editingSource.domain}
disabled
className="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-50 text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={editingSource.name}
onChange={(e) => setEditingSource({ ...editingSource, name: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Lizenz *</label>
<select
value={editingSource.license}
onChange={(e) => setEditingSource({ ...editingSource, license: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<input
type="text"
value={editingSource.legal_basis || ''}
onChange={(e) => setEditingSource({ ...editingSource, legal_basis: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitiervorlage</label>
<input
type="text"
value={editingSource.citation_template || ''}
onChange={(e) => setEditingSource({ ...editingSource, citation_template: e.target.value })}
placeholder="Quelle: {source}, {title}, {date}"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={editingSource.trust_boost}
onChange={(e) => setEditingSource({ ...editingSource, trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(editingSource.trust_boost * 100).toFixed(0)}%
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={editingSource.is_active}
onChange={(e) => setEditingSource({ ...editingSource, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="is_active" className="text-sm text-slate-700">
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setEditingSource(null)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={updateSource}
disabled={saving}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}