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:
48
admin-compliance/app/api/sdk/v1/payment-compliance/route.ts
Normal file
48
admin-compliance/app/api/sdk/v1/payment-compliance/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'
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
291
admin-compliance/app/sdk/payment-compliance/page.tsx
Normal file
291
admin-compliance/app/sdk/payment-compliance/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user