feat: Vorbereitung-Module auf 100% — Persistenz, Backend-Services, UCCA Frontend
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 37s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s

Phase A: PostgreSQL State Store (sdk_states Tabelle, InMemory-Fallback)
Phase B: Modules dynamisch vom Backend, Scope DB-Persistenz, Source Policy State
Phase C: UCCA Frontend (3 Seiten, Wizard, RiskScoreGauge), Obligations Live-Daten
Phase D: Document Import (PDF/LLM/Gap-Analyse), System Screening (SBOM/OSV.dev)
Phase E: Company Profile CRUD mit Audit-Logging
Phase F: Tests (Python + TypeScript), flow-data.ts DB-Tabellen aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-02 11:04:31 +01:00
parent cd15ab0932
commit e6d666b89b
38 changed files with 4195 additions and 420 deletions

View File

@@ -100,8 +100,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
inputs: [],
outputs: ['companyProfile', 'complianceScope'],
prerequisiteSteps: [],
dbTables: [],
dbMode: 'none',
dbTables: ['sdk_states', 'compliance_company_profiles', 'compliance_company_profile_audit'],
dbMode: 'read/write',
ragCollections: [],
isOptional: false,
url: '/sdk/company-profile',
@@ -120,8 +120,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
inputs: ['companyProfile'],
outputs: ['complianceDepthLevel'],
prerequisiteSteps: ['company-profile'],
dbTables: [],
dbMode: 'none',
dbTables: ['sdk_states'],
dbMode: 'read/write',
ragCollections: [],
isOptional: false,
url: '/sdk/compliance-scope',
@@ -141,12 +141,12 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
inputs: ['companyProfile'],
outputs: ['useCases'],
prerequisiteSteps: ['company-profile'],
dbTables: [],
dbMode: 'none',
dbTables: ['ucca_assessments', 'ucca_findings', 'ucca_controls'],
dbMode: 'read/write',
ragCollections: ['bp_compliance_ce'],
ragPurpose: 'CE-Regulierungen fuer Use-Case Matching',
isOptional: false,
url: '/sdk/advisory-board',
url: '/sdk/use-cases',
},
{
id: 'import',
@@ -159,8 +159,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
inputs: ['useCases'],
outputs: ['importedDocuments'],
prerequisiteSteps: ['use-case-assessment'],
dbTables: [],
dbMode: 'none',
dbTables: ['compliance_imported_documents', 'compliance_gap_analyses'],
dbMode: 'read/write',
ragCollections: [],
isOptional: true,
url: '/sdk/import',
@@ -179,8 +179,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
inputs: ['useCases'],
outputs: ['screening', 'sbom'],
prerequisiteSteps: ['use-case-assessment'],
dbTables: [],
dbMode: 'none',
dbTables: ['compliance_screenings', 'compliance_security_issues'],
dbMode: 'read/write',
ragCollections: [],
isOptional: false,
url: '/sdk/screening',
@@ -199,8 +199,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
inputs: ['companyProfile', 'screening'],
outputs: ['modules'],
prerequisiteSteps: ['screening'],
dbTables: [],
dbMode: 'none',
dbTables: ['compliance_service_modules', 'sdk_states'],
dbMode: 'read/write',
ragCollections: ['bp_compliance_gesetze'],
ragPurpose: 'Regulierungen den Modulen zuordnen',
isOptional: false,
@@ -220,8 +220,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
inputs: ['modules'],
outputs: ['sourcePolicy'],
prerequisiteSteps: ['modules'],
dbTables: [],
dbMode: 'none',
dbTables: ['compliance_source_policies', 'compliance_allowed_sources', 'compliance_pii_field_rules', 'compliance_source_policy_audit'],
dbMode: 'read/write',
ragCollections: [],
isOptional: false,
url: '/sdk/source-policy',

View File

@@ -49,22 +49,30 @@ export default function ComplianceScopePage() {
const [isLoading, setIsLoading] = useState(true)
const [isEvaluating, setIsEvaluating] = useState(false)
// Load from localStorage on mount
// Load from SDK context first (persisted via State API), then localStorage as fallback
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as ComplianceScopeState
setScopeState(parsed)
// Also sync to SDK context
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
// Priority 1: SDK context (loaded from PostgreSQL via State API)
if (sdkState.complianceScope && sdkState.complianceScope.answers?.length > 0) {
setScopeState(sdkState.complianceScope)
// Also update localStorage for offline fallback
localStorage.setItem(STORAGE_KEY, JSON.stringify(sdkState.complianceScope))
} else {
// Priority 2: localStorage fallback
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as ComplianceScopeState
setScopeState(parsed)
// Sync to SDK context for backend persistence
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
}
}
} catch (error) {
console.error('Failed to load compliance scope state from localStorage:', error)
console.error('Failed to load compliance scope state:', error)
} finally {
setIsLoading(false)
}
}, [dispatch])
}, [dispatch, sdkState.complianceScope])
// Save to localStorage and SDK context whenever state changes
useEffect(() => {

View File

@@ -358,108 +358,105 @@ export default function ImportPage() {
if (files.length === 0) return
setIsAnalyzing(true)
const allGaps: GapItem[] = []
// Simulate upload and analysis
for (let i = 0; i < files.length; i++) {
const file = files[i]
// Update to uploading
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'uploading' as const } : f)))
// Simulate upload progress
for (let p = 0; p <= 100; p += 20) {
await new Promise(resolve => setTimeout(resolve, 100))
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: p } : f)))
// Upload progress
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 30 } : f)))
// Prepare form data for backend
const formData = new FormData()
formData.append('file', file.file)
formData.append('document_type', file.type)
formData.append('tenant_id', 'default')
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 60, status: 'analyzing' as const } : f)))
try {
const response = await fetch('/api/sdk/v1/import/analyze', {
method: 'POST',
body: formData,
})
if (response.ok) {
const result = await response.json()
// Create imported document from backend response
const doc: ImportedDocument = {
id: result.document_id || file.id,
name: file.file.name,
type: result.detected_type || file.type,
fileUrl: URL.createObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
detectedType: result.detected_type || file.type,
confidence: result.confidence || 0.85,
extractedEntities: result.extracted_entities || [],
gaps: result.gap_analysis?.gaps || [],
recommendations: result.recommendations || [],
},
}
addImportedDocument(doc)
// Collect gaps
if (result.gap_analysis?.gaps) {
for (const gap of result.gap_analysis.gaps) {
allGaps.push({
id: gap.id,
category: gap.category,
description: gap.description,
severity: gap.severity,
regulation: gap.regulation,
requiredAction: gap.required_action,
relatedStepId: gap.related_step_id || '',
})
}
}
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const } : f)))
} else {
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'error' as const, error: 'Analyse fehlgeschlagen' } : f)))
}
} catch {
// Fallback: create basic document without backend analysis
const doc: ImportedDocument = {
id: file.id,
name: file.file.name,
type: file.type,
fileUrl: URL.createObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
detectedType: file.type,
confidence: 0.5,
extractedEntities: [],
gaps: [],
recommendations: ['Backend nicht erreichbar — manuelle Pruefung empfohlen'],
},
}
addImportedDocument(doc)
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const } : f)))
}
// Update to analyzing
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'analyzing' as const } : f)))
// Simulate analysis
await new Promise(resolve => setTimeout(resolve, 1000))
// Create imported document
const doc: ImportedDocument = {
id: file.id,
name: file.file.name,
type: file.type,
fileUrl: URL.createObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
detectedType: file.type,
confidence: 0.85 + Math.random() * 0.15,
extractedEntities: ['DSGVO', 'AI Act', 'Personenbezogene Daten'],
gaps: [],
recommendations: ['KI-spezifische Klauseln ergaenzen', 'AI Act Anforderungen pruefen'],
},
}
addImportedDocument(doc)
// Update to complete
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'complete' as const } : f)))
}
// Generate mock gap analysis
const gaps: GapItem[] = [
{
id: 'gap-1',
category: 'AI Act Compliance',
description: 'Keine Risikoklassifizierung fuer KI-Systeme vorhanden',
severity: 'CRITICAL',
regulation: 'EU AI Act Art. 6',
requiredAction: 'Risikoklassifizierung durchfuehren',
relatedStepId: 'ai-act',
},
{
id: 'gap-2',
category: 'Transparenz',
description: 'Informationspflichten bei automatisierten Entscheidungen fehlen',
severity: 'HIGH',
regulation: 'DSGVO Art. 13, 14, 22',
requiredAction: 'Datenschutzerklaerung erweitern',
relatedStepId: 'einwilligungen',
},
{
id: 'gap-3',
category: 'TOMs',
description: 'KI-spezifische technische Massnahmen nicht dokumentiert',
severity: 'MEDIUM',
regulation: 'DSGVO Art. 32',
requiredAction: 'TOMs um KI-Aspekte erweitern',
relatedStepId: 'tom',
},
{
id: 'gap-4',
category: 'VVT',
description: 'KI-basierte Verarbeitungstaetigkeiten nicht erfasst',
severity: 'HIGH',
regulation: 'DSGVO Art. 30',
requiredAction: 'VVT aktualisieren',
relatedStepId: 'vvt',
},
{
id: 'gap-5',
category: 'Aufsicht',
description: 'Menschliche Aufsicht nicht definiert',
severity: 'MEDIUM',
regulation: 'EU AI Act Art. 14',
requiredAction: 'Aufsichtsprozesse definieren',
relatedStepId: 'controls',
},
]
// Build gap analysis summary
const gapAnalysis: GapAnalysis = {
id: `analysis-${Date.now()}`,
createdAt: new Date(),
totalGaps: gaps.length,
criticalGaps: gaps.filter(g => g.severity === 'CRITICAL').length,
highGaps: gaps.filter(g => g.severity === 'HIGH').length,
mediumGaps: gaps.filter(g => g.severity === 'MEDIUM').length,
lowGaps: gaps.filter(g => g.severity === 'LOW').length,
gaps,
recommendedPackages: ['analyse', 'dokumentation'],
totalGaps: allGaps.length,
criticalGaps: allGaps.filter(g => g.severity === 'CRITICAL').length,
highGaps: allGaps.filter(g => g.severity === 'HIGH').length,
mediumGaps: allGaps.filter(g => g.severity === 'MEDIUM').length,
lowGaps: allGaps.filter(g => g.severity === 'LOW').length,
gaps: allGaps,
recommendedPackages: allGaps.length > 0 ? ['analyse', 'dokumentation'] : [],
}
setAnalysisResult(gapAnalysis)

