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
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:
@@ -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',
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
179
admin-compliance/app/(sdk)/sdk/use-cases/[id]/page.tsx
Normal file
179
admin-compliance/app/(sdk)/sdk/use-cases/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
464
admin-compliance/app/(sdk)/sdk/use-cases/new/page.tsx
Normal file
464
admin-compliance/app/(sdk)/sdk/use-cases/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
admin-compliance/app/(sdk)/sdk/use-cases/page.tsx
Normal file
210
admin-compliance/app/(sdk)/sdk/use-cases/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
admin-compliance/app/api/sdk/v1/company-profile/route.ts
Normal file
81
admin-compliance/app/api/sdk/v1/company-profile/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
36
admin-compliance/app/api/sdk/v1/import/analyze/route.ts
Normal file
36
admin-compliance/app/api/sdk/v1/import/analyze/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
56
admin-compliance/app/api/sdk/v1/modules/route.ts
Normal file
56
admin-compliance/app/api/sdk/v1/modules/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
36
admin-compliance/app/api/sdk/v1/screening/scan/route.ts
Normal file
36
admin-compliance/app/api/sdk/v1/screening/scan/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
41
admin-compliance/app/api/sdk/v1/ucca/assess/route.ts
Normal file
41
admin-compliance/app/api/sdk/v1/ucca/assess/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
48
admin-compliance/app/api/sdk/v1/ucca/assessments/route.ts
Normal file
48
admin-compliance/app/api/sdk/v1/ucca/assessments/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
110
admin-compliance/lib/sdk/__tests__/api-client.test.ts
Normal file
110
admin-compliance/lib/sdk/__tests__/api-client.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -65,6 +65,9 @@ const initialState: SDKState = {
|
||||
// Compliance Scope
|
||||
complianceScope: null,
|
||||
|
||||
// Source Policy
|
||||
sourcePolicy: null,
|
||||
|
||||
// Progress
|
||||
currentPhase: 1,
|
||||
currentStep: 'company-profile',
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('StateProjector', () => {
|
||||
customerType: null,
|
||||
companyProfile: null,
|
||||
complianceScope: null,
|
||||
sourcePolicy: null,
|
||||
currentPhase: 1,
|
||||
currentStep: 'company-profile',
|
||||
completedSteps: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user