feat: Payment Terminal Compliance Modul — Phase 1+2

1. Control-Bibliothek: 130 Controls in 10 Domaenen (payment_controls_v1.json)
   - PAY (20): Transaction Flow, Idempotenz, State Machine
   - LOG (15): Audit Trail, PAN-Maskierung, Event-Typen
   - CRYPTO (15): Secrets, HSM, P2PE, TLS
   - API (15): Auth, RBAC, Rate Limiting, Injection
   - TERM (15): ZVT/OPI, Heartbeat, Offline-Queue
   - FW (10): Firmware Signing, Secure Boot, Tamper Detection
   - REP (10): Reconciliation, Tagesabschluss, GoBD
   - ACC (10): MFA, Session, Least Privilege
   - ERR (10): Recovery, Circuit Breaker, Offline-Modus
   - BLD (10): CI/CD, SBOM, Container Scanning
2. Backend: DB Migration 024, Go Handler (5 Endpoints), Routes
3. Frontend: /sdk/payment-compliance mit Control-Browser + Assessment-Wizard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-13 07:51:59 +02:00
parent dd64e33e88
commit 38d3d24121
6 changed files with 878 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const endpoint = searchParams.get('endpoint') || 'controls'
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
let path: string
switch (endpoint) {
case 'controls':
const domain = searchParams.get('domain') || ''
path = `/sdk/v1/payment-compliance/controls${domain ? `?domain=${domain}` : ''}`
break
case 'assessments':
path = '/sdk/v1/payment-compliance/assessments'
break
default:
path = '/sdk/v1/payment-compliance/controls'
}
const resp = await fetch(`${SDK_URL}${path}`, {
headers: { 'X-Tenant-ID': tenantId },
})
const data = await resp.json()
return NextResponse.json(data)
} catch (err) {
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const body = await request.json()
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/assessments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
body: JSON.stringify(body),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (err) {
return NextResponse.json({ error: 'Failed to create' }, { status: 500 })
}
}

View File