View File

@@ -19,11 +19,26 @@ interface DisplayModule extends ServiceModule {
completionPercent: number
}
interface BackendModule {
id: string
name: string
display_name: string
description: string
service_type: string | null
processes_pii: boolean
ai_components: boolean
criticality: string
is_active: boolean
compliance_score: number | null
regulation_count: number
risk_count: number
}
// =============================================================================
// AVAILABLE MODULES (Templates)
// FALLBACK MODULES (used when backend is unavailable)
// =============================================================================
const availableModules: Omit<DisplayModule, 'status' | 'completionPercent'>[] = [
const fallbackModules: Omit<DisplayModule, 'status' | 'completionPercent'>[] = [
{
id: 'mod-gdpr',
name: 'DSGVO Compliance',
@@ -74,6 +89,34 @@ const availableModules: Omit<DisplayModule, 'status' | 'completionPercent'>[] =
},
]
// =============================================================================
// HELPERS
// =============================================================================
function categorizeModule(name: string): ModuleCategory {
const lower = name.toLowerCase()
if (lower.includes('dsgvo') || lower.includes('gdpr') || lower.includes('datenschutz')) return 'gdpr'
if (lower.includes('ai act') || lower.includes('ki-verordnung')) return 'ai-act'
if (lower.includes('iso 27001') || lower.includes('iso27001') || lower.includes('isms')) return 'iso27001'
if (lower.includes('nis2') || lower.includes('netz- und informations')) return 'nis2'
return 'custom'
}
function mapBackendToDisplay(m: BackendModule): Omit<DisplayModule, 'status' | 'completionPercent'> {
return {
id: m.id,
name: m.display_name || m.name,
description: m.description || '',
category: categorizeModule(m.display_name || m.name),
regulations: [],
criticality: (m.criticality || 'MEDIUM').toUpperCase(),
processesPersonalData: m.processes_pii,
hasAIComponents: m.ai_components,
requirementsCount: m.regulation_count || 0,
controlsCount: m.risk_count || 0,
}
}
// =============================================================================
// COMPONENTS
// =============================================================================
@@ -124,13 +167,15 @@ function ModuleCard({
</div>
<h3 className="text-lg font-semibold text-gray-900">{module.name}</h3>
<p className="text-sm text-gray-500 mt-1">{module.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{module.regulations.map(reg => (
<span key={reg} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
{reg}
</span>
))}
</div>
{module.regulations.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{module.regulations.map(reg => (
<span key={reg} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
{reg}
</span>
))}
</div>
)}
</div>
</div>
@@ -193,6 +238,33 @@ function ModuleCard({
export default function ModulesPage() {
const { state, dispatch } = useSDK()
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)
// Load modules from backend
useEffect(() => {
async function loadModules() {
try {
const response = await fetch('/api/sdk/v1/modules')
if (response.ok) {
const data = await response.json()
if (data.modules && data.modules.length > 0) {
const mapped = data.modules.map(mapBackendToDisplay)
setAvailableModules(mapped)
setBackendError(null)
}
} else {
setBackendError('Backend nicht erreichbar — zeige Standard-Module')
}
} catch {
setBackendError('Backend nicht erreichbar — zeige Standard-Module')
} finally {
setIsLoadingModules(false)
}
}
loadModules()
}, [])
// Convert SDK modules to display modules with additional UI properties
const displayModules: DisplayModule[] = availableModules.map(template => {
@@ -243,7 +315,6 @@ export default function ModulesPage() {
}
const handleDeactivateModule = (moduleId: string) => {
// Remove module by updating state without it
const updatedModules = state.modules.filter(m => m.id !== moduleId)
dispatch({ type: 'SET_STATE', payload: { modules: updatedModules } })
}
@@ -268,6 +339,18 @@ export default function ModulesPage() {
</button>
</StepHeader>
{/* Backend Status */}
{backendError && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">
{backendError}
</div>
)}
{/* Loading */}
{isLoadingModules && (
<div className="text-center py-8 text-gray-500">Lade Module vom Backend...</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
@@ -21,73 +21,6 @@ interface Obligation {
linkedSystems: string[]
}
// =============================================================================
// MOCK DATA
// =============================================================================
const mockObligations: Obligation[] = [
{
id: 'obl-1',
title: 'Risikomanagementsystem implementieren',
description: 'Ein Risikomanagementsystem fuer das Hochrisiko-KI-System muss implementiert werden.',
source: 'AI Act',
sourceArticle: 'Art. 9',
deadline: new Date('2024-06-01'),
status: 'in-progress',
priority: 'critical',
responsible: 'IT Security',
linkedSystems: ['Bewerber-Screening'],
},
{
id: 'obl-2',
title: 'Technische Dokumentation erstellen',
description: 'Umfassende technische Dokumentation fuer alle Hochrisiko-KI-Systeme.',
source: 'AI Act',
sourceArticle: 'Art. 11',
deadline: new Date('2024-05-15'),
status: 'pending',
priority: 'high',
responsible: 'Entwicklung',
linkedSystems: ['Bewerber-Screening'],
},
{
id: 'obl-3',
title: 'Datenschutzerklaerung aktualisieren',
description: 'Die Datenschutzerklaerung muss an die neuen KI-Verarbeitungen angepasst werden.',
source: 'DSGVO',
sourceArticle: 'Art. 13/14',
deadline: new Date('2024-02-01'),
status: 'overdue',
priority: 'high',
responsible: 'Datenschutz',
linkedSystems: ['Kundenservice Chatbot', 'Empfehlungsalgorithmus'],
},
{
id: 'obl-4',
title: 'KI-Kennzeichnung implementieren',
description: 'Nutzer muessen informiert werden, dass sie mit einem KI-System interagieren.',
source: 'AI Act',
sourceArticle: 'Art. 52',
deadline: new Date('2024-03-01'),
status: 'completed',
priority: 'medium',
responsible: 'UX Team',
linkedSystems: ['Kundenservice Chatbot'],
},
{
id: 'obl-5',
title: 'Menschliche Aufsicht sicherstellen',
description: 'Prozesse fuer menschliche Aufsicht bei automatisierten Entscheidungen.',
source: 'AI Act',
sourceArticle: 'Art. 14',
deadline: new Date('2024-04-01'),
status: 'pending',
priority: 'critical',
responsible: 'Operations',
linkedSystems: ['Bewerber-Screening'],
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
@@ -188,14 +121,124 @@ function ObligationCard({ obligation }: { obligation: Obligation }) {
)
}
// =============================================================================
// HELPERS
// =============================================================================
function mapControlsToObligations(assessments: Array<{
id: string
title?: string
domain?: string
result?: {
required_controls?: Array<{
id: string
title: string
description: string
gdpr_ref?: string
effort?: string
}>
triggered_rules?: Array<{
rule_code: string
title: string
severity: string
gdpr_ref: string
}>
risk_level?: string
}
}>): Obligation[] {
const obligations: Obligation[] = []
for (const assessment of assessments) {
// Map triggered rules to obligations
const rules = assessment.result?.triggered_rules || []
for (const rule of rules) {
const severity = rule.severity
obligations.push({
id: `${assessment.id}-${rule.rule_code}`,
title: rule.title,
description: `Aus Assessment: ${assessment.title || assessment.id.slice(0, 8)}`,
source: rule.gdpr_ref?.includes('AI Act') ? 'AI Act' : 'DSGVO',
sourceArticle: rule.gdpr_ref || '',
deadline: null,
status: 'pending',
priority: severity === 'BLOCK' ? 'critical' : severity === 'WARN' ? 'high' : 'medium',
responsible: 'Compliance Team',
linkedSystems: assessment.title ? [assessment.title] : [],
})
}
// Map required controls to obligations
const controls = assessment.result?.required_controls || []
for (const control of controls) {
obligations.push({
id: `${assessment.id}-ctrl-${control.id}`,
title: control.title,
description: control.description,
source: control.gdpr_ref?.includes('AI Act') ? 'AI Act' : 'DSGVO',
sourceArticle: control.gdpr_ref || '',
deadline: null,
status: 'pending',
priority: assessment.result?.risk_level === 'HIGH' || assessment.result?.risk_level === 'UNACCEPTABLE' ? 'high' : 'medium',
responsible: 'IT / Compliance',
linkedSystems: assessment.title ? [assessment.title] : [],
})
}
}
return obligations
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function ObligationsPage() {
const { state } = useSDK()
const [obligations] = useState<Obligation[]>(mockObligations)
const [obligations, setObligations] = useState<Obligation[]>([])
const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [backendAvailable, setBackendAvailable] = useState(false)
useEffect(() => {
async function loadObligations() {
try {
const response = await fetch('/api/sdk/v1/ucca/assessments')
if (response.ok) {
const data = await response.json()
const assessments = data.assessments || []
if (assessments.length > 0) {
const mapped = mapControlsToObligations(assessments)
setObligations(mapped)
setBackendAvailable(true)
setLoading(false)
return
}
}
} catch {
// Backend unavailable, use SDK state obligations
}
// Fallback: use obligations from SDK state
if (state.obligations && state.obligations.length > 0) {
setObligations(state.obligations.map(o => ({
id: o.id,
title: o.title,
description: o.description || '',
source: o.source || 'DSGVO',
sourceArticle: o.sourceArticle || '',
deadline: o.deadline ? new Date(o.deadline) : null,
status: (o.status as Obligation['status']) || 'pending',
priority: (o.priority as Obligation['priority']) || 'medium',
responsible: o.responsible || 'Compliance Team',
linkedSystems: o.linkedSystems || [],
})))
}
setLoading(false)
}
loadObligations()
}, [state.obligations])
const filteredObligations = filter === 'all'
? obligations
@@ -226,6 +269,18 @@ export default function ObligationsPage() {
</button>
</StepHeader>
{/* Backend Status */}
{backendAvailable && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-700">
Pflichten aus UCCA-Assessments geladen (Live-Daten)
</div>
)}
{/* Loading */}
{loading && (
<div className="text-center py-8 text-gray-500">Lade Pflichten...</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -288,7 +343,6 @@ export default function ObligationsPage() {
<div className="space-y-4">
{filteredObligations
.sort((a, b) => {
// Sort by status priority: overdue > in-progress > pending > completed
const statusOrder = { overdue: 0, 'in-progress': 1, pending: 2, completed: 3 }
return statusOrder[a.status] - statusOrder[b.status]
})
@@ -297,7 +351,7 @@ export default function ObligationsPage() {
))}
</div>
{filteredObligations.length === 0 && (
{filteredObligations.length === 0 && !loading && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -305,7 +359,9 @@ export default function ObligationsPage() {
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Pflichten gefunden</h3>
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder fuegen Sie neue Pflichten hinzu.</p>
<p className="mt-2 text-gray-500">
Erstellen Sie zuerst ein Use Case Assessment, um automatisch Pflichten abzuleiten.
</p>
</div>
)}
</div>

View File

@@ -1,85 +1,8 @@
'use client'
import React, { useState } from 'react'
import React, { useState, useRef } from 'react'
import { useSDK, ScreeningResult, SecurityIssue, SBOMComponent } from '@/lib/sdk'
// =============================================================================
// MOCK DATA
// =============================================================================
const mockSBOMComponents: SBOMComponent[] = [
{
name: 'react',
version: '18.3.0',
type: 'library',
purl: 'pkg:npm/react@18.3.0',
licenses: ['MIT'],
vulnerabilities: [],
},
{
name: 'next',
version: '15.1.0',
type: 'framework',
purl: 'pkg:npm/next@15.1.0',
licenses: ['MIT'],
vulnerabilities: [],
},
{
name: 'lodash',
version: '4.17.21',
type: 'library',
purl: 'pkg:npm/lodash@4.17.21',
licenses: ['MIT'],
vulnerabilities: [
{
id: 'CVE-2021-23337',
cve: 'CVE-2021-23337',
severity: 'HIGH',
title: 'Prototype Pollution',
description: 'Lodash versions prior to 4.17.21 are vulnerable to Command Injection via the template function.',
cvss: 7.2,
fixedIn: '4.17.21',
},
],
},
]
const mockSecurityIssues: SecurityIssue[] = [
{
id: 'issue-1',
severity: 'CRITICAL',
title: 'SQL Injection Vulnerability',
description: 'Unvalidated user input in database queries',
cve: 'CVE-2024-12345',
cvss: 9.8,
affectedComponent: 'database-connector',
remediation: 'Use parameterized queries',
status: 'OPEN',
},
{
id: 'issue-2',
severity: 'HIGH',
title: 'Cross-Site Scripting (XSS)',
description: 'Reflected XSS in search functionality',
cve: 'CVE-2024-12346',
cvss: 7.5,
affectedComponent: 'search-module',
remediation: 'Sanitize and encode user input',
status: 'IN_PROGRESS',
},
{
id: 'issue-3',
severity: 'MEDIUM',
title: 'Insecure Cookie Configuration',
description: 'Session cookies missing Secure and HttpOnly flags',
cve: null,
cvss: 5.3,
affectedComponent: 'auth-service',
remediation: 'Set Secure and HttpOnly flags on cookies',
status: 'OPEN',
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
@@ -243,62 +166,120 @@ export default function ScreeningPage() {
const [isScanning, setIsScanning] = useState(false)
const [scanProgress, setScanProgress] = useState(0)
const [scanStatus, setScanStatus] = useState('')
const [repositoryUrl, setRepositoryUrl] = useState('')
const startScan = async () => {
if (!repositoryUrl) return
const [scanError, setScanError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const startScan = async (file: File) => {
setIsScanning(true)
setScanProgress(0)
setScanStatus('Initialisierung...')
setScanError(null)
// Simulate scan progress
const steps = [
{ progress: 10, status: 'Repository wird geklont...' },
{ progress: 25, status: 'Abhängigkeiten werden analysiert...' },
{ progress: 40, status: 'SBOM wird generiert...' },
{ progress: 60, status: 'Schwachstellenscan läuft...' },
{ progress: 80, status: 'Lizenzprüfung...' },
{ progress: 95, status: 'Bericht wird erstellt...' },
{ progress: 100, status: 'Abgeschlossen!' },
]
// Show progress steps while API processes
const progressInterval = setInterval(() => {
setScanProgress(prev => {
if (prev >= 90) return prev
const step = Math.random() * 15 + 5
const next = Math.min(prev + step, 90)
const statuses = [
'Abhaengigkeiten werden analysiert...',
'SBOM wird generiert...',
'Schwachstellenscan laeuft...',
'OSV.dev Datenbank wird abgefragt...',
'Lizenzpruefung...',
]
setScanStatus(statuses[Math.min(Math.floor(next / 20), statuses.length - 1)])
return next
})
}, 600)
for (const step of steps) {
await new Promise(r => setTimeout(r, 800))
setScanProgress(step.progress)
setScanStatus(step.status)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('tenant_id', 'default')
const response = await fetch('/api/sdk/v1/screening/scan', {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.details || err.error || `HTTP ${response.status}`)
}
const data = await response.json()
setScanProgress(100)
setScanStatus('Abgeschlossen!')
// Map backend response to ScreeningResult
const issues: SecurityIssue[] = (data.issues || []).map((i: any) => ({
id: i.id,
severity: i.severity,
title: i.title,
description: i.description,
cve: i.cve || null,
cvss: i.cvss || null,
affectedComponent: i.affected_component,
remediation: i.remediation,
status: i.status || 'OPEN',
}))
const components: SBOMComponent[] = (data.components || []).map((c: any) => ({
name: c.name,
version: c.version,
type: c.type,
purl: c.purl,
licenses: c.licenses || [],
vulnerabilities: c.vulnerabilities || [],
}))
const result: ScreeningResult = {
id: data.id,
status: 'COMPLETED',
startedAt: data.started_at ? new Date(data.started_at) : new Date(),
completedAt: data.completed_at ? new Date(data.completed_at) : new Date(),
sbom: {
format: data.sbom_format || 'CycloneDX',
version: data.sbom_version || '1.5',
components,
dependencies: [],
generatedAt: new Date(),
},
securityScan: {
totalIssues: data.total_issues || issues.length,
critical: data.critical_issues || 0,
high: data.high_issues || 0,
medium: data.medium_issues || 0,
low: data.low_issues || 0,
issues,
},
error: null,
}
dispatch({ type: 'SET_SCREENING', payload: result })
issues.forEach(issue => {
dispatch({ type: 'ADD_SECURITY_ISSUE', payload: issue })
})
} catch (error: any) {
clearInterval(progressInterval)
console.error('Screening scan failed:', error)
setScanError(error.message || 'Scan fehlgeschlagen')
setScanProgress(0)
setScanStatus('')
} finally {
setIsScanning(false)
}
}
// Set mock results
const result: ScreeningResult = {
id: `scan-${Date.now()}`,
status: 'COMPLETED',
startedAt: new Date(Date.now() - 30000),
completedAt: new Date(),
sbom: {
format: 'CycloneDX',
version: '1.5',
components: mockSBOMComponents,
dependencies: [],
generatedAt: new Date(),
},
securityScan: {
totalIssues: mockSecurityIssues.length,
critical: mockSecurityIssues.filter(i => i.severity === 'CRITICAL').length,
high: mockSecurityIssues.filter(i => i.severity === 'HIGH').length,
medium: mockSecurityIssues.filter(i => i.severity === 'MEDIUM').length,
low: mockSecurityIssues.filter(i => i.severity === 'LOW').length,
issues: mockSecurityIssues,
},
error: null,
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
startScan(file)
}
dispatch({ type: 'SET_SCREENING', payload: result })
mockSecurityIssues.forEach(issue => {
dispatch({ type: 'ADD_SECURITY_ISSUE', payload: issue })
})
setIsScanning(false)
}
return (
@@ -314,30 +295,33 @@ export default function ScreeningPage() {
{/* Scan Input */}
{!state.screening && !isScanning && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Repository scannen</h3>
<h3 className="font-semibold text-gray-900 mb-4">Abhaengigkeiten scannen</h3>
<p className="text-sm text-gray-500 mb-4">
Laden Sie eine Abhaengigkeitsdatei hoch, um ein SBOM zu generieren und Schwachstellen zu erkennen.
</p>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt,.lock"
onChange={handleFileSelect}
className="hidden"
/>
<div className="flex gap-4">
<input
type="text"
value={repositoryUrl}
onChange={e => setRepositoryUrl(e.target.value)}
placeholder="https://github.com/organization/repository"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<button
onClick={startScan}
disabled={!repositoryUrl}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
repositoryUrl
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
onClick={() => fileInputRef.current?.click()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
>
Scan starten
Datei auswaehlen & scannen
</button>
</div>
<p className="mt-2 text-sm text-gray-500">
Unterstützte Formate: Git URL, GitHub, GitLab, Bitbucket
Unterstuetzte Formate: package-lock.json, requirements.txt, yarn.lock
</p>
{scanError && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{scanError}
</div>
)}
</div>
)}

View File

@@ -0,0 +1,179 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
interface FullAssessment {
id: string
title: string
tenant_id: string
domain: string
created_at: string
use_case_text?: string
intake?: Record<string, unknown>
result?: {
feasibility: string
risk_level: string
risk_score: number
complexity: string
dsfa_recommended: boolean
art22_risk: boolean
training_allowed: string
summary: string
recommendation: string
alternative_approach?: string
triggered_rules?: Array<{
rule_code: string
title: string
severity: string
gdpr_ref: string
}>
required_controls?: Array<{
id: string
title: string
description: string
effort: string
}>
recommended_architecture?: Array<{
id: string
title: string
description: string
benefit: string
}>
}
}
export default function AssessmentDetailPage() {
const params = useParams()
const router = useRouter()
const assessmentId = params.id as string
const [assessment, setAssessment] = useState<FullAssessment | 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/ucca/assessments/${assessmentId}`)
if (!response.ok) {
throw new Error('Assessment nicht gefunden')
}
const data = await response.json()
setAssessment(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
if (assessmentId) {
// Try the direct endpoint first; if it fails, try the list endpoint and filter
load().catch(() => {
// Fallback: fetch from list
fetch('/api/sdk/v1/ucca/assessments')
.then(r => r.json())
.then(data => {
const found = (data.assessments || []).find((a: FullAssessment) => a.id === assessmentId)
if (found) {
setAssessment(found)
setError(null)
}
})
.catch(() => {})
.finally(() => setLoading(false))
})
}
}, [assessmentId])
const handleDelete = async () => {
if (!confirm('Assessment wirklich loeschen?')) return
try {
await fetch(`/api/sdk/v1/ucca/assessments/${assessmentId}`, { method: 'DELETE' })
router.push('/sdk/use-cases')
} catch {
// Ignore delete errors
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Lade Assessment...</div>
</div>
)
}
if (error || !assessment) {
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 || 'Assessment nicht gefunden'}</p>
</div>
<Link href="/sdk/use-cases" className="text-purple-600 hover:text-purple-700">
Zurueck zur Uebersicht
</Link>
</div>
)
}
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-500">
<Link href="/sdk/use-cases" className="hover:text-purple-600">Use Cases</Link>
<span>/</span>
<span className="text-gray-900">{assessment.title || assessmentId.slice(0, 8)}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{assessment.title || 'Assessment Detail'}</h1>
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span>Domain: {assessment.domain}</span>
<span>Erstellt: {new Date(assessment.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDelete}
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
<Link
href="/sdk/use-cases"
className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Zurueck
</Link>
</div>
</div>
{/* Use Case Text */}
{assessment.use_case_text && (
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-medium text-gray-500 mb-2">Beschreibung des Anwendungsfalls</h3>
<p className="text-gray-800 whitespace-pre-wrap">{assessment.use_case_text}</p>
</div>
)}
{/* Result */}
{assessment.result && (
<AssessmentResultCard result={assessment.result} />
)}
{/* No Result */}
{!assessment.result && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6 text-center">
<p className="text-yellow-700">Dieses Assessment hat noch kein Ergebnis.</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,464 @@
'use client'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
// =============================================================================
// WIZARD STEPS CONFIG
// =============================================================================
const WIZARD_STEPS = [
{ id: 1, title: 'Grundlegendes', description: 'Titel und Beschreibung' },
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
{ id: 3, title: 'Automatisierung', description: 'Grad der Automatisierung' },
{ id: 4, title: 'Hosting & Modell', description: 'Technische Details' },
{ id: 5, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' },
]
const DOMAINS = [
{ value: 'healthcare', label: 'Gesundheit' },
{ value: 'finance', label: 'Finanzen' },
{ value: 'education', label: 'Bildung' },
{ value: 'retail', label: 'Handel' },
{ value: 'it_services', label: 'IT-Dienstleistungen' },
{ value: 'consulting', label: 'Beratung' },
{ value: 'manufacturing', label: 'Produktion' },
{ value: 'hr', label: 'Personalwesen' },
{ value: 'marketing', label: 'Marketing' },
{ value: 'legal', label: 'Recht' },
{ value: 'public', label: 'Oeffentlicher Sektor' },
{ value: 'general', label: 'Allgemein' },
]
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export default function NewUseCasePage() {
const router = useRouter()
const [currentStep, setCurrentStep] = useState(1)
const [isSubmitting, setIsSubmitting] = useState(false)
const [result, setResult] = useState<unknown>(null)
const [error, setError] = useState<string | null>(null)
// Form state
const [form, setForm] = useState({
title: '',
use_case_text: '',
domain: 'general',
// Data Types
personal_data: false,
special_categories: false,
minors_data: false,
health_data: false,
biometric_data: false,
financial_data: false,
// Purpose
purpose_profiling: false,
purpose_automated_decision: false,
purpose_marketing: false,
purpose_analytics: false,
purpose_service_delivery: false,
// Automation
automation: 'assistive' as 'assistive' | 'semi_automated' | 'fully_automated',
// Hosting
hosting_provider: 'self_hosted',
hosting_region: 'eu',
// Model Usage
model_rag: false,
model_finetune: false,
model_training: false,
model_inference: true,
// Retention
retention_days: 90,
retention_purpose: '',
})
const updateForm = (updates: Partial<typeof form>) => {
setForm(prev => ({ ...prev, ...updates }))
}
const handleSubmit = async () => {
setIsSubmitting(true)
setError(null)
try {
const intake = {
title: form.title,
use_case_text: form.use_case_text,
domain: form.domain,
data_types: {
personal_data: form.personal_data,
special_categories: form.special_categories,
minors_data: form.minors_data,
health_data: form.health_data,
biometric_data: form.biometric_data,
financial_data: form.financial_data,
},
purpose: {
profiling: form.purpose_profiling,
automated_decision: form.purpose_automated_decision,
marketing: form.purpose_marketing,
analytics: form.purpose_analytics,
service_delivery: form.purpose_service_delivery,
},
automation: form.automation,
hosting: {
provider: form.hosting_provider,
region: form.hosting_region,
},
model_usage: {
rag: form.model_rag,
finetune: form.model_finetune,
training: form.model_training,
inference: form.model_inference,
},
retention: {
days: form.retention_days,
purpose: form.retention_purpose,
},
store_raw_text: true,
}
const response = await fetch('/api/sdk/v1/ucca/assess', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(intake),
})
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error || `HTTP ${response.status}`)
}
const data = await response.json()
setResult(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler bei der Bewertung')
} finally {
setIsSubmitting(false)
}
}
// If we have a result, show it
if (result) {
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
<div className="flex gap-2">
{r.assessment?.id && (
<button
onClick={() => router.push(`/sdk/use-cases/${r.assessment!.id}`)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Zum Assessment
</button>
)}
<button
onClick={() => router.push('/sdk/use-cases')}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Zur Uebersicht
</button>
</div>
</div>
{r.result && (
<AssessmentResultCard result={r.result as Parameters<typeof AssessmentResultCard>[0]['result']} />
)}
</div>
)
}
return (
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Neues Use Case Assessment</h1>
<p className="mt-1 text-gray-500">
Beschreiben Sie Ihren KI-Anwendungsfall Schritt fuer Schritt
</p>
</div>
{/* Step Indicator */}
<div className="flex items-center gap-2">
{WIZARD_STEPS.map((step, idx) => (
<React.Fragment key={step.id}>
<button
onClick={() => setCurrentStep(step.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
currentStep === step.id
? 'bg-purple-600 text-white'
: currentStep > step.id
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
{currentStep > step.id ? '✓' : step.id}
</span>
<span className="hidden md:inline">{step.title}</span>
</button>
{idx < WIZARD_STEPS.length - 1 && <div className="flex-1 h-px bg-gray-200" />}
</React.Fragment>
))}
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">{error}</div>
)}
{/* Step Content */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
{/* Step 1: Grundlegendes */}
{currentStep === 1 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Grundlegende Informationen</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
<input
type="text"
value={form.title}
onChange={e => updateForm({ title: e.target.value })}
placeholder="z.B. Chatbot fuer Kundenservice"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={form.use_case_text}
onChange={e => updateForm({ use_case_text: e.target.value })}
rows={4}
placeholder="Beschreiben Sie den Anwendungsfall..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
<select
value={form.domain}
onChange={e => updateForm({ domain: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
{DOMAINS.map(d => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
</div>
</div>
)}
{/* Step 2: Datenkategorien */}
{currentStep === 2 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Welche Daten werden verarbeitet?</h2>
{[
{ key: 'personal_data', label: 'Personenbezogene Daten', desc: 'Name, E-Mail, Adresse etc.' },
{ key: 'special_categories', label: 'Besondere Kategorien (Art. 9)', desc: 'Religion, Gesundheit, politische Meinung' },
{ key: 'health_data', label: 'Gesundheitsdaten', desc: 'Diagnosen, Medikation, Fitness' },
{ key: 'biometric_data', label: 'Biometrische Daten', desc: 'Gesichtserkennung, Fingerabdruck, Stimme' },
{ key: 'minors_data', label: 'Daten von Minderjaehrigen', desc: 'Unter 16 Jahren' },
{ key: 'financial_data', label: 'Finanzdaten', desc: 'Kontodaten, Transaktionen, Kreditwuerdigkeit' },
].map(item => (
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
<input
type="checkbox"
checked={form[item.key as keyof typeof form] as boolean}
onChange={e => updateForm({ [item.key]: e.target.checked })}
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">{item.label}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
</label>
))}
<h3 className="text-sm font-medium text-gray-700 mt-4">Zweck der Verarbeitung</h3>
{[
{ key: 'purpose_profiling', label: 'Profiling', desc: 'Automatisierte Analyse personenbezogener Aspekte' },
{ key: 'purpose_automated_decision', label: 'Automatisierte Entscheidung', desc: 'Art. 22 DSGVO — Entscheidung ohne menschliches Zutun' },
{ key: 'purpose_marketing', label: 'Marketing', desc: 'Werbung, Personalisierung, Targeting' },
{ key: 'purpose_analytics', label: 'Analytics', desc: 'Statistische Auswertung, Business Intelligence' },
{ key: 'purpose_service_delivery', label: 'Serviceerbringung', desc: 'Kernfunktion des Produkts/Services' },
].map(item => (
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
<input
type="checkbox"
checked={form[item.key as keyof typeof form] as boolean}
onChange={e => updateForm({ [item.key]: e.target.checked })}
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">{item.label}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
</label>
))}
</div>
)}
{/* Step 3: Automatisierung */}
{currentStep === 3 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
{[
{ value: 'assistive', label: 'Assistiv', desc: 'KI unterstuetzt, Mensch entscheidet immer' },
{ value: 'semi_automated', label: 'Teilautomatisiert', desc: 'KI schlaegt vor, Mensch prueft und bestaetigt' },
{ value: 'fully_automated', label: 'Vollautomatisiert', desc: 'KI entscheidet autonom, Mensch ueberwacht nur' },
].map(item => (
<label
key={item.value}
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
form.automation === item.value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="automation"
value={item.value}
checked={form.automation === item.value}
onChange={e => updateForm({ automation: e.target.value as typeof form.automation })}
className="mt-1 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">{item.label}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
</label>
))}
</div>
)}
{/* Step 4: Hosting & Modell */}
{currentStep === 4 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hosting</label>
<select
value={form.hosting_provider}
onChange={e => updateForm({ hosting_provider: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="self_hosted">Eigenes Hosting</option>
<option value="aws">AWS</option>
<option value="azure">Microsoft Azure</option>
<option value="gcp">Google Cloud</option>
<option value="hetzner">Hetzner (DE)</option>
<option value="other">Anderer Anbieter</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
<select
value={form.hosting_region}
onChange={e => updateForm({ hosting_region: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="eu">EU</option>
<option value="de">Deutschland</option>
<option value="us">USA</option>
<option value="other">Andere</option>
</select>
</div>
<h3 className="text-sm font-medium text-gray-700 mt-4">Modell-Nutzung</h3>
{[
{ key: 'model_inference', label: 'Inferenz', desc: 'Vortrainiertes Modell nutzen (Standard)' },
{ key: 'model_rag', label: 'RAG (Retrieval-Augmented)', desc: 'Eigene Daten als Kontext bereitstellen' },
{ key: 'model_finetune', label: 'Fine-Tuning', desc: 'Modell mit eigenen Daten nachtrainieren' },
{ key: 'model_training', label: 'Training', desc: 'Eigenes Modell von Grund auf trainieren' },
].map(item => (
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
<input
type="checkbox"
checked={form[item.key as keyof typeof form] as boolean}
onChange={e => updateForm({ [item.key]: e.target.checked })}
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">{item.label}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
</label>
))}
</div>
)}
{/* Step 5: Datenhaltung */}
{currentStep === 5 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Aufbewahrungsdauer (Tage)
</label>
<input
type="number"
min={0}
value={form.retention_days}
onChange={e => updateForm({ retention_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Zweck der Aufbewahrung
</label>
<textarea
value={form.retention_purpose}
onChange={e => updateForm({ retention_purpose: e.target.value })}
rows={3}
placeholder="z.B. Vertragliche Pflichten, gesetzliche Aufbewahrungsfristen..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
)}
</div>
{/* Navigation Buttons */}
<div className="flex items-center justify-between">
<button
onClick={() => currentStep > 1 ? setCurrentStep(currentStep - 1) : router.push('/sdk/use-cases')}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
{currentStep < 5 ? (
<button
onClick={() => setCurrentStep(currentStep + 1)}
disabled={currentStep === 1 && !form.title}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
Weiter
</button>
) : (
<button
onClick={handleSubmit}
disabled={isSubmitting || !form.title}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSubmitting ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Bewerte...
</>
) : (
'Assessment starten'
)}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import { RiskScoreGauge } from '@/components/sdk/use-case-assessment/RiskScoreGauge'
interface Assessment {
id: string
title: string
feasibility: string
risk_level: string
risk_score: number
domain: string
created_at: string
}
const FEASIBILITY_STYLES: Record<string, { bg: string; text: string; label: string }> = {
YES: { bg: 'bg-green-100', text: 'text-green-700', label: 'Machbar' },
CONDITIONAL: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Bedingt' },
NO: { bg: 'bg-red-100', text: 'text-red-700', label: 'Nein' },
}
export default function UseCasesPage() {
const [assessments, setAssessments] = useState<Assessment[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterFeasibility, setFilterFeasibility] = useState<string>('all')
const [filterRisk, setFilterRisk] = useState<string>('all')
useEffect(() => {
fetchAssessments()
}, [])
async function fetchAssessments() {
try {
setLoading(true)
const response = await fetch('/api/sdk/v1/ucca/assessments')
if (!response.ok) {
throw new Error('Fehler beim Laden der Assessments')
}
const data = await response.json()
setAssessments(data.assessments || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const filtered = assessments.filter(a => {
if (filterFeasibility !== 'all' && a.feasibility !== filterFeasibility) return false
if (filterRisk !== 'all' && a.risk_level !== filterRisk) return false
return true
})
const stats = {
total: assessments.length,
feasible: assessments.filter(a => a.feasibility === 'YES').length,
conditional: assessments.filter(a => a.feasibility === 'CONDITIONAL').length,
rejected: assessments.filter(a => a.feasibility === 'NO').length,
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Use Case Assessment</h1>
<p className="mt-1 text-gray-500">
KI-Anwendungsfaelle erfassen und auf Compliance pruefen
</p>
</div>
<Link
href="/sdk/use-cases/new"
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>
Neues Assessment
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Machbar</div>
<div className="text-3xl font-bold text-green-600">{stats.feasible}</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-6">
<div className="text-sm text-yellow-600">Bedingt</div>
<div className="text-3xl font-bold text-yellow-600">{stats.conditional}</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6">
<div className="text-sm text-red-600">Abgelehnt</div>
<div className="text-3xl font-bold text-red-600">{stats.rejected}</div>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Machbarkeit:</span>
{['all', 'YES', 'CONDITIONAL', 'NO'].map(f => (
<button
key={f}
onClick={() => setFilterFeasibility(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filterFeasibility === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' : FEASIBILITY_STYLES[f]?.label || f}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Risiko:</span>
{['all', 'MINIMAL', 'LOW', 'MEDIUM', 'HIGH', 'UNACCEPTABLE'].map(f => (
<button
key={f}
onClick={() => setFilterRisk(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filterRisk === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' : f}
</button>
))}
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
<button onClick={fetchAssessments} className="ml-3 underline">Erneut versuchen</button>
</div>
)}
{/* Loading */}
{loading && (
<div className="text-center py-12 text-gray-500">Lade Assessments...</div>
)}
{/* Assessment List */}
{!loading && filtered.length > 0 && (
<div className="space-y-4">
{filtered.map(assessment => {
const feasibility = FEASIBILITY_STYLES[assessment.feasibility] || FEASIBILITY_STYLES.YES
return (
<Link
key={assessment.id}
href={`/sdk/use-cases/${assessment.id}`}
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-md transition-all"
>
<div className="flex items-center gap-6">
<RiskScoreGauge score={assessment.risk_score} riskLevel={assessment.risk_level} size="sm" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">{assessment.title || 'Unbenanntes Assessment'}</h3>
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
{feasibility.label}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>{assessment.domain}</span>
<span>{new Date(assessment.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
)
})}
</div>
)}
{/* Empty State */}
{!loading && filtered.length === 0 && !error && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Noch keine Assessments</h3>
<p className="mt-2 text-gray-500 mb-4">
Erstellen Sie Ihr erstes Use Case Assessment, um die Compliance-Bewertung zu starten.
</p>
<Link
href="/sdk/use-cases/new"
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Erstes Assessment erstellen
</Link>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy: GET /api/sdk/v1/company-profile → Backend GET /api/v1/company-profile
*/
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/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
{
headers: {
'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 company profile:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}
/**
* Proxy: POST /api/sdk/v1/company-profile → Backend POST /api/v1/company-profile
*/
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/company-profile?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 company profile:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy: POST /api/sdk/v1/import/analyze → Backend POST /api/v1/import/analyze
* Forwards multipart form data (PDF file upload) to the backend for analysis.
*/
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const response = await fetch(`${BACKEND_URL}/api/v1/import/analyze`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorText = await response.text()
console.error('Import analyze error:', errorText)
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 call import analyze:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy to backend-compliance /api/modules endpoint.
* Returns the list of service modules from the database.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const params = new URLSearchParams()
// Forward filter params
const serviceType = searchParams.get('service_type')
const criticality = searchParams.get('criticality')
const processesPii = searchParams.get('processes_pii')
const aiComponents = searchParams.get('ai_components')
if (serviceType) params.set('service_type', serviceType)
if (criticality) params.set('criticality', criticality)
if (processesPii) params.set('processes_pii', processesPii)
if (aiComponents) params.set('ai_components', aiComponents)
const queryString = params.toString()
const url = `${BACKEND_URL}/api/modules${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
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()
console.error('Backend modules error:', errorText)
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 modules from backend:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy: POST /api/sdk/v1/screening/scan → Backend POST /api/v1/screening/scan
* Forwards multipart form data (dependency file upload) to the backend for scanning.
*/
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const response = await fetch(`${BACKEND_URL}/api/v1/screening/scan`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorText = await response.text()
console.error('Screening scan error:', errorText)
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 call screening scan:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { Pool } from 'pg'
/**
* SDK State Management API
@@ -11,7 +12,7 @@ import { NextRequest, NextResponse } from 'next/server'
* - Versioning for optimistic locking
* - Last-Modified headers
* - ETag support for caching
* - Prepared for PostgreSQL migration
* - PostgreSQL persistence (with InMemory fallback)
*/
// =============================================================================
@@ -27,27 +28,9 @@ interface StoredState {
}
// =============================================================================
// STORAGE LAYER (Abstract - Easy to swap to PostgreSQL)
// STORAGE LAYER
// =============================================================================
/**
* In-memory storage for development
* TODO: Replace with PostgreSQL implementation
*
* PostgreSQL Schema:
* CREATE TABLE sdk_states (
* id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
* tenant_id VARCHAR(255) NOT NULL UNIQUE,
* user_id VARCHAR(255),
* state JSONB NOT NULL,
* version INTEGER DEFAULT 1,
* created_at TIMESTAMP DEFAULT NOW(),
* updated_at TIMESTAMP DEFAULT NOW()
* );
*
* CREATE INDEX idx_sdk_states_tenant ON sdk_states(tenant_id);
*/
interface StateStore {
get(tenantId: string): Promise<StoredState | null>
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState>
@@ -69,7 +52,6 @@ class InMemoryStateStore implements StateStore {
): Promise<StoredState> {
const existing = this.store.get(tenantId)
// Optimistic locking check
if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) {
const error = new Error('Version conflict') as Error & { status: number }
error.status = 409
@@ -99,68 +81,94 @@ class InMemoryStateStore implements StateStore {
}
}
// Future PostgreSQL implementation would look like:
// class PostgreSQLStateStore implements StateStore {
// private db: Pool
//
// constructor(connectionString: string) {
// this.db = new Pool({ connectionString })
// }
//
// async get(tenantId: string): Promise<StoredState | null> {
// const result = await this.db.query(
// 'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1',
// [tenantId]
// )
// if (result.rows.length === 0) return null
// const row = result.rows[0]
// return {
// state: row.state,
// version: row.version,
// userId: row.user_id,
// createdAt: row.created_at,
// updatedAt: row.updated_at,
// }
// }
//
// async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState> {
// // Use UPSERT with version check
// const result = await this.db.query(`
// INSERT INTO sdk_states (tenant_id, user_id, state, version)
// VALUES ($1, $2, $3, 1)
// ON CONFLICT (tenant_id) DO UPDATE SET
// state = $3,
// user_id = COALESCE($2, sdk_states.user_id),
// version = sdk_states.version + 1,
// updated_at = NOW()
// WHERE ($4::int IS NULL OR sdk_states.version = $4)
// RETURNING version, created_at, updated_at
// `, [tenantId, userId, JSON.stringify(state), expectedVersion])
//
// if (result.rows.length === 0) {
// throw new Error('Version conflict')
// }
//
// return {
// state,
// version: result.rows[0].version,
// userId,
// createdAt: result.rows[0].created_at,
// updatedAt: result.rows[0].updated_at,
// }
// }
//
// async delete(tenantId: string): Promise<boolean> {
// const result = await this.db.query(
// 'DELETE FROM sdk_states WHERE tenant_id = $1',
// [tenantId]
// )
// return result.rowCount > 0
// }
// }
class PostgreSQLStateStore implements StateStore {
private pool: Pool
// Use in-memory store for now
const stateStore: StateStore = new InMemoryStateStore()
constructor(connectionString: string) {
this.pool = new Pool({
connectionString,
max: 5,
// Set search_path for compliance schema
options: '-c search_path=compliance,core,public',
})
}
async get(tenantId: string): Promise<StoredState | null> {
const result = await this.pool.query(
'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1',
[tenantId]
)
if (result.rows.length === 0) return null
const row = result.rows[0]
return {
state: row.state,
version: row.version,
userId: row.user_id,
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
}
}
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState> {
const now = new Date().toISOString()
const stateWithTimestamp = {
...(state as object),
lastModified: now,
}
// Use UPSERT with version check
const result = await this.pool.query(`
INSERT INTO sdk_states (tenant_id, user_id, state, version, created_at, updated_at)
VALUES ($1, $2, $3::jsonb, 1, NOW(), NOW())
ON CONFLICT (tenant_id) DO UPDATE SET
state = $3::jsonb,
user_id = COALESCE($2, sdk_states.user_id),
version = sdk_states.version + 1,
updated_at = NOW()
WHERE ($4::int IS NULL OR sdk_states.version = $4)
RETURNING version, user_id, created_at, updated_at
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null])
if (result.rows.length === 0) {
const error = new Error('Version conflict') as Error & { status: number }
error.status = 409
throw error
}
const row = result.rows[0]
return {
state: stateWithTimestamp,
version: row.version,
userId: row.user_id,
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
}
}
async delete(tenantId: string): Promise<boolean> {
const result = await this.pool.query(
'DELETE FROM sdk_states WHERE tenant_id = $1',
[tenantId]
)
return (result.rowCount ?? 0) > 0
}
}
// =============================================================================
// STORE INITIALIZATION
// =============================================================================
function createStateStore(): StateStore {
const databaseUrl = process.env.DATABASE_URL
if (databaseUrl) {
console.log('[SDK State] Using PostgreSQL state store')
return new PostgreSQLStateStore(databaseUrl)
}
console.log('[SDK State] Using in-memory state store (no DATABASE_URL)')
return new InMemoryStateStore()
}
const stateStore: StateStore = createStateStore()
// =============================================================================
// HELPER FUNCTIONS

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
/**
* Proxy: POST /api/sdk/v1/ucca/assess → Go Backend POST /sdk/v1/ucca/assess
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/assess`, {
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()
console.error('UCCA assess error:', errorText)
return NextResponse.json(
{ error: 'UCCA backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data, { status: 201 })
} catch (error) {
console.error('Failed to call UCCA assess:', error)
return NextResponse.json(
{ error: 'Failed to connect to UCCA backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
/**
* Proxy: GET /api/sdk/v1/ucca/assessments → Go Backend GET /sdk/v1/ucca/assessments
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const params = new URLSearchParams()
// Forward filter params
for (const [key, value] of searchParams.entries()) {
params.set(key, value)
}
const queryString = params.toString()
const url = `${SDK_URL}/sdk/v1/ucca/assessments${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
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: 'UCCA backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to fetch UCCA assessments:', error)
return NextResponse.json(
{ error: 'Failed to connect to UCCA backend' },
{ status: 503 }
)
}
}

View File

@@ -148,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 Lehrer KI
Compliance Admin
</Link>
)}
<button

View File

@@ -0,0 +1,154 @@
'use client'
import { RiskScoreGauge } from './RiskScoreGauge'
interface AssessmentResult {
feasibility: string
risk_level: string
risk_score: number
complexity: string
dsfa_recommended: boolean
art22_risk: boolean
training_allowed: string
summary: string
recommendation: string
alternative_approach?: string
triggered_rules?: Array<{
rule_code: string
title: string
severity: string
gdpr_ref: string
}>
required_controls?: Array<{
id: string
title: string
description: string
effort: string
}>
recommended_architecture?: Array<{
id: string
title: string
description: string
benefit: string
}>
}
interface AssessmentResultCardProps {
result: AssessmentResult
}
const FEASIBILITY_STYLES: Record<string, { bg: string; text: string; label: string }> = {
YES: { bg: 'bg-green-100', text: 'text-green-700', label: 'Machbar' },
CONDITIONAL: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Bedingt machbar' },
NO: { bg: 'bg-red-100', text: 'text-red-700', label: 'Nicht empfohlen' },
}
const SEVERITY_STYLES: Record<string, string> = {
INFO: 'bg-blue-100 text-blue-700',
WARN: 'bg-yellow-100 text-yellow-700',
BLOCK: 'bg-red-100 text-red-700',
}
export function AssessmentResultCard({ result }: AssessmentResultCardProps) {
const feasibility = FEASIBILITY_STYLES[result.feasibility] || FEASIBILITY_STYLES.YES
return (
<div className="space-y-6">
{/* Header with Score and Feasibility */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start gap-6">
<RiskScoreGauge score={result.risk_score} riskLevel={result.risk_level} size="lg" />
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${feasibility.bg} ${feasibility.text}`}>
{feasibility.label}
</span>
<span className="px-3 py-1 rounded-full text-sm bg-gray-100 text-gray-700">
Komplexitaet: {result.complexity}
</span>
{result.dsfa_recommended && (
<span className="px-3 py-1 rounded-full text-sm bg-orange-100 text-orange-700">
DSFA empfohlen
</span>
)}
{result.art22_risk && (
<span className="px-3 py-1 rounded-full text-sm bg-red-100 text-red-700">
Art. 22 Risiko
</span>
)}
</div>
<p className="text-gray-700">{result.summary}</p>
<p className="text-sm text-gray-500 mt-2">{result.recommendation}</p>
{result.alternative_approach && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg text-sm text-blue-700">
<span className="font-medium">Alternative: </span>
{result.alternative_approach}
</div>
)}
</div>
</div>
</div>
{/* Triggered Rules */}
{result.triggered_rules && result.triggered_rules.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Ausgeloeste Regeln ({result.triggered_rules.length})
</h3>
<div className="space-y-2">
{result.triggered_rules.map((rule) => (
<div key={rule.rule_code} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<span className={`px-2 py-1 text-xs rounded-full ${SEVERITY_STYLES[rule.severity] || 'bg-gray-100 text-gray-700'}`}>
{rule.severity}
</span>
<span className="text-xs text-gray-400 font-mono">{rule.rule_code}</span>
<span className="text-sm font-medium text-gray-800 flex-1">{rule.title}</span>
<span className="text-xs text-purple-600">{rule.gdpr_ref}</span>
</div>
))}
</div>
</div>
)}
{/* Required Controls */}
{result.required_controls && result.required_controls.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Erforderliche Kontrollen ({result.required_controls.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{result.required_controls.map((control) => (
<div key={control.id} className="p-4 border border-gray-200 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-gray-900 text-sm">{control.title}</span>
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
{control.effort}
</span>
</div>
<p className="text-xs text-gray-500">{control.description}</p>
</div>
))}
</div>
</div>
)}
{/* Recommended Architecture Patterns */}
{result.recommended_architecture && result.recommended_architecture.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Empfohlene Architektur-Patterns
</h3>
<div className="space-y-3">
{result.recommended_architecture.map((pattern) => (
<div key={pattern.id} className="p-4 bg-purple-50 border border-purple-200 rounded-lg">
<h4 className="font-medium text-purple-900">{pattern.title}</h4>
<p className="text-sm text-purple-700 mt-1">{pattern.description}</p>
<p className="text-xs text-purple-600 mt-2">Vorteil: {pattern.benefit}</p>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
interface RiskScoreGaugeProps {
score: number // 0-100
riskLevel: string
size?: 'sm' | 'md' | 'lg'
}
const RISK_COLORS: Record<string, string> = {
MINIMAL: '#22c55e',
LOW: '#84cc16',
MEDIUM: '#eab308',
HIGH: '#f97316',
UNACCEPTABLE: '#ef4444',
}
const RISK_LABELS: Record<string, string> = {
MINIMAL: 'Minimal',
LOW: 'Niedrig',
MEDIUM: 'Mittel',
HIGH: 'Hoch',
UNACCEPTABLE: 'Unzulaessig',
}
export function RiskScoreGauge({ score, riskLevel, size = 'md' }: RiskScoreGaugeProps) {
const color = RISK_COLORS[riskLevel] || '#9ca3af'
const label = RISK_LABELS[riskLevel] || riskLevel
const sizes = {
sm: { w: 80, r: 30, stroke: 6, fontSize: '1rem', labelSize: '0.65rem' },
md: { w: 120, r: 46, stroke: 8, fontSize: '1.5rem', labelSize: '0.75rem' },
lg: { w: 160, r: 62, stroke: 10, fontSize: '2rem', labelSize: '0.875rem' },
}
const s = sizes[size]
const circumference = 2 * Math.PI * s.r
const dashOffset = circumference - (score / 100) * circumference
return (
<div className="flex flex-col items-center">
<svg width={s.w} height={s.w} viewBox={`0 0 ${s.w} ${s.w}`}>
{/* Background circle */}
<circle
cx={s.w / 2}
cy={s.w / 2}
r={s.r}
fill="none"
stroke="#e5e7eb"
strokeWidth={s.stroke}
/>
{/* Score arc */}
<circle
cx={s.w / 2}
cy={s.w / 2}
r={s.r}
fill="none"
stroke={color}
strokeWidth={s.stroke}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
strokeLinecap="round"
transform={`rotate(-90 ${s.w / 2} ${s.w / 2})`}
className="transition-all duration-500"
/>
{/* Score text */}
<text
x={s.w / 2}
y={s.w / 2}
textAnchor="middle"
dominantBaseline="central"
fill={color}
style={{ fontSize: s.fontSize, fontWeight: 700 }}
>
{score}
</text>
</svg>
<span
className="mt-1 font-medium"
style={{ color, fontSize: s.labelSize }}
>
{label}
</span>
</div>
)
}

View File

@@ -0,0 +1,110 @@
/**
* Tests for SDK API Client extensions (modules, UCCA, import, screening).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch
// Import after mocking
import { sdkApiClient } from '../api-client'
describe('SDK API Client', () => {
beforeEach(() => {
mockFetch.mockReset()
})
describe('getModules', () => {
it('fetches modules from backend', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([{ id: 'mod-1', name: 'DSGVO' }]),
})
const result = await sdkApiClient.getModules()
expect(result).toHaveLength(1)
expect(result[0].name).toBe('DSGVO')
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/sdk/v1/modules'),
expect.any(Object)
)
})
it('returns empty array on error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const result = await sdkApiClient.getModules()
expect(result).toEqual([])
})
})
describe('analyzeDocument', () => {
it('sends FormData to import analyze endpoint', async () => {
const mockResponse = {
document_id: 'doc-1',
detected_type: 'DSFA',
confidence: 0.85,
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const formData = new FormData()
const result = await sdkApiClient.analyzeDocument(formData)
expect(result.document_id).toBe('doc-1')
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/sdk/v1/import/analyze'),
expect.objectContaining({ method: 'POST' })
)
})
})
describe('scanDependencies', () => {
it('sends FormData to screening scan endpoint', async () => {
const mockResponse = {
id: 'scan-1',
status: 'completed',
total_components: 10,
total_issues: 2,
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const formData = new FormData()
const result = await sdkApiClient.scanDependencies(formData)
expect(result.id).toBe('scan-1')
expect(result.total_components).toBe(10)
})
})
describe('assessUseCase', () => {
it('sends intake data to UCCA assess endpoint', async () => {
const mockResult = { id: 'assessment-1', feasibility: 'GREEN' }
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResult),
})
const result = await sdkApiClient.assessUseCase({
name: 'Test Use Case',
domain: 'education',
})
expect(result.feasibility).toBe('GREEN')
})
})
describe('getAssessments', () => {
it('fetches assessment list', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([{ id: 'a1' }, { id: 'a2' }]),
})
const result = await sdkApiClient.getAssessments()
expect(result).toHaveLength(2)
})
})
})

View File

@@ -372,6 +372,153 @@ export class SDKApiClient {
return response.data
}
// ---------------------------------------------------------------------------
// Public Methods - Modules
// ---------------------------------------------------------------------------
/**
* Get available compliance modules from backend
*/
async getModules(filters?: {
serviceType?: string
criticality?: string
processesPii?: boolean
aiComponents?: boolean
}): Promise<{ modules: unknown[]; total: number }> {
const params = new URLSearchParams()
if (filters?.serviceType) params.set('service_type', filters.serviceType)
if (filters?.criticality) params.set('criticality', filters.criticality)
if (filters?.processesPii !== undefined) params.set('processes_pii', String(filters.processesPii))
if (filters?.aiComponents !== undefined) params.set('ai_components', String(filters.aiComponents))
const queryString = params.toString()
const url = `${this.baseUrl}/modules${queryString ? `?${queryString}` : ''}`
const response = await this.fetchWithRetry<{ modules: unknown[]; total: number }>(
url,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}
)
return response
}
// ---------------------------------------------------------------------------
// Public Methods - UCCA (Use Case Compliance Assessment)
// ---------------------------------------------------------------------------
/**
* Assess a use case
*/
async assessUseCase(intake: unknown): Promise<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
`${this.baseUrl}/ucca/assess`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': this.tenantId,
},
body: JSON.stringify(intake),
}
)
return response
}
/**
* Get all assessments
*/
async getAssessments(): Promise<unknown[]> {
const response = await this.fetchWithRetry<APIResponse<unknown[]>>(
`${this.baseUrl}/ucca/assessments?tenantId=${encodeURIComponent(this.tenantId)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': this.tenantId,
},
}
)
return response.data || []
}
/**
* Get a single assessment
*/
async getAssessment(id: string): Promise<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
`${this.baseUrl}/ucca/assessments/${id}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': this.tenantId,
},
}
)
return response.data
}
/**
* Delete an assessment
*/
async deleteAssessment(id: string): Promise<void> {
await this.fetchWithRetry<APIResponse<void>>(
`${this.baseUrl}/ucca/assessments/${id}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': this.tenantId,
},
}
)
}
// ---------------------------------------------------------------------------
// Public Methods - Document Import
// ---------------------------------------------------------------------------
/**
* Analyze an uploaded document
*/
async analyzeDocument(formData: FormData): Promise<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
`${this.baseUrl}/import/analyze`,
{
method: 'POST',
headers: {
'X-Tenant-ID': this.tenantId,
},
body: formData,
}
)
return response.data
}
// ---------------------------------------------------------------------------
// Public Methods - System Screening
// ---------------------------------------------------------------------------
/**
* Scan a dependency file (package-lock.json, requirements.txt, etc.)
*/
async scanDependencies(formData: FormData): Promise<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
`${this.baseUrl}/screening/scan`,
{
method: 'POST',
headers: {
'X-Tenant-ID': this.tenantId,
},
body: formData,
}
)
return response.data
}
// ---------------------------------------------------------------------------
// Public Methods - Export
// ---------------------------------------------------------------------------

View File

@@ -65,6 +65,9 @@ const initialState: SDKState = {
// Compliance Scope
complianceScope: null,
// Source Policy
sourcePolicy: null,
// Progress
currentPhase: 1,
currentStep: 'company-profile',

View File

@@ -15,6 +15,7 @@ describe('StateProjector', () => {
customerType: null,
companyProfile: null,
complianceScope: null,
sourcePolicy: null,
currentPhase: 1,
currentStep: 'company-profile',
completedSteps: [],

View File

@@ -1483,6 +1483,14 @@ export interface SDKState {
// Compliance Scope (determines depth level L1-L4)
complianceScope: import('./compliance-scope-types').ComplianceScopeState | null
// Source Policy (checkpoint tracking — actual data in backend)
sourcePolicy: {
configured: boolean
sourcesCount: number
piiRulesCount: number
lastAuditAt: string | null
} | null
// Progress
currentPhase: SDKPhase
currentStep: string

View File

@@ -23,6 +23,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",