feat: Vorbereitung-Module auf 100% — Compliance-Scope Backend, DELETE-Endpoints, Proxy-Fixes, blocked-content Tab
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 35s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 35s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
Paket A — Kritische Blocker: - compliance_scope_routes.py: GET + POST UPSERT für sdk_states JSONB-Feld - compliance/api/__init__.py: compliance_scope_router registriert - import/route.ts: POST-Proxy für multipart/form-data Upload - screening/route.ts: POST-Proxy für Dependency-File Upload Paket B — Backend + UI: - company_profile_routes.py: DELETE-Endpoint (DSGVO Art. 17) - company-profile/route.ts: DELETE-Proxy - company-profile/page.tsx: Profil-löschen-Button mit Bestätigungs-Dialog - source-policy/pii-rules/[id]/route.ts: GET ergänzt - source-policy/operations/[id]/route.ts: GET + DELETE ergänzt Paket C — Tests + UI: - test_compliance_scope_routes.py: 27 Tests (neu) - test_import_routes.py: +36 Tests → 60 gesamt - test_screening_routes.py: +28 Tests → 80+ gesamt - source-policy/page.tsx: "Blockierte Inhalte" Tab mit Tabelle + Remove Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,43 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/company-profile → Backend DELETE /api/v1/company-profile
|
||||
* DSGVO Art. 17 Recht auf Löschung
|
||||
*/
|
||||
export async function DELETE(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)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to delete company profile:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PATCH /api/sdk/v1/company-profile → Backend PATCH /api/v1/company-profile
|
||||
* Partial updates for individual fields
|
||||
|
||||
@@ -40,3 +40,52 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/import → Backend POST /api/v1/import/analyze
|
||||
* Uploads a document for gap analysis. Forwards multipart/form-data.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
const url = `${BACKEND_URL}/api/v1/import/analyze`
|
||||
|
||||
let body: BodyInit
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
body = await request.arrayBuffer()
|
||||
headers['Content-Type'] = contentType
|
||||
} else {
|
||||
body = await request.text()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (request.headers.get('X-Tenant-ID')) {
|
||||
headers['X-Tenant-ID'] = request.headers.get('X-Tenant-ID') as string
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
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 upload document for import analysis:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,3 +40,52 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/screening → Backend POST /api/v1/screening/scan
|
||||
* Uploads a dependency file (package-lock.json, requirements.txt, etc.) for SBOM + vulnerability scan.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
const url = `${BACKEND_URL}/api/v1/screening/scan`
|
||||
|
||||
let body: BodyInit
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
body = await request.arrayBuffer()
|
||||
headers['Content-Type'] = contentType
|
||||
} else {
|
||||
body = await request.text()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (request.headers.get('X-Tenant-ID')) {
|
||||
headers['X-Tenant-ID'] = request.headers.get('X-Tenant-ID') as string
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
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 upload file for screening scan:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,34 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/admin/operations/${encodeURIComponent(id)}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch operation:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -32,3 +60,32 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/admin/operations/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to delete operation:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,34 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/admin/pii-rules/${encodeURIComponent(id)}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'Backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch PII rule:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
||||
@@ -1097,6 +1097,8 @@ function CoverageAssessmentPanel({ profile }: { profile: Partial<CompanyProfile>
|
||||
export default function CompanyProfilePage() {
|
||||
const { state, dispatch, setCompanyProfile, goToNextStep } = useSDK()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [formData, setFormData] = useState<Partial<CompanyProfile>>({
|
||||
companyName: '',
|
||||
legalForm: undefined,
|
||||
@@ -1290,6 +1292,52 @@ export default function CompanyProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteProfile = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch('/api/sdk/v1/company-profile?tenant_id=default', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (response.ok) {
|
||||
// Reset form and SDK state
|
||||
setFormData({
|
||||
companyName: '',
|
||||
legalForm: undefined,
|
||||
industry: '',
|
||||
foundedYear: null,
|
||||
businessModel: undefined,
|
||||
offerings: [],
|
||||
companySize: undefined,
|
||||
employeeCount: '',
|
||||
annualRevenue: '',
|
||||
headquartersCountry: 'DE',
|
||||
headquartersCity: '',
|
||||
hasInternationalLocations: false,
|
||||
internationalCountries: [],
|
||||
targetMarkets: [],
|
||||
primaryJurisdiction: 'DE',
|
||||
isDataController: true,
|
||||
isDataProcessor: false,
|
||||
usesAI: false,
|
||||
aiUseCases: [],
|
||||
dpoName: null,
|
||||
dpoEmail: null,
|
||||
legalContactName: null,
|
||||
legalContactEmail: null,
|
||||
isComplete: false,
|
||||
completedAt: null,
|
||||
})
|
||||
setCurrentStep(1)
|
||||
dispatch({ type: 'SET_STATE', payload: { companyProfile: undefined } })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete company profile:', err)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
@@ -1412,6 +1460,34 @@ export default function CompanyProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Profil löschen?</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Alle gespeicherten Unternehmensdaten werden unwiderruflich gelöscht (DSGVO Art. 17).
|
||||
Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteProfile}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Lösche...' : 'Endgültig löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar: Coverage Assessment */}
|
||||
<div className="lg:col-span-1">
|
||||
<CoverageAssessmentPanel profile={formData} />
|
||||
@@ -1441,6 +1517,17 @@ export default function CompanyProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Delete Profile Button */}
|
||||
{formData.companyName && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full px-4 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Profil löschen (Art. 17 DSGVO)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,16 @@ interface PolicyStats {
|
||||
blocked_total: number
|
||||
}
|
||||
|
||||
type TabId = 'dashboard' | 'sources' | 'operations' | 'pii' | 'audit'
|
||||
type TabId = 'dashboard' | 'sources' | 'operations' | 'pii' | 'audit' | 'blocked'
|
||||
|
||||
interface BlockedContent {
|
||||
id: string
|
||||
content_type: string
|
||||
pattern: string
|
||||
reason: string
|
||||
blocked_at: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export default function SourcePolicyPage() {
|
||||
const { state } = useSDK()
|
||||
@@ -34,11 +43,43 @@ export default function SourcePolicyPage() {
|
||||
const [stats, setStats] = useState<PolicyStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [blockedContent, setBlockedContent] = useState<BlockedContent[]>([])
|
||||
const [blockedLoading, setBlockedLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'blocked') {
|
||||
fetchBlockedContent()
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
const fetchBlockedContent = async () => {
|
||||
setBlockedLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/blocked-content`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setBlockedContent(Array.isArray(data) ? data : (data.items || []))
|
||||
}
|
||||
} catch {
|
||||
// silently ignore — empty state shown
|
||||
} finally {
|
||||
setBlockedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveBlocked = async (id: string) => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/blocked-content/${id}`, { method: 'DELETE' })
|
||||
setBlockedContent(prev => prev.filter(item => item.id !== id))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -110,6 +151,15 @@ export default function SourcePolicyPage() {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'blocked',
|
||||
name: 'Blockierte Inhalte',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -242,6 +292,72 @@ export default function SourcePolicyPage() {
|
||||
{activeTab === 'operations' && <OperationsMatrixTab apiBase={API_BASE} />}
|
||||
{activeTab === 'pii' && <PIIRulesTab apiBase={API_BASE} onUpdate={fetchStats} />}
|
||||
{activeTab === 'audit' && <AuditTab apiBase={API_BASE} />}
|
||||
{activeTab === 'blocked' && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Blockierte Inhalte</h3>
|
||||
<button
|
||||
onClick={fetchBlockedContent}
|
||||
className="text-sm text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{blockedLoading ? (
|
||||
<div className="p-8 text-center text-gray-500">Lade blockierte Inhalte...</div>
|
||||
) : blockedContent.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">Keine blockierten Inhalte vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-gray-600 font-medium">Typ</th>
|
||||
<th className="text-left px-4 py-3 text-gray-600 font-medium">Muster / Pattern</th>
|
||||
<th className="text-left px-4 py-3 text-gray-600 font-medium">Grund</th>
|
||||
<th className="text-left px-4 py-3 text-gray-600 font-medium">Blockiert am</th>
|
||||
<th className="text-left px-4 py-3 text-gray-600 font-medium">Quelle</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{blockedContent.map(item => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs font-medium">
|
||||
{item.content_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700 max-w-xs truncate">
|
||||
{item.pattern}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{item.reason}</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
{new Date(item.blocked_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{item.source || '—'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleRemoveBlocked(item.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs px-2 py-1 rounded hover:bg-red-50"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user