@@ -0,0 +1,291 @@
'use client'
import React, { useState, useEffect } from 'react'
interface PaymentControl {
control_id: string
domain: string
title: string
objective: string
check_target: string
evidence: string[]
automation: string
}
interface PaymentDomain {
id: string
name: string
description: string
}
interface Assessment {
id: string
project_name: string
tender_reference: string
customer_name: string
system_type: string
total_controls: number
controls_passed: number
controls_failed: number
controls_partial: number
controls_not_applicable: number
controls_not_checked: number
compliance_score: number
status: string
created_at: string
}
const AUTOMATION_STYLES: Record<string, { bg: string; text: string }> = {
high: { bg: 'bg-green-100', text: 'text-green-700' },
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
partial: { bg: 'bg-orange-100', text: 'text-orange-700' },
low: { bg: 'bg-red-100', text: 'text-red-700' },
}
const TARGET_ICONS: Record<string, string> = {
code: '💻', system: '🖥️', config: '⚙️', process: '📋',
repository: '📦', certificate: '📜',
}
export default function PaymentCompliancePage() {
const [controls, setControls] = useState<PaymentControl[]>([])
const [domains, setDomains] = useState<PaymentDomain[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [selectedDomain, setSelectedDomain] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<'controls' | 'assessments'>('controls')
const [showNewAssessment, setShowNewAssessment] = useState(false)
const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
useEffect(() => {
loadData()
}, [])
async function loadData() {
try {
setLoading(true)
const [ctrlResp, assessResp] = await Promise.all([
fetch('/api/sdk/v1/payment-compliance?endpoint=controls'),
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
])
if (ctrlResp.ok) {
const data = await ctrlResp.json()
setControls(data.controls || [])
setDomains(data.domains || [])
}
if (assessResp.ok) {
const data = await assessResp.json()
setAssessments(data.assessments || [])
}
} catch {}
finally { setLoading(false) }
}
async function handleCreateAssessment() {
const resp = await fetch('/api/sdk/v1/payment-compliance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProject),
})
if (resp.ok) {
setShowNewAssessment(false)
setNewProject({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
loadData()
}
}
const filteredControls = selectedDomain === 'all'
? controls
: controls.filter(c => c.domain === selectedDomain)
const domainStats = domains.map(d => ({
...d,
count: controls.filter(c => c.domain === d.id).length,
}))
return (
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Payment Terminal Compliance</h1>
<p className="text-sm text-gray-500 mt-1">
Technische Pruefbibliothek fuer Zahlungssysteme {controls.length} Controls in {domains.length} Domaenen
</p>
</div>
<div className="flex gap-2">
<button onClick={() => setTab('controls')}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'controls' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
Controls ({controls.length})
</button>
<button onClick={() => setTab('assessments')}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'assessments' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
Assessments ({assessments.length})
</button>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-gray-500">Lade...</div>
) : tab === 'controls' ? (
<>
{/* Domain Filter */}
<div className="grid grid-cols-5 gap-3 mb-6">
<button onClick={() => setSelectedDomain('all')}
className={`p-3 rounded-xl border text-center ${selectedDomain === 'all' ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
<div className="text-lg font-bold text-purple-700">{controls.length}</div>
<div className="text-xs text-gray-500">Alle</div>
</button>
{domainStats.map(d => (
<button key={d.id} onClick={() => setSelectedDomain(d.id)}
className={`p-3 rounded-xl border text-center ${selectedDomain === d.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
<div className="text-lg font-bold text-gray-900">{d.count}</div>
<div className="text-xs text-gray-500 truncate">{d.id}</div>
</button>
))}
</div>
{/* Domain Description */}
{selectedDomain !== 'all' && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
<strong>{domains.find(d => d.id === selectedDomain)?.name}:</strong>{' '}
{domains.find(d => d.id === selectedDomain)?.description}
</div>
)}
{/* Controls List */}
<div className="space-y-3">
{filteredControls.map(ctrl => {
const autoStyle = AUTOMATION_STYLES[ctrl.automation] || AUTOMATION_STYLES.low
return (
<div key={ctrl.control_id} className="bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
<span className="text-xs text-gray-400">{TARGET_ICONS[ctrl.check_target] || '🔍'} {ctrl.check_target}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${autoStyle.bg} ${autoStyle.text}`}>
{ctrl.automation}
</span>
</div>
<h3 className="text-sm font-semibold text-gray-900">{ctrl.title}</h3>
<p className="text-xs text-gray-500 mt-1">{ctrl.objective}</p>
</div>
</div>
<div className="flex gap-1 mt-2">
{ctrl.evidence.map(ev => (
<span key={ev} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{ev}</span>
))}
</div>
</div>
)
})}
</div>
</>
) : (
<>
{/* Assessments Tab */}
<div className="mb-4">
<button onClick={() => setShowNewAssessment(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Neues Assessment
</button>
</div>
{showNewAssessment && (
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
<h3 className="font-semibold text-gray-900 mb-4">Neues Payment Compliance Assessment</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Projektname *</label>
<input value={newProject.project_name} onChange={e => setNewProject(p => ({ ...p, project_name: e.target.value }))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Ausschreibung Muenchen 2026" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ausschreibungs-Referenz</label>
<input value={newProject.tender_reference} onChange={e => setNewProject(p => ({ ...p, tender_reference: e.target.value }))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. 2026-PAY-001" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
<input value={newProject.customer_name} onChange={e => setNewProject(p => ({ ...p, customer_name: e.target.value }))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Stadt Muenchen" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Systemtyp</label>
<select value={newProject.system_type} onChange={e => setNewProject(p => ({ ...p, system_type: e.target.value }))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
<option value="full_stack">Full Stack (Terminal + Backend)</option>
<option value="terminal">Nur Terminal</option>
<option value="backend">Nur Backend</option>
</select>
</div>
</div>
<div className="flex gap-2 mt-4">
<button onClick={handleCreateAssessment} disabled={!newProject.project_name}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">Erstellen</button>
<button onClick={() => setShowNewAssessment(false)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
</div>
</div>
)}
{assessments.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<p className="text-lg mb-2">Noch keine Assessments</p>
<p className="text-sm">Erstelle ein neues Assessment fuer eine Ausschreibung.</p>
</div>
) : (
<div className="space-y-4">
{assessments.map(a => (
<div key={a.id} className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-900">{a.project_name}</h3>
<div className="text-sm text-gray-500">
{a.customer_name && <span>{a.customer_name} · </span>}
{a.tender_reference && <span>Ref: {a.tender_reference} · </span>}
<span>{new Date(a.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
a.status === 'completed' ? 'bg-green-100 text-green-700' :
a.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-700'
}`}>{a.status}</span>
</div>
<div className="grid grid-cols-6 gap-2">
<div className="text-center p-2 bg-gray-50 rounded">
<div className="text-lg font-bold">{a.total_controls}</div>
<div className="text-xs text-gray-500">Total</div>
</div>
<div className="text-center p-2 bg-green-50 rounded">
<div className="text-lg font-bold text-green-700">{a.controls_passed}</div>
<div className="text-xs text-gray-500">Passed</div>
</div>
<div className="text-center p-2 bg-red-50 rounded">
<div className="text-lg font-bold text-red-700">{a.controls_failed}</div>
<div className="text-xs text-gray-500">Failed</div>
</div>
<div className="text-center p-2 bg-yellow-50 rounded">
<div className="text-lg font-bold text-yellow-700">{a.controls_partial}</div>
<div className="text-xs text-gray-500">Partial</div>
</div>
<div className="text-center p-2 bg-gray-50 rounded">
<div className="text-lg font-bold text-gray-400">{a.controls_not_applicable}</div>
<div className="text-xs text-gray-500">N/A</div>
</div>
<div className="text-center p-2 bg-gray-50 rounded">
<div className="text-lg font-bold text-gray-400">{a.controls_not_checked}</div>
<div className="text-xs text-gray-500">Offen</div>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)
}