feat: 7 Vorbereitungs-Module auf 100% — Frontend, Proxy-Routen, Backend-Fixes
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 35s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 19s

Profil: machineBuilder-Felder im POST-Body, PATCH-Handler
Scope: API-Route (GET/POST), ScopeDecisionTab Props + Buttons, Export-Druckansicht HTML
Anwendung: PUT-Handler, Bearbeiten-Button, Pagination/Search
Import: Verlauf laden, DELETE-Route, Offline-Badge, ObjectURL Memory-Leak fix
Screening: Security-Backlog Button verdrahtet, Scan-Verlauf
Module: Detail-Seite, GET-Proxy, Konfigurieren-Button, Modul-erstellen-Modal, Error-Toast
Quellen: 10 Proxy-Routen, Tab-Komponenten umgestellt, Dashboard-Tab, blocked_today Bug fix, Datum-Filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-02 15:08:13 +01:00
parent fc83ebfd82
commit d079886819
32 changed files with 1734 additions and 76 deletions

View File

@@ -1248,6 +1248,33 @@ export default function CompanyProfilePage() {
dpo_name: formData.dpoName || '',
dpo_email: formData.dpoEmail || '',
is_complete: true,
// Machine builder fields (if applicable)
...(formData.machineBuilder ? {
machine_builder: {
product_types: formData.machineBuilder.productTypes || [],
product_description: formData.machineBuilder.productDescription || '',
product_pride: formData.machineBuilder.productPride || '',
contains_software: formData.machineBuilder.containsSoftware || false,
contains_firmware: formData.machineBuilder.containsFirmware || false,
contains_ai: formData.machineBuilder.containsAI || false,
ai_integration_type: formData.machineBuilder.aiIntegrationType || [],
has_safety_function: formData.machineBuilder.hasSafetyFunction || false,
safety_function_description: formData.machineBuilder.safetyFunctionDescription || '',
autonomous_behavior: formData.machineBuilder.autonomousBehavior || false,
human_oversight_level: formData.machineBuilder.humanOversightLevel || 'full',
is_networked: formData.machineBuilder.isNetworked || false,
has_remote_access: formData.machineBuilder.hasRemoteAccess || false,
has_ota_updates: formData.machineBuilder.hasOTAUpdates || false,
update_mechanism: formData.machineBuilder.updateMechanism || '',
export_markets: formData.machineBuilder.exportMarkets || [],
critical_sector_clients: formData.machineBuilder.criticalSectorClients || false,
critical_sectors: formData.machineBuilder.criticalSectors || [],
oem_clients: formData.machineBuilder.oemClients || false,
ce_marking_required: formData.machineBuilder.ceMarkingRequired || false,
existing_ce_process: formData.machineBuilder.existingCEProcess || false,
has_risk_assessment: formData.machineBuilder.hasRiskAssessment || false,
},
} : {}),
}),
})
} catch (err) {

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import type { ImportedDocument, ImportedDocumentType, GapAnalysis, GapItem } from '@/lib/sdk/types'
@@ -216,7 +216,15 @@ function FileItem({
<span className="text-sm">Analysiere...</span>
</div>
)}
{file.status === 'complete' && (
{file.status === 'complete' && file.error === 'offline' && (
<div className="flex items-center gap-1 text-amber-600">
<svg className="w-5 h-5" 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="text-sm">Offline nicht analysiert</span>
</div>
)}
{file.status === 'complete' && file.error !== 'offline' && (
<div className="flex items-center gap-1 text-green-600">
<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" />
@@ -334,6 +342,42 @@ export default function ImportPage() {
const [files, setFiles] = useState<UploadedFile[]>([])
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [analysisResult, setAnalysisResult] = useState<GapAnalysis | null>(null)
const [importHistory, setImportHistory] = useState<any[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [objectUrls, setObjectUrls] = useState<string[]>([])
// 4.1: Load import history
useEffect(() => {
const loadHistory = async () => {
setHistoryLoading(true)
try {
const response = await fetch('/api/sdk/v1/import?tenant_id=default')
if (response.ok) {
const data = await response.json()
setImportHistory(Array.isArray(data) ? data : data.items || [])
}
} catch (err) {
console.error('Failed to load import history:', err)
} finally {
setHistoryLoading(false)
}
}
loadHistory()
}, [analysisResult])
// 4.4: Cleanup ObjectURLs on unmount
useEffect(() => {
return () => {
objectUrls.forEach(url => URL.revokeObjectURL(url))
}
}, [objectUrls])
// Helper to create and track ObjectURLs
const createTrackedObjectURL = useCallback((file: File) => {
const url = URL.createObjectURL(file)
setObjectUrls(prev => [...prev, url])
return url
}, [])
const handleFilesAdded = useCallback((newFiles: File[]) => {
const uploadedFiles: UploadedFile[] = newFiles.map(file => ({
@@ -391,7 +435,7 @@ export default function ImportPage() {
id: result.document_id || file.id,
name: file.file.name,
type: result.detected_type || file.type,
fileUrl: URL.createObjectURL(file.file),
fileUrl: createTrackedObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
@@ -430,7 +474,7 @@ export default function ImportPage() {
id: file.id,
name: file.file.name,
type: file.type,
fileUrl: URL.createObjectURL(file.file),
fileUrl: createTrackedObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
@@ -442,7 +486,7 @@ export default function ImportPage() {
},
}
addImportedDocument(doc)
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const } : f)))
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const, error: 'offline' } : f)))
}
}
@@ -565,6 +609,56 @@ export default function ImportPage() {
</button>
</div>
)}
{/* Import-Verlauf (4.1) */}
{importHistory.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Import-Verlauf</h3>
<p className="text-sm text-gray-500">{importHistory.length} fruehere Imports</p>
</div>
<div className="divide-y divide-gray-100">
{importHistory.map((item: any, idx: number) => (
<div key={item.id || idx} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-gray-500" 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>
</div>
<div>
<p className="text-sm font-medium text-gray-900">{item.name || item.filename || `Import #${idx + 1}`}</p>
<p className="text-xs text-gray-500">
{item.document_type || item.type || 'Unbekannt'} {item.uploaded_at ? new Date(item.uploaded_at).toLocaleString('de-DE') : 'Unbekannt'}
</p>
</div>
</div>
<button
onClick={async () => {
try {
const res = await fetch(`/api/sdk/v1/import/${item.id}`, { method: 'DELETE' })
if (res.ok) {
setImportHistory(prev => prev.filter(h => h.id !== item.id))
}
} catch (err) {
console.error('Failed to delete import:', err)
}
}}
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
title="Import loeschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
{historyLoading && (
<div className="text-center py-4 text-sm text-gray-500">Import-Verlauf wird geladen...</div>
)}
</div>
)
}

View File

@@ -0,0 +1,209 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
interface ModuleDetail {
id: string
name: string
display_name: string
description: string
is_active: boolean
criticality: string
processes_pii: boolean
ai_components: boolean
compliance_score: number | null
regulation_count: number
risk_count: number
requirements?: Array<{
id: string
title: string
status: string
regulation: string
}>
controls?: Array<{
id: string
title: string
status: string
description: string
}>
regulations?: string[]
}
export default function ModuleDetailPage() {
const params = useParams()
const router = useRouter()
const moduleId = params.moduleId as string
const [module, setModule] = useState<ModuleDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function load() {
try {
const response = await fetch(`/api/sdk/v1/modules/${encodeURIComponent(moduleId)}`)
if (!response.ok) {
throw new Error('Modul nicht gefunden')
}
const data = await response.json()
setModule(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
if (moduleId) load()
}, [moduleId])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Lade Modul-Details...</div>
</div>
)
}
if (error || !module) {
return (
<div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<h3 className="text-lg font-semibold text-red-800">Fehler</h3>
<p className="text-red-600 mt-1">{error || 'Modul nicht gefunden'}</p>
</div>
<Link href="/sdk/modules" className="text-purple-600 hover:text-purple-700">
Zurueck zur Uebersicht
</Link>
</div>
)
}
const criticalityColors: Record<string, string> = {
HIGH: 'bg-red-100 text-red-700',
MEDIUM: 'bg-yellow-100 text-yellow-700',
LOW: 'bg-green-100 text-green-700',
}
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-500">
<Link href="/sdk/modules" className="hover:text-purple-600">Module</Link>
<span>/</span>
<span className="text-gray-900">{module.display_name || module.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{module.display_name || module.name}</h1>
<p className="text-gray-500 mt-1">{module.description}</p>
<div className="flex items-center gap-3 mt-3">
<span className={`px-2 py-1 text-xs rounded-full ${module.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{module.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${criticalityColors[module.criticality?.toUpperCase()] || 'bg-gray-100 text-gray-700'}`}>
{module.criticality}
</span>
{module.processes_pii && (
<span className="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-700">PII</span>
)}
{module.ai_components && (
<span className="px-2 py-1 text-xs rounded-full bg-indigo-100 text-indigo-700">KI</span>
)}
</div>
</div>
<button
onClick={() => router.push('/sdk/modules')}
className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Zurueck
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Compliance Score</div>
<div className="text-3xl font-bold text-purple-600">
{module.compliance_score != null ? `${module.compliance_score}%` : '—'}
</div>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6">
<div className="text-sm text-blue-600">Regulierungen</div>
<div className="text-3xl font-bold text-blue-600">{module.regulation_count || 0}</div>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6">
<div className="text-sm text-orange-600">Risiken</div>
<div className="text-3xl font-bold text-orange-600">{module.risk_count || 0}</div>
</div>
</div>
{/* Requirements */}
{module.requirements && module.requirements.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Anforderungen</h3>
</div>
<div className="divide-y divide-gray-100">
{module.requirements.map(req => (
<div key={req.id} className="px-6 py-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{req.title}</p>
<p className="text-xs text-gray-500">{req.regulation}</p>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
req.status === 'IMPLEMENTED' || req.status === 'VERIFIED'
? 'bg-green-100 text-green-700'
: req.status === 'IN_PROGRESS'
? 'bg-yellow-100 text-yellow-700'
: 'bg-gray-100 text-gray-500'
}`}>
{req.status}
</span>
</div>
))}
</div>
</div>
)}
{/* Controls */}
{module.controls && module.controls.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Kontrollen</h3>
</div>
<div className="divide-y divide-gray-100">
{module.controls.map(ctrl => (
<div key={ctrl.id} className="px-6 py-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900">{ctrl.title}</p>
<span className={`px-2 py-1 text-xs rounded-full ${
ctrl.status === 'IMPLEMENTED' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
{ctrl.status}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">{ctrl.description}</p>
</div>
))}
</div>
</div>
)}
{/* Regulations */}
{module.regulations && module.regulations.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-3">Zugeordnete Regulierungen</h3>
<div className="flex flex-wrap gap-2">
{module.regulations.map(reg => (
<span key={reg} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full">{reg}</span>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK, ServiceModule } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
@@ -126,11 +127,13 @@ function ModuleCard({
isActive,
onActivate,
onDeactivate,
onConfigure,
}: {
module: DisplayModule
isActive: boolean
onActivate: () => void
onDeactivate: () => void
onConfigure: () => void
}) {
const categoryColors = {
gdpr: 'bg-blue-100 text-blue-700',
@@ -208,7 +211,10 @@ function ModuleCard({
<div className="mt-4 flex items-center gap-2">
{isActive ? (
<>
<button className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors">
<button
onClick={onConfigure}
className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors"
>
Konfigurieren
</button>
<button
@@ -237,10 +243,16 @@ function ModuleCard({
export default function ModulesPage() {
const { state, dispatch } = useSDK()
const router = useRouter()
const [filter, setFilter] = useState<string>('all')
const [availableModules, setAvailableModules] = useState<Omit<DisplayModule, 'status' | 'completionPercent'>[]>(fallbackModules)
const [isLoadingModules, setIsLoadingModules] = useState(true)
const [backendError, setBackendError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newModuleName, setNewModuleName] = useState('')
const [newModuleCategory, setNewModuleCategory] = useState<ModuleCategory>('custom')
const [newModuleDescription, setNewModuleDescription] = useState('')
const [actionError, setActionError] = useState<string | null>(null)
// Load modules from backend
useEffect(() => {
@@ -312,26 +324,79 @@ export default function ModulesPage() {
hasAIComponents: module.hasAIComponents,
}
dispatch({ type: 'ADD_MODULE', payload: serviceModule })
setActionError(null)
try {
await fetch(`/api/sdk/v1/modules/${encodeURIComponent(module.id)}/activate`, {
const res = await fetch(`/api/sdk/v1/modules/${encodeURIComponent(module.id)}/activate`, {
method: 'POST',
})
if (!res.ok) throw new Error('Aktivierung fehlgeschlagen')
} catch {
console.warn('Could not persist module activation to backend')
// Rollback optimistic update
const rollbackModules = state.modules.filter(m => m.id !== module.id)
dispatch({ type: 'SET_STATE', payload: { modules: rollbackModules } })
setActionError(`Modul "${module.name}" konnte nicht aktiviert werden.`)
setTimeout(() => setActionError(null), 5000)
}
}
const handleDeactivateModule = async (moduleId: string) => {
const previousModules = [...state.modules]
const updatedModules = state.modules.filter(m => m.id !== moduleId)
dispatch({ type: 'SET_STATE', payload: { modules: updatedModules } })
setActionError(null)
try {
await fetch(`/api/sdk/v1/modules/${encodeURIComponent(moduleId)}/deactivate`, {
const res = await fetch(`/api/sdk/v1/modules/${encodeURIComponent(moduleId)}/deactivate`, {
method: 'POST',
})
if (!res.ok) throw new Error('Deaktivierung fehlgeschlagen')
} catch {
console.warn('Could not persist module deactivation to backend')
// Rollback optimistic update
dispatch({ type: 'SET_STATE', payload: { modules: previousModules } })
setActionError('Modul konnte nicht deaktiviert werden.')
setTimeout(() => setActionError(null), 5000)
}
}
const handleCreateModule = async () => {
if (!newModuleName.trim()) return
try {
const res = await fetch('/api/sdk/v1/modules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newModuleName,
category: newModuleCategory,
description: newModuleDescription,
}),
})
if (res.ok) {
const data = await res.json()
const newMod: Omit<DisplayModule, 'status' | 'completionPercent'> = {
id: data.id || `custom-${Date.now()}`,
name: newModuleName,
description: newModuleDescription,
category: newModuleCategory,
regulations: [],
criticality: 'MEDIUM',
processesPersonalData: false,
hasAIComponents: false,
requirementsCount: 0,
controlsCount: 0,
}
setAvailableModules(prev => [...prev, newMod])
}
setShowCreateModal(false)
setNewModuleName('')
setNewModuleCategory('custom')
setNewModuleDescription('')
} catch {
setActionError('Modul konnte nicht erstellt werden.')
setTimeout(() => setActionError(null), 5000)
}
}
@@ -347,7 +412,10 @@ export default function ModulesPage() {
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
@@ -355,6 +423,78 @@ export default function ModulesPage() {
</button>
</StepHeader>
{/* Error Toast */}
{actionError && (
<div className="fixed top-4 right-4 z-50 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-red-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<p className="text-sm text-red-700">{actionError}</p>
</div>
</div>
)}
{/* Create Module Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-2xl">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Eigenes Modul erstellen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={newModuleName}
onChange={e => setNewModuleName(e.target.value)}
placeholder="z.B. ISO 42001 AI Management"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={newModuleCategory}
onChange={e => setNewModuleCategory(e.target.value as ModuleCategory)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="gdpr">DSGVO</option>
<option value="ai-act">AI Act</option>
<option value="iso27001">ISO 27001</option>
<option value="nis2">NIS2</option>
<option value="custom">Eigene</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={newModuleDescription}
onChange={e => setNewModuleDescription(e.target.value)}
placeholder="Was deckt dieses Modul ab?"
rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
>
Abbrechen
</button>
<button
onClick={handleCreateModule}
disabled={!newModuleName.trim()}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Erstellen
</button>
</div>
</div>
</div>
)}
{/* Backend Status */}
{backendError && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">
@@ -434,6 +574,7 @@ export default function ModulesPage() {
isActive={state.modules.some(m => m.id === module.id)}
onActivate={() => handleActivateModule(module)}
onDeactivate={() => handleDeactivateModule(module.id)}
onConfigure={() => router.push(`/sdk/modules/${module.id}`)}
/>
))}
</div>

View File

@@ -1,7 +1,8 @@
'use client'
import React, { useState, useRef } from 'react'
import { useSDK, ScreeningResult, SecurityIssue, SBOMComponent } from '@/lib/sdk'
import React, { useState, useRef, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK, ScreeningResult, SecurityIssue, SBOMComponent, BacklogItem } from '@/lib/sdk'
// =============================================================================
// COMPONENTS
@@ -163,12 +164,54 @@ function SecurityIssueCard({ issue }: { issue: SecurityIssue }) {
export default function ScreeningPage() {
const { state, dispatch } = useSDK()
const router = useRouter()
const [isScanning, setIsScanning] = useState(false)
const [scanProgress, setScanProgress] = useState(0)
const [scanStatus, setScanStatus] = useState('')
const [scanError, setScanError] = useState<string | null>(null)
const [scanHistory, setScanHistory] = useState<any[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// 5.2: Load scan history
useEffect(() => {
const loadHistory = async () => {
setHistoryLoading(true)
try {
const response = await fetch('/api/sdk/v1/screening?tenant_id=default')
if (response.ok) {
const data = await response.json()
setScanHistory(Array.isArray(data) ? data : data.items || [])
}
} catch (err) {
console.error('Failed to load scan history:', err)
} finally {
setHistoryLoading(false)
}
}
loadHistory()
}, [state.screening])
// 5.1: Add issues to security backlog
const addToSecurityBacklog = () => {
const issues = state.screening?.securityScan?.issues || []
issues.forEach(issue => {
const backlogItem: BacklogItem = {
id: `backlog-${issue.id}`,
title: issue.title,
description: `${issue.description}\n\nBetroffene Komponente: ${issue.affectedComponent}\nEmpfehlung: ${issue.remediation}`,
severity: issue.severity,
securityIssueId: issue.id,
status: 'OPEN',
assignee: null,
dueDate: null,
createdAt: new Date(),
}
dispatch({ type: 'ADD_BACKLOG_ITEM', payload: backlogItem })
})
router.push('/sdk/security-backlog')
}
const startScan = async (file: File) => {
setIsScanning(true)
setScanProgress(0)
@@ -380,12 +423,54 @@ export default function ScreeningPage() {
>
Neuen Scan starten
</button>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
Zum Security Backlog hinzufügen
<button
onClick={addToSecurityBacklog}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Zum Security Backlog hinzufuegen
</button>
</div>
</>
)}
{/* Scan-Verlauf */}
{scanHistory.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Scan-Verlauf</h3>
<p className="text-sm text-gray-500">{scanHistory.length} fruehere Scans</p>
</div>
<div className="divide-y divide-gray-100">
{scanHistory.map((scan: any, idx: number) => (
<div key={scan.id || idx} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50">
<div>
<p className="text-sm font-medium text-gray-900">
Scan #{scan.id?.slice(0, 8) || idx + 1}
</p>
<p className="text-xs text-gray-500">
{scan.completed_at ? new Date(scan.completed_at).toLocaleString('de-DE') : 'Unbekannt'}
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">
{scan.total_issues || 0} Issues
</span>
<span className={`px-2 py-1 text-xs rounded-full ${
(scan.critical_issues || 0) > 0
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'
}`}>
{(scan.critical_issues || 0) > 0 ? `${scan.critical_issues} Kritisch` : 'Keine kritischen'}
</span>
</div>
</div>
))}
</div>
</div>
)}
{historyLoading && (
<div className="text-center py-4 text-sm text-gray-500">Scan-Verlauf wird geladen...</div>
)}
</div>
)
}

View File

@@ -15,15 +15,8 @@ import { OperationsMatrixTab } from '@/components/sdk/source-policy/OperationsMa
import { PIIRulesTab } from '@/components/sdk/source-policy/PIIRulesTab'
import { AuditTab } from '@/components/sdk/source-policy/AuditTab'
// API base URL for backend-compliance
const getApiBase = () => {
if (typeof window === 'undefined') return 'http://localhost:8002/api'
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:8002/api'
}
return `https://${hostname}:8002/api`
}
// API base URL — now uses Next.js proxy routes
const API_BASE = '/api/sdk/v1/source-policy'
interface PolicyStats {
active_policies: number
@@ -41,23 +34,15 @@ export default function SourcePolicyPage() {
const [stats, setStats] = useState<PolicyStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [apiBase, setApiBase] = useState<string | null>(null)
useEffect(() => {
const base = getApiBase()
setApiBase(base)
fetchStats()
}, [])
useEffect(() => {
if (apiBase !== null) {
fetchStats()
}
}, [apiBase])
const fetchStats = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/policy-stats`)
const res = await fetch(`${API_BASE}/policy-stats`)
if (!res.ok) {
throw new Error('Fehler beim Laden der Statistiken')
@@ -182,21 +167,82 @@ export default function SourcePolicyPage() {
</div>
{/* Tab Content */}
{apiBase === null ? (
<div className="text-center py-12 text-slate-500">Initialisiere...</div>
) : (
<>
{activeTab === 'dashboard' && (
<div className="text-center py-12 text-slate-500">
{loading ? 'Lade Dashboard...' : 'Dashboard-Ansicht - Wechseln Sie zu einem Tab fuer Details.'}
<>
{activeTab === 'dashboard' && stats && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quellen-Uebersicht</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Zugelassene Quellen</span>
<span className="text-2xl font-bold text-green-600">{stats.allowed_sources}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Aktive Policies</span>
<span className="text-2xl font-bold text-purple-600">{stats.active_policies}</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${stats.allowed_sources > 0 ? Math.min((stats.active_policies / stats.allowed_sources) * 100, 100) : 0}%` }}
/>
</div>
</div>
</div>
)}
{activeTab === 'sources' && <SourcesTab apiBase={apiBase} onUpdate={fetchStats} />}
{activeTab === 'operations' && <OperationsMatrixTab apiBase={apiBase} />}
{activeTab === 'pii' && <PIIRulesTab apiBase={apiBase} onUpdate={fetchStats} />}
{activeTab === 'audit' && <AuditTab apiBase={apiBase} />}
</>
)}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Datenschutz-Regeln</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">PII-Regeln aktiv</span>
<span className="text-2xl font-bold text-blue-600">{stats.pii_rules}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Blockiert (heute)</span>
<span className={`text-2xl font-bold ${stats.blocked_today > 0 ? 'text-red-600' : 'text-green-600'}`}>
{stats.blocked_today}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Blockiert (gesamt)</span>
<span className="text-lg font-semibold text-gray-500">{stats.blocked_total}</span>
</div>
</div>
</div>
<div className="md:col-span-2 bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Status</h3>
<div className="flex items-center gap-6">
<div className="flex-1 text-center p-4 bg-green-50 rounded-lg">
<div className="text-sm text-green-700 font-medium">Quellen konfiguriert</div>
<div className="text-3xl font-bold text-green-600 mt-1">
{stats.allowed_sources > 0 ? 'Ja' : 'Nein'}
</div>
</div>
<div className="flex-1 text-center p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-700 font-medium">PII-Schutz aktiv</div>
<div className="text-3xl font-bold text-blue-600 mt-1">
{stats.pii_rules > 0 ? 'Ja' : 'Nein'}
</div>
</div>
<div className="flex-1 text-center p-4 bg-purple-50 rounded-lg">
<div className="text-sm text-purple-700 font-medium">Policies definiert</div>
<div className="text-3xl font-bold text-purple-600 mt-1">
{stats.active_policies > 0 ? 'Ja' : 'Nein'}
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'dashboard' && !stats && loading && (
<div className="text-center py-12 text-slate-500">Lade Dashboard...</div>
)}
{activeTab === 'sources' && <SourcesTab apiBase={API_BASE} onUpdate={fetchStats} />}
{activeTab === 'operations' && <OperationsMatrixTab apiBase={API_BASE} />}
{activeTab === 'pii' && <PIIRulesTab apiBase={API_BASE} onUpdate={fetchStats} />}
{activeTab === 'audit' && <AuditTab apiBase={API_BASE} />}
</>
</div>
)
}

View File

@@ -140,6 +140,12 @@ export default function AssessmentDetailPage() {
</div>
</div>
<div className="flex items-center gap-2">
<Link
href={`/sdk/use-cases/new?edit=${assessmentId}`}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Bearbeiten
</Link>
<button
onClick={handleDelete}
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"

View File

@@ -26,20 +26,31 @@ export default function UseCasesPage() {
const [error, setError] = useState<string | null>(null)
const [filterFeasibility, setFilterFeasibility] = useState<string>('all')
const [filterRisk, setFilterRisk] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const [page, setPage] = useState(0)
const [totalCount, setTotalCount] = useState(0)
const PAGE_SIZE = 20
useEffect(() => {
fetchAssessments()
}, [])
}, [page, searchQuery])
async function fetchAssessments() {
try {
setLoading(true)
const response = await fetch('/api/sdk/v1/ucca/assessments')
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(page * PAGE_SIZE),
})
if (searchQuery) params.set('search', searchQuery)
const response = await fetch(`/api/sdk/v1/ucca/assessments?${params}`)
if (!response.ok) {
throw new Error('Fehler beim Laden der Assessments')
}
const data = await response.json()
setAssessments(data.assessments || [])
setTotalCount(data.total || data.assessments?.length || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
@@ -101,6 +112,20 @@ export default function UseCasesPage() {
</div>
</div>
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Assessments durchsuchen..."
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(0) }}
className="w-full px-4 py-2 pl-10 bg-white border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<svg className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Filters */}
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
@@ -185,6 +210,31 @@ export default function UseCasesPage() {
</div>
)}
{/* Pagination */}
{!loading && totalCount > PAGE_SIZE && (
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, totalCount)} von {totalCount}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-sm bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Zurueck
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= totalCount}
className="px-3 py-1 text-sm bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
</div>
)}
{/* Empty State */}
{!loading && filtered.length === 0 && !error && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">

View File

@@ -79,3 +79,43 @@ export async function POST(request: NextRequest) {
)
}
}
/**
* Proxy: PATCH /api/sdk/v1/company-profile → Backend PATCH /api/v1/company-profile
* Partial updates for individual fields
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
const tenantId = body.tenant_id || 'default'
const response = await fetch(
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
body: JSON.stringify(body),
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to patch company profile:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy: GET /api/sdk/v1/compliance-scope → Backend GET /api/v1/compliance-scope
* Retrieves the persisted scope decision for a tenant.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const tenantId = searchParams.get('tenant_id') || 'default'
const response = await fetch(
`${BACKEND_URL}/api/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
{
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
}
)
if (!response.ok) {
if (response.status === 404) {
return NextResponse.json(null, { status: 404 })
}
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to fetch compliance scope:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}
/**
* Proxy: POST /api/sdk/v1/compliance-scope → Backend POST /api/v1/compliance-scope
* Persists the scope decision and answers.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const tenantId = body.tenant_id || 'default'
const response = await fetch(
`${BACKEND_URL}/api/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
body: JSON.stringify(body),
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to save compliance scope:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy: DELETE /api/sdk/v1/import/:id → Backend DELETE /api/v1/import/:id
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const response = await fetch(`${BACKEND_URL}/api/v1/import/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete import:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy: GET /api/sdk/v1/modules/:moduleId → Backend GET /api/modules/:moduleId
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ moduleId: string }> }
) {
try {
const { moduleId } = await params
const response = await fetch(
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to fetch module:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -54,3 +54,41 @@ export async function GET(request: NextRequest) {
)
}
}
/**
* Proxy: POST /api/sdk/v1/modules → Backend POST /api/modules
* Creates a new custom module.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const response = await fetch(`${BACKEND_URL}/api/modules`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to create module:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/v1/admin/blocked-content${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to fetch blocked content:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/v1/admin/compliance-report${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to fetch compliance report:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function GET(request: NextRequest) {
try {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/operations-matrix`, {
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to fetch operations matrix:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const response = await fetch(`${BACKEND_URL}/api/v1/admin/operations/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to update operation:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const response = await fetch(`${BACKEND_URL}/api/v1/admin/pii-rules/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to update PII rule:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const response = await fetch(`${BACKEND_URL}/api/v1/admin/pii-rules/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to delete PII rule:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function GET(request: NextRequest) {
try {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/pii-rules`, {
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to fetch PII rules:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const response = await fetch(`${BACKEND_URL}/api/v1/admin/pii-rules`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to create PII rule:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/v1/admin/policy-audit${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to fetch policy audit:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function GET(request: NextRequest) {
try {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/policy-stats`, {
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to fetch policy stats:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const response = await fetch(`${BACKEND_URL}/api/v1/admin/sources/${encodeURIComponent(id)}`, {
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to fetch source:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const response = await fetch(`${BACKEND_URL}/api/v1/admin/sources/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to update source:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const response = await fetch(`${BACKEND_URL}/api/v1/admin/sources/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to delete source:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/v1/admin/sources${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to fetch sources:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const response = await fetch(`${BACKEND_URL}/api/v1/admin/sources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to create source:', error)
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
}
}

View File

@@ -41,6 +41,47 @@ export async function GET(
}
}
/**
* Proxy: PUT /api/sdk/v1/ucca/assessments/[id] → Go Backend PUT /sdk/v1/ucca/assessments/:id
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/assessments/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'UCCA backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to update UCCA assessment:', error)
return NextResponse.json(
{ error: 'Failed to connect to UCCA backend' },
{ status: 503 }
)
}
}
/**
* Proxy: DELETE /api/sdk/v1/ucca/assessments/[id] → Go Backend DELETE /sdk/v1/ucca/assessments/:id
*/

View File

@@ -5,9 +5,22 @@ import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUM
interface ScopeDecisionTabProps {
decision: ScopeDecision | null
answers?: unknown[]
onBackToWizard?: () => void
onGoToExport?: () => void
canEvaluate?: boolean
onEvaluate?: () => void
isEvaluating?: boolean
}
export function ScopeDecisionTab({ decision }: ScopeDecisionTabProps) {
export function ScopeDecisionTab({
decision,
onBackToWizard,
onGoToExport,
canEvaluate,
onEvaluate,
isEvaluating,
}: ScopeDecisionTabProps) {
const [expandedTrigger, setExpandedTrigger] = useState<number | null>(null)
const [showAuditTrail, setShowAuditTrail] = useState(false)
@@ -320,6 +333,35 @@ export function ScopeDecisionTab({ decision }: ScopeDecisionTabProps) {
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-3">
{onBackToWizard && (
<button
onClick={onBackToWizard}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Zurueck zum Wizard
</button>
)}
{canEvaluate && onEvaluate && (
<button
onClick={onEvaluate}
disabled={isEvaluating}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isEvaluating ? 'Bewertung laeuft...' : 'Neu bewerten'}
</button>
)}
{onGoToExport && (
<button
onClick={onGoToExport}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Zum Export
</button>
)}
</div>
{/* Audit Trail */}
{decision.auditTrail && decision.auditTrail.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">

View File

@@ -126,10 +126,63 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
})
}, [generateMarkdownSummary])
/** Simple markdown-to-HTML converter for print view */
const markdownToHtml = useCallback((md: string): string => {
let html = md
// Escape HTML entities
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Tables
const lines = html.split('\n')
let inTable = false
const result: string[] = []
for (const line of lines) {
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
if (line.includes('---')) continue // separator row
const cells = line.split('|').filter(c => c.trim() !== '')
if (!inTable) {
result.push('<table><thead><tr>')
cells.forEach(c => result.push(`<th>${c.trim()}</th>`))
result.push('</tr></thead><tbody>')
inTable = true
} else {
result.push('<tr>')
cells.forEach(c => result.push(`<td>${c.trim()}</td>`))
result.push('</tr>')
}
} else {
if (inTable) {
result.push('</tbody></table>')
inTable = false
}
// List items
if (line.trim().startsWith('- ')) {
result.push(`<li>${line.trim().slice(2)}</li>`)
} else if (/^\d+\.\s/.test(line.trim())) {
result.push(`<li>${line.trim().replace(/^\d+\.\s/, '')}</li>`)
} else if (line.trim() === '') {
result.push('<br/>')
} else {
result.push(`<p>${line}</p>`)
}
}
}
if (inTable) result.push('</tbody></table>')
return result.join('\n')
}, [])
const handlePrintView = useCallback(() => {
if (!decision) return
const markdown = generateMarkdownSummary()
const renderedHtml = markdownToHtml(markdown)
const htmlContent = `
<!DOCTYPE html>
<html>
@@ -152,6 +205,7 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
th { background-color: #f3f4f6; font-weight: 600; }
ul { list-style-type: disc; padding-left: 20px; }
li { margin: 8px 0; }
p { margin: 4px 0; }
@media print {
body { margin: 20px; }
h1, h2, h3 { page-break-after: avoid; }
@@ -160,7 +214,7 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
</style>
</head>
<body>
<pre style="white-space: pre-wrap; font-family: inherit;">${markdown}</pre>
${renderedHtml}
</body>
</html>
`
@@ -171,7 +225,7 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
printWindow.focus()
setTimeout(() => printWindow.print(), 250)
}
}, [decision, generateMarkdownSummary])
}, [decision, generateMarkdownSummary, markdownToHtml])
if (!decision) {
return (

View File

@@ -87,7 +87,7 @@ export function AuditTab({ apiBase }: AuditTabProps) {
if (entityFilter) params.append('entity_type', entityFilter)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/policy-audit?${params}`)
const res = await fetch(`${apiBase}/policy-audit?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
@@ -108,7 +108,7 @@ export function AuditTab({ apiBase }: AuditTabProps) {
if (dateTo) params.append('to', dateTo)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/blocked-content?${params}`)
const res = await fetch(`${apiBase}/blocked-content?${params}`)
if (!res.ok) {
// Endpoint may not exist yet — show empty state
setBlockedContent([])
@@ -136,7 +136,7 @@ export function AuditTab({ apiBase }: AuditTabProps) {
if (dateTo) params.append('to', dateTo)
params.append('format', 'download')
const res = await fetch(`${apiBase}/v1/admin/compliance-report?${params}`)
const res = await fetch(`${apiBase}/compliance-report?${params}`)
if (!res.ok) throw new Error('Fehler beim Export')
const blob = await res.blob()

View File

@@ -44,8 +44,8 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
try {
setLoading(true)
const [sourcesRes, opsRes] = await Promise.all([
fetch(`${apiBase}/v1/admin/sources`),
fetch(`${apiBase}/v1/admin/operations-matrix`),
fetch(`${apiBase}/sources`),
fetch(`${apiBase}/operations-matrix`),
])
if (!sourcesRes.ok || !opsRes.ok) throw new Error('Fehler beim Laden')
@@ -95,7 +95,7 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
setUpdating(updateId)
try {
const res = await fetch(`${apiBase}/v1/admin/operations/${permission.id}`, {
const res = await fetch(`${apiBase}/operations/${permission.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowed: !permission.allowed }),

View File

@@ -82,7 +82,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const fetchRules = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`)
const res = await fetch(`${apiBase}/pii-rules`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
@@ -97,7 +97,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const createRule = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`, {
const res = await fetch(`${apiBase}/pii-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newRule),
@@ -127,7 +127,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${editingRule.id}`, {
const res = await fetch(`${apiBase}/pii-rules/${editingRule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
@@ -149,7 +149,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
if (!confirm('Regel wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${id}`, {
const res = await fetch(`${apiBase}/pii-rules/${id}`, {
method: 'DELETE',
})
@@ -164,7 +164,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const toggleRuleStatus = async (rule: PIIRule) => {
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${rule.id}`, {
const res = await fetch(`${apiBase}/pii-rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !rule.active }),

View File

@@ -78,7 +78,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
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}`)
const res = await fetch(`${apiBase}/sources?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
@@ -93,7 +93,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const createSource = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources`, {
const res = await fetch(`${apiBase}/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSource),
@@ -124,7 +124,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources/${editingSource.id}`, {
const res = await fetch(`${apiBase}/sources/${editingSource.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingSource),
@@ -146,7 +146,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
if (!confirm('Quelle wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${id}`, {
const res = await fetch(`${apiBase}/sources/${id}`, {
method: 'DELETE',
})
@@ -161,7 +161,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const toggleSourceStatus = async (source: AllowedSource) => {
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${source.id}`, {
const res = await fetch(`${apiBase}/sources/${source.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !source.active }),

View File

@@ -13,6 +13,7 @@
"jszip": "^3.10.1",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"pg": "^8.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"reactflow": "^11.11.4",
@@ -4624,6 +4625,95 @@
"license": "MIT",
"optional": true
},
"node_modules/pg": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0",
"pg-protocol": "^1.12.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz",
"integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz",
"integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4872,6 +4962,45 @@
"dev": true,
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/preact": {
"version": "10.28.4",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
@@ -5389,6 +5518,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -6145,6 +6283,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -408,6 +408,8 @@ async def list_blocked_content(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
domain: Optional[str] = None,
date_from: Optional[str] = Query(None, alias="from"),
date_to: Optional[str] = Query(None, alias="to"),
db: Session = Depends(get_db),
):
"""List blocked content entries."""
@@ -416,6 +418,20 @@ async def list_blocked_content(
if domain:
query = query.filter(BlockedContentDB.domain == domain)
if date_from:
try:
from_dt = datetime.fromisoformat(date_from)
query = query.filter(BlockedContentDB.created_at >= from_dt)
except ValueError:
pass
if date_to:
try:
to_dt = datetime.fromisoformat(date_to)
query = query.filter(BlockedContentDB.created_at <= to_dt)
except ValueError:
pass
total = query.count()
entries = query.order_by(BlockedContentDB.created_at.desc()).offset(offset).limit(limit).all()
@@ -445,6 +461,8 @@ async def get_policy_audit(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
entity_type: Optional[str] = None,
date_from: Optional[str] = Query(None, alias="from"),
date_to: Optional[str] = Query(None, alias="to"),
db: Session = Depends(get_db),
):
"""Get the audit trail for source policy changes."""
@@ -452,6 +470,20 @@ async def get_policy_audit(
if entity_type:
query = query.filter(SourcePolicyAuditDB.entity_type == entity_type)
if date_from:
try:
from_dt = datetime.fromisoformat(date_from)
query = query.filter(SourcePolicyAuditDB.created_at >= from_dt)
except ValueError:
pass
if date_to:
try:
to_dt = datetime.fromisoformat(date_to)
query = query.filter(SourcePolicyAuditDB.created_at <= to_dt)
except ValueError:
pass
total = query.count()
entries = query.order_by(SourcePolicyAuditDB.created_at.desc()).offset(offset).limit(limit).all()
@@ -486,16 +518,13 @@ async def get_policy_stats(db: Session = Depends(get_db)):
active_sources = db.query(AllowedSourceDB).filter(AllowedSourceDB.active == True).count()
pii_rules = db.query(PIIRuleDB).filter(PIIRuleDB.active == True).count()
# Count audit entries from today
# Count blocked content entries from today
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
blocked_today = db.query(SourcePolicyAuditDB).filter(
SourcePolicyAuditDB.action == "delete",
SourcePolicyAuditDB.created_at >= today_start,
blocked_today = db.query(BlockedContentDB).filter(
BlockedContentDB.created_at >= today_start,
).count()
blocked_total = db.query(SourcePolicyAuditDB).filter(
SourcePolicyAuditDB.action == "delete",
).count()
blocked_total = db.query(BlockedContentDB).count()
return {
"active_policies": active_sources,