Compare commits
216 Commits
4d2f4f2d24
...
feature/be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd64e33e88 | ||
|
|
2f8269d115 | ||
|
|
532febe35c | ||
|
|
0a0863f31c | ||
|
|
d892ad161f | ||
|
|
17153ccbe8 | ||
|
|
352d7112c9 | ||
|
|
0957254547 | ||
|
|
f17608a956 | ||
|
|
ce3df9f080 | ||
|
|
2da39e035d | ||
|
|
1989c410a9 | ||
|
|
c55a6ab995 | ||
|
|
bc75b4455d | ||
|
|
712fa8cb74 | ||
|
|
447ec08509 | ||
|
|
8cb1dc1108 | ||
|
|
f8d9919b97 | ||
|
|
fb2cf29b34 | ||
|
|
f39e5a71af | ||
|
|
ac42a0aaa0 | ||
|
|
52e463a7c8 | ||
|
|
2dee62fa6f | ||
|
|
3fb07e201f | ||
|
|
81c9ce5de3 | ||
|
|
db7c207464 | ||
|
|
cb034b8009 | ||
|
|
564f93259b | ||
|
|
89ac223c41 | ||
|
|
23dd5116b3 | ||
|
|
81ce9dde07 | ||
|
|
5e9cab6ab5 | ||
|
|
a29bfdd588 | ||
|
|
9dbb4cc5d2 | ||
|
|
c56bccaedf | ||
|
|
230fbeb490 | ||
|
|
6d3bdf8e74 | ||
|
|
200facda6a | ||
|
|
9282850138 | ||
|
|
770f0b5ab0 | ||
|
|
35784c35eb | ||
|
|
cce2707c03 | ||
|
|
2efc738803 | ||
|
|
e6201d5239 | ||
|
|
48ca0a6bef | ||
|
|
1a63f5857b | ||
|
|
295c18c6f7 | ||
|
|
649a3c5e4e | ||
|
|
bdd2f6fa0f | ||
|
|
ac6134ce6d | ||
|
|
0027f78fc5 | ||
|
|
b29a7caee7 | ||
|
|
a14e2f3a00 | ||
|
|
71b8c33270 | ||
|
|
f2924a58ed | ||
|
|
643b26618f | ||
|
|
c52dbdb8f1 | ||
|
|
c3a53fe5d2 | ||
|
|
df5b6d69ef | ||
|
|
4f6ac9b23a | ||
|
|
5ea31a3236 | ||
|
|
95c371e9a5 | ||
|
|
b1627252ee | ||
|
|
2a0449c9b7 | ||
|
|
92d37a1660 | ||
|
|
0e16640c28 | ||
|
|
24f02b52ed | ||
|
|
9b0f25c105 | ||
|
|
1cc34c23d9 | ||
|
|
5dd7a27336 | ||
|
|
c3afa628ed | ||
|
|
4b1eede45b | ||
|
|
2a70441eaa | ||
|
|
f2819b99af | ||
|
|
3bb9fffab6 | ||
|
|
148c7ba3af | ||
|
|
a9e0869205 | ||
|
|
653aad57e3 | ||
|
|
a7f7e57dd7 | ||
|
|
567e82ddf5 | ||
|
|
36ef34169a | ||
|
|
d22c47c9eb | ||
|
|
825e070ed9 | ||
|
|
4f6bc8f6f6 | ||
|
|
d2133dbfa2 | ||
|
|
6d2de9b897 | ||
|
|
5adb1c5f16 | ||
|
|
9c1355c05f | ||
|
|
3b2006ebce | ||
|
|
c7651796c9 | ||
|
|
c8fd9cc780 | ||
|
|
0d95c3bb44 | ||
|
|
f066cf1a03 | ||
|
|
dd09fa7a46 | ||
|
|
f3e05c1bf7 | ||
|
|
2ed1c08acf | ||
|
|
4018b9af9b | ||
|
|
a9f291ff49 | ||
|
|
0171d611f6 | ||
|
|
637fab6fdb | ||
|
|
d462141ccd | ||
|
|
5f8aebf5b1 | ||
|
|
c74f506415 | ||
|
|
49ce417428 | ||
|
|
13d13c8226 | ||
|
|
b6e6ffaaee | ||
|
|
8a05fcc2f0 | ||
|
|
9812ff46f3 | ||
|
|
30236c0001 | ||
|
|
b4d2be83eb | ||
|
|
38c7cf0a00 | ||
|
|
399fa62267 | ||
| f1710fdb9e | |||
|
|
499ddc04d5 | ||
|
|
f738ca8c52 | ||
|
|
c530898963 | ||
|
|
cdafc4d9f4 | ||
|
|
de19ef0684 | ||
|
|
c87f07c99a | ||
|
|
453eec9ed8 | ||
|
|
050f353192 | ||
|
|
8442115e7c | ||
|
|
999cc81c78 | ||
|
|
ff66612beb | ||
|
|
42ec3cad6d | ||
|
|
9945a62a50 | ||
|
|
eef1c2e7d3 | ||
|
|
a0e2a35e66 | ||
|
|
57f390190d | ||
|
|
cf60c39658 | ||
|
|
c88653b221 | ||
|
|
87d06c8b20 | ||
|
|
0b47612272 | ||
|
|
c14b31b3bc | ||
|
|
0b836f7e2d | ||
|
|
18d9eec654 | ||
|
|
339505feed | ||
|
|
23b9808bf3 | ||
|
|
c3654bc9ea | ||
|
|
363bf9606a | ||
|
|
e88c0aeeb3 | ||
|
|
ebe7e90bd8 | ||
|
|
995de9e0f4 | ||
|
|
4e08364bc6 | ||
|
|
7f38df9d9c | ||
|
|
cb48b8289e | ||
|
|
46048554cb | ||
|
|
a673cb0ce4 | ||
|
|
7afbcfd9f5 | ||
|
|
091f093e1b | ||
|
|
5d99d5d47a | ||
|
|
e3a877b549 | ||
|
|
237c05a94c | ||
|
|
b1a0dd3615 | ||
|
|
6e7d0d9b14 | ||
|
|
24afed69c1 | ||
|
|
579fe1b5e1 | ||
|
|
ee6743c7c6 | ||
|
|
a6818b39c5 | ||
|
|
e3fb81fc0d | ||
|
|
3512963006 | ||
|
|
85b3cc3421 | ||
|
|
f6019ecba9 | ||
|
|
1f91e05600 | ||
|
|
3c0c1e49da | ||
|
|
5da93c5d10 | ||
|
|
fa4cda7627 | ||
|
|
90d99bba08 | ||
|
|
2c35775b44 | ||
|
|
aaf95cf894 | ||
|
|
9f41ed4f8e | ||
|
|
e7fab73a3a | ||
|
|
f8917ee6fd | ||
|
|
51a208a2e1 | ||
|
|
1c59996f32 | ||
|
|
61064fdcba | ||
|
|
11d4c2fd36 | ||
|
|
3d22935065 | ||
|
|
09cfb79840 | ||
|
|
d53cf21b95 | ||
|
|
0c83e765d9 | ||
|
|
9b59044663 | ||
|
|
d787e58341 | ||
|
|
4b778f2f85 | ||
|
|
0affa4eb66 | ||
|
|
d3fc4cdaaa | ||
|
|
de486aeab0 | ||
|
|
e96f623af0 | ||
|
|
53ff0722a4 | ||
|
|
2abf0b4cac | ||
|
|
fd45545fbe | ||
|
|
504b1a1207 | ||
|
|
66d32cd744 | ||
|
|
7fa0349fe4 | ||
|
|
6ff9f1f2f3 | ||
|
|
56758e8b55 | ||
|
|
0c75182fb3 | ||
|
|
4a60b3a744 | ||
|
|
95fcba34cd | ||
|
|
6509e64dd9 | ||
|
|
7ec6b9f6c0 | ||
|
|
9e65dff7d6 | ||
|
|
1e84df9769 | ||
|
|
ef9aed666f | ||
|
|
37166c966f | ||
|
|
3467bce222 | ||
|
|
a5e4801b09 | ||
|
|
8f3fb84b61 | ||
|
|
2dd86e97be | ||
|
|
8742cb7f5a | ||
|
|
6a940344c2 | ||
|
|
095eff26d9 | ||
|
|
3593a4ff78 | ||
|
|
4cbfea5c1d | ||
|
|
885b97d422 | ||
|
|
ee359885a8 |
@@ -2,72 +2,127 @@
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup
|
||||
### Zwei-Rechner-Setup + Coolify
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
||||
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||
| **Coolify** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
||||
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
|
||||
|
||||
### Entwicklungsworkflow
|
||||
### Entwicklungsworkflow (CI/CD — Coolify)
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und pushen:
|
||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# 3. Auf Mac Mini pullen (WICHTIG: git -C statt cd):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||
# - Gitea Actions: Lint → Tests → Validierung
|
||||
# - Coolify: Build → Deploy
|
||||
# Dauer: ca. 3 Minuten
|
||||
# Status pruefen: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
```
|
||||
|
||||
# 4. Container neu bauen (WICHTIG: -f statt cd, da cd in SSH nicht funktioniert!):
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` fuer Production.
|
||||
**NIEMALS** manuell in Coolify auf "Redeploy" klicken — Gitea Actions triggert Coolify automatisch.
|
||||
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||
|
||||
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
||||
|
||||
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
|
||||
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
|
||||
```bash
|
||||
# Compliance Health-Endpoints:
|
||||
curl -sf https://api-dev.breakpilot.ai/health # Backend Compliance
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health # AI Compliance SDK
|
||||
```
|
||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||
**"Deploy abgeschlossen! Du kannst jetzt testen: https://admin-dev.breakpilot.ai"**
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Coolify-Logs.
|
||||
|
||||
**Ablauf im Terminal:**
|
||||
```
|
||||
> git push gitea main ✓
|
||||
> "Deploy gestartet, ich ueberwache den Status..."
|
||||
> [Hintergrund-Polling laeuft]
|
||||
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
|
||||
```
|
||||
|
||||
### CI/CD Pipeline (Gitea Actions → Coolify)
|
||||
|
||||
```
|
||||
Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
|
||||
→ test-go-ai-compliance
|
||||
→ test-python-backend-compliance
|
||||
→ test-python-document-crawler
|
||||
→ test-python-dsms-gateway
|
||||
→ validate-canonical-controls
|
||||
→ Coolify: Build + Deploy (automatisch bei Push)
|
||||
```
|
||||
|
||||
**Dateien:**
|
||||
- `.gitea/workflows/ci.yaml` — Pipeline-Definition (Tests + Validierung)
|
||||
- `docker-compose.yml` — Haupt-Compose
|
||||
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Coolify Production (x86_64)
|
||||
|
||||
### Lokale Entwicklung (Mac Mini — optional)
|
||||
|
||||
```bash
|
||||
# Nur fuer lokale Tests, NICHT fuer Production:
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
|
||||
# Fuer schnelle Iteration ohne Commit (rsync):
|
||||
rsync -avz --exclude node_modules --exclude .next --exclude .git \
|
||||
admin-compliance/ macmini:~/Projekte/breakpilot-compliance/admin-compliance/
|
||||
```
|
||||
|
||||
### SSH-Verbindung (fuer Docker/Tests)
|
||||
|
||||
```bash
|
||||
# RICHTIG — cd funktioniert NICHT in SSH-Einzelbefehlen:
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance <git-cmd>"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml <compose-cmd>"
|
||||
ssh macmini "/usr/local/bin/docker exec bp-compliance-<service> <cmd>"
|
||||
```
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzung
|
||||
|
||||
**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services:
|
||||
- PostgreSQL (Schema: `compliance`, `core`)
|
||||
- Valkey (Session-Cache)
|
||||
- Vault (Secrets)
|
||||
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
|
||||
- Nginx (Reverse Proxy)
|
||||
|
||||
Pruefen: `curl -sf http://macmini:8099/health`
|
||||
**Externe Services (Production):**
|
||||
- PostgreSQL 17 (sslmode=require) — Schemas: `compliance`, `public`
|
||||
- Qdrant @ `qdrant-dev.breakpilot.ai` (HTTPS, API-Key)
|
||||
- Object Storage (S3-kompatibel, TLS)
|
||||
|
||||
Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Haupt-URLs (Browser auf MacBook)
|
||||
## Haupt-URLs
|
||||
|
||||
### Frontends
|
||||
### Production (Coolify-deployed)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
| **https://macmini:3007/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||
| **https://macmini:3006/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||
| **https://admin-dev.breakpilot.ai/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||
| **https://developers-dev.breakpilot.ai/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||
| https://api-dev.breakpilot.ai/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||
| https://sdk-dev.breakpilot.ai/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||
|
||||
### Backend-APIs
|
||||
### Lokal (Mac Mini — nur Dev/Tests)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
| https://macmini:8002/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||
| https://macmini:8093/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||
| https://macmini:3007/ | Admin Compliance | Lokale Entwicklung |
|
||||
| https://macmini:3006/ | Developer Portal | Lokale Entwicklung |
|
||||
| https://macmini:8002/ | Backend Compliance | Lokale Entwicklung |
|
||||
| https://macmini:8093/ | AI Compliance SDK | Lokale Entwicklung |
|
||||
|
||||
### Admin Compliance Module (https://macmini:3007/)
|
||||
|
||||
@@ -107,18 +162,6 @@ Pruefen: `curl -sf http://macmini:8099/health`
|
||||
| docs | MkDocs/nginx | 8011 | bp-compliance-docs |
|
||||
| core-wait | curl health-check | - | bp-compliance-core-wait |
|
||||
|
||||
### compliance-tts-service
|
||||
- Piper TTS + FFmpeg fuer Schulungsvideos
|
||||
- Speichert Audio/Video in MinIO (bp-core-minio:9000)
|
||||
- TTS-Modell: `de_DE-thorsten-high.onnx`
|
||||
- Dateien: `main.py`, `tts_engine.py`, `video_generator.py`, `storage.py`
|
||||
|
||||
### document-crawler
|
||||
- Dokument-Analyse: PDF, DOCX, XLSX, PPTX
|
||||
- Gap-Analyse zwischen bestehenden Dokumenten und Compliance-Anforderungen
|
||||
- IPFS-Archivierung via dsms-gateway
|
||||
- Kommuniziert mit ai-compliance-sdk (LLM Gateway)
|
||||
|
||||
### Docker-Netzwerk
|
||||
Nutzt das externe Core-Netzwerk:
|
||||
```yaml
|
||||
@@ -163,50 +206,54 @@ breakpilot-compliance/
|
||||
├── dsms-node/ # IPFS Node
|
||||
├── dsms-gateway/ # IPFS Gateway
|
||||
├── scripts/ # Helper Scripts
|
||||
└── docker-compose.yml # Compliance Compose (~8 Services)
|
||||
├── docker-compose.yml # Compliance Compose (~10 Services, platform: arm64)
|
||||
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Coolify Production
|
||||
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Haeufige Befehle
|
||||
|
||||
### Docker
|
||||
### Deployment (CI/CD — Standardweg)
|
||||
|
||||
```bash
|
||||
# Compliance-Services starten (Core muss laufen!)
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d"
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# Einzelnen Service neu bauen
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
||||
# CI-Status pruefen (im Browser):
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
|
||||
# Service neu bauen und starten
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
|
||||
# Logs
|
||||
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
||||
|
||||
# Status
|
||||
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
||||
# Health Checks:
|
||||
curl -sf https://api-dev.breakpilot.ai/health
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health
|
||||
```
|
||||
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
||||
Der CLAUDE.md-Entwicklungsworkflow und die Beispiele mit `cd ... &&` sind veraltet — nie so verwenden.
|
||||
|
||||
### Git
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT! — vom MacBook):
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# Auf Mac Mini pullen (RICHTIG: git -C statt cd):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
|
||||
# Remotes:
|
||||
# origin: lokale Gitea (macmini:3003)
|
||||
# gitea: gitea.meghsakha.com:22222
|
||||
```
|
||||
|
||||
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)
|
||||
|
||||
```bash
|
||||
# Logs
|
||||
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
||||
|
||||
# Status
|
||||
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
||||
|
||||
# Lokaler Rebuild (nur wenn noetig):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kernprinzipien
|
||||
@@ -254,6 +301,36 @@ ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --n
|
||||
- `lib/sdk/catalog-manager/` — catalog-registry.ts, types.ts
|
||||
- 17 DSGVO/AI-Act Kataloge (dsfa, vvt-baseline, vendor-compliance, etc.)
|
||||
|
||||
### Multi-Projekt-Architektur (seit 2026-03-09)
|
||||
|
||||
Jeder Tenant kann mehrere Compliance-Projekte anlegen. CompanyProfile ist **pro Projekt** (nicht tenant-weit).
|
||||
|
||||
**URL-Schema:** `/sdk?project={uuid}` — alle SDK-Seiten enthalten `?project=` Query-Param.
|
||||
`/sdk` ohne `?project=` zeigt die Projektliste (ProjectSelector).
|
||||
|
||||
**Datenbank:**
|
||||
- `compliance_projects` — Projekt-Metadaten (Name, Typ, Status, Version)
|
||||
- `sdk_states` — UNIQUE auf `(tenant_id, project_id)` statt nur `tenant_id`
|
||||
- Migration: `039_compliance_projects.sql`
|
||||
|
||||
**Backend API (FastAPI):**
|
||||
```
|
||||
GET /api/v1/projects → Alle Projekte des Tenants
|
||||
POST /api/v1/projects → Neues Projekt erstellen (mit copy_from_project_id)
|
||||
GET /api/v1/projects/{project_id} → Einzelnes Projekt laden
|
||||
PATCH /api/v1/projects/{project_id} → Projekt aktualisieren
|
||||
DELETE /api/v1/projects/{project_id} → Projekt archivieren (Soft Delete)
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
- `components/sdk/ProjectSelector/ProjectSelector.tsx` — Projektliste + Erstellen-Dialog
|
||||
- `lib/sdk/types.ts` — `ProjectInfo` Interface, `SDKState.projectId`
|
||||
- `lib/sdk/context.tsx` — `projectId` Prop, `createProject()`, `listProjects()`, `switchProject()`
|
||||
- `lib/sdk/sync.ts` — BroadcastChannel + localStorage pro Projekt
|
||||
- `lib/sdk/api-client.ts` — `projectId` in State-API + Projekt-CRUD-Methoden
|
||||
- `app/sdk/layout.tsx` — liest `?project=` aus searchParams
|
||||
- `app/api/sdk/v1/projects/` — Next.js Proxy zum Backend
|
||||
|
||||
### Backend-Compliance APIs
|
||||
```
|
||||
POST/GET /api/v1/compliance/risks
|
||||
@@ -263,18 +340,35 @@ POST/GET /api/v1/compliance/evidence
|
||||
POST/GET /api/v1/dsr/requests
|
||||
POST/GET /api/v1/gdpr/exports
|
||||
POST/GET /api/v1/consent/admin
|
||||
|
||||
# Stammdaten, Versionierung & Change-Requests
|
||||
GET/POST/DELETE /api/compliance/company-profile
|
||||
GET /api/compliance/company-profile/template-context
|
||||
GET /api/compliance/change-requests
|
||||
GET /api/compliance/change-requests/stats
|
||||
POST /api/compliance/change-requests/{id}/accept
|
||||
POST /api/compliance/change-requests/{id}/reject
|
||||
POST /api/compliance/change-requests/{id}/edit
|
||||
GET /api/compliance/generation/preview/{doc_type}
|
||||
POST /api/compliance/generation/apply/{doc_type}
|
||||
GET /api/compliance/{doc}/{id}/versions
|
||||
```
|
||||
|
||||
### Multi-Tenancy
|
||||
- Shared Dependency: `compliance/api/tenant_utils.py` (`get_tenant_id()`)
|
||||
- UUID-Format, kein `"default"` mehr
|
||||
- Header `X-Tenant-ID` > Query `tenant_id` > ENV-Fallback
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Dateien (Referenz)
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `admin-compliance/app/(sdk)/` | Alle 37 SDK-Routes |
|
||||
| `admin-compliance/components/sdk/SDKSidebar.tsx` | SDK Navigation |
|
||||
| `admin-compliance/app/(sdk)/` | Alle 37+ SDK-Routes |
|
||||
| `admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx` | SDK Navigation |
|
||||
| `admin-compliance/components/sdk/CommandBar.tsx` | Command Palette |
|
||||
| `admin-compliance/lib/sdk/context.tsx` | SDK State (Provider) |
|
||||
| `backend-compliance/compliance/` | Haupt-Package (40 Dateien) |
|
||||
| `backend-compliance/compliance/` | Haupt-Package (50+ Dateien) |
|
||||
| `ai-compliance-sdk/` | KI-Compliance Analyse |
|
||||
| `developer-portal/` | API-Dokumentation |
|
||||
|
||||
61
.env.coolify.example
Normal file
61
.env.coolify.example
Normal file
@@ -0,0 +1,61 @@
|
||||
# =========================================================
|
||||
# BreakPilot Compliance — Coolify Environment Variables
|
||||
# =========================================================
|
||||
# Copy these into Coolify's environment variable UI
|
||||
# for the breakpilot-compliance Docker Compose resource.
|
||||
# =========================================================
|
||||
|
||||
# --- External PostgreSQL (Coolify-managed, same as Core) ---
|
||||
COMPLIANCE_DATABASE_URL=postgresql://breakpilot:CHANGE_ME@<coolify-postgres-hostname>:5432/breakpilot_db
|
||||
|
||||
# --- Security ---
|
||||
JWT_SECRET=CHANGE_ME_SAME_AS_CORE
|
||||
|
||||
# --- External S3 Storage (same as Core) ---
|
||||
S3_ENDPOINT=<s3-endpoint-host:port>
|
||||
S3_ACCESS_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECRET_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECURE=true
|
||||
|
||||
# --- External Qdrant ---
|
||||
QDRANT_URL=https://<qdrant-hostname>
|
||||
QDRANT_API_KEY=CHANGE_ME_QDRANT_API_KEY
|
||||
|
||||
# --- Session ---
|
||||
SESSION_TTL_HOURS=24
|
||||
|
||||
# --- SMTP (Real mail server) ---
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=compliance@breakpilot.ai
|
||||
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
|
||||
SMTP_FROM_NAME=BreakPilot Compliance
|
||||
SMTP_FROM_ADDR=compliance@breakpilot.ai
|
||||
|
||||
# --- LLM Configuration ---
|
||||
COMPLIANCE_LLM_PROVIDER=anthropic
|
||||
SELF_HOSTED_LLM_URL=
|
||||
SELF_HOSTED_LLM_MODEL=
|
||||
COMPLIANCE_LLM_MAX_TOKENS=4096
|
||||
COMPLIANCE_LLM_TEMPERATURE=0.3
|
||||
COMPLIANCE_LLM_TIMEOUT=120
|
||||
ANTHROPIC_API_KEY=CHANGE_ME_ANTHROPIC_KEY
|
||||
ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-5-20250929
|
||||
|
||||
# --- Ollama (optional) ---
|
||||
OLLAMA_URL=
|
||||
OLLAMA_DEFAULT_MODEL=
|
||||
COMPLIANCE_LLM_MODEL=
|
||||
|
||||
# --- LLM Fallback ---
|
||||
LLM_FALLBACK_PROVIDER=
|
||||
|
||||
# --- PII & Audit ---
|
||||
PII_REDACTION_ENABLED=true
|
||||
PII_REDACTION_LEVEL=standard
|
||||
AUDIT_RETENTION_DAYS=365
|
||||
AUDIT_LOG_PROMPTS=true
|
||||
|
||||
# --- Frontend URLs (build args) ---
|
||||
NEXT_PUBLIC_API_URL=https://api-compliance.breakpilot.ai
|
||||
NEXT_PUBLIC_SDK_URL=https://sdk.breakpilot.ai
|
||||
@@ -48,3 +48,11 @@ SESSION_TTL_HOURS=24
|
||||
# SMTP (uses Core Mailpit)
|
||||
SMTP_HOST=bp-core-mailpit
|
||||
SMTP_PORT=1025
|
||||
|
||||
# Qdrant (externe Instanz — Hetzner/meghshakka)
|
||||
QDRANT_URL=https://qdrant-dev.breakpilot.ai
|
||||
QDRANT_API_KEY=<api-key>
|
||||
|
||||
# MinIO / Object Storage (Hetzner Object Storage)
|
||||
# MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY sind direkt in docker-compose hart kodiert
|
||||
# (compliance-tts-service: nbg1.your-objectstorage.com)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# Gitea Actions CI Pipeline
|
||||
# Gitea Actions CI/CD Pipeline
|
||||
# BreakPilot Compliance
|
||||
#
|
||||
# Services:
|
||||
# Go: ai-compliance-sdk
|
||||
# Python: backend-compliance, document-crawler, dsms-gateway
|
||||
# Node.js: admin-compliance, developer-portal
|
||||
#
|
||||
# Workflow:
|
||||
# Push auf main → Tests → Deploy (Coolify)
|
||||
# Pull Request → Lint + Tests (kein Deploy)
|
||||
|
||||
name: CI
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -164,3 +168,42 @@ jobs:
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest test_main.py -v --tb=short
|
||||
|
||||
# ========================================
|
||||
# Validate Canonical Controls
|
||||
# ========================================
|
||||
|
||||
validate-canonical-controls:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Validate controls
|
||||
run: |
|
||||
python scripts/validate-controls.py
|
||||
|
||||
# ========================================
|
||||
# Deploy via Coolify (nur main, kein PR)
|
||||
# ========================================
|
||||
|
||||
deploy-coolify:
|
||||
name: Deploy
|
||||
runs-on: docker
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-go-ai-compliance
|
||||
- test-python-backend-compliance
|
||||
- test-python-document-crawler
|
||||
- test-python-dsms-gateway
|
||||
- validate-canonical-controls
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Trigger Coolify deploy
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||
|
||||
115
.gitea/workflows/rag-ingest.yaml
Normal file
115
.gitea/workflows/rag-ingest.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
# Gitea Actions — RAG Legal Corpus Ingestion
|
||||
#
|
||||
# Manuell triggerbarer Workflow zur Ingestion von Rechtstexten in Qdrant.
|
||||
# Trigger: Gitea UI → Actions → "RAG Ingestion" → Run
|
||||
#
|
||||
# Phasen: gesetze, eu, templates, datenschutz, verbraucherschutz, verify, version, all
|
||||
#
|
||||
# Voraussetzung: RAG-Service und Qdrant muessen auf Coolify laufen.
|
||||
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-coolify).
|
||||
|
||||
name: RAG Ingestion
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
phase:
|
||||
description: 'Ingestion Phase (gesetze, eu, templates, datenschutz, verbraucherschutz, dach, security, verify, version, all)'
|
||||
required: true
|
||||
default: 'verbraucherschutz'
|
||||
|
||||
jobs:
|
||||
ingest:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Setup
|
||||
run: |
|
||||
apk add --no-cache git curl bash > /dev/null 2>&1
|
||||
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth 1 --branch main ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
|
||||
- name: Run Ingestion
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PHASE="${{ github.event.inputs.phase }}"
|
||||
|
||||
echo "=== RAG Ingestion: Phase ${PHASE} ==="
|
||||
echo ""
|
||||
|
||||
# Pruefen ob Services laufen
|
||||
echo "--- BreakPilot Container ---"
|
||||
docker ps --filter name=bp- --format "{{.Names}}: {{.Status}}" 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
# Netzwerk finden in dem die bp-Services laufen
|
||||
BP_NETWORK=$(docker inspect bp-core-rag-service --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||
if [ -z "$BP_NETWORK" ]; then
|
||||
BP_NETWORK=$(docker inspect bp-compliance-backend --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$BP_NETWORK" ]; then
|
||||
echo "FEHLER: Keine BreakPilot-Container gefunden."
|
||||
echo "Bitte zuerst deployen (CI/CD Pipeline oder manuell)."
|
||||
echo ""
|
||||
echo "Verfuegbare Container:"
|
||||
docker ps --format " {{.Names}}" 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Verfuegbare Netzwerke:"
|
||||
docker network ls --format " {{.Name}}" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "BreakPilot Netzwerk: $BP_NETWORK"
|
||||
echo ""
|
||||
|
||||
# Ingestion-Container erstellen (noch nicht starten),
|
||||
# dann Scripts aus dem Checkout per docker cp hineinkopieren.
|
||||
# So verwenden wir IMMER die neueste Version der Scripts,
|
||||
# unabhaengig vom Deploy-Dir auf dem Host.
|
||||
CONTAINER_ID=$(docker create \
|
||||
--network "$BP_NETWORK" \
|
||||
-e "WORK_DIR=/tmp/rag-ingestion" \
|
||||
-e "RAG_URL=http://bp-core-rag-service:8097/api/v1/documents/upload" \
|
||||
-e "QDRANT_URL=https://qdrant-dev.breakpilot.ai" \
|
||||
-e "QDRANT_API_KEY=z9cKbT74vl1aKPD1QGIlKWfET47VH93u" \
|
||||
-e "SDK_URL=http://bp-compliance-ai-sdk:8090" \
|
||||
alpine:3.19 \
|
||||
sh -c "
|
||||
apk add --no-cache curl bash coreutils git python3 unzip > /dev/null 2>&1
|
||||
mkdir -p /tmp/rag-ingestion/{pdfs,repos,texts}
|
||||
mkdir -p /workspace/scripts
|
||||
cp -r /workspace_scripts/* /workspace/scripts/ 2>/dev/null || true
|
||||
cd /workspace
|
||||
if [ '${PHASE}' = 'all' ]; then
|
||||
bash scripts/ingest-legal-corpus.sh
|
||||
elif [ '${PHASE}' = 'download' ]; then
|
||||
bash scripts/ingest-legal-corpus.sh --only download
|
||||
else
|
||||
echo '=== Running download phase first ==='
|
||||
bash scripts/ingest-legal-corpus.sh --only download
|
||||
echo ''
|
||||
echo '=== Running phase: ${PHASE} ==='
|
||||
bash scripts/ingest-legal-corpus.sh --only '${PHASE}'
|
||||
fi
|
||||
")
|
||||
|
||||
echo "Container: $CONTAINER_ID"
|
||||
|
||||
# Workspace-Dir im Container anlegen und Scripts hineinkopieren
|
||||
docker cp scripts "${CONTAINER_ID}:/workspace_scripts"
|
||||
echo "Scripts kopiert (aus Git-Checkout)"
|
||||
|
||||
# Container starten und Output streamen
|
||||
docker start -a "${CONTAINER_ID}" || EXITCODE=$?
|
||||
|
||||
# Container aufraeumen
|
||||
docker rm -f "${CONTAINER_ID}" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Ingestion abgeschlossen ==="
|
||||
|
||||
# Exit mit dem Original-Exitcode
|
||||
exit ${EXITCODE:-0}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,6 +17,9 @@ __pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
.coverage
|
||||
coverage.out
|
||||
test_*.db
|
||||
|
||||
# Docker
|
||||
backups/*.backup
|
||||
@@ -40,3 +43,4 @@ backups/*.backup
|
||||
*.mp3
|
||||
*.wav
|
||||
ai-compliance-sdk/server
|
||||
*.bak
|
||||
|
||||
@@ -22,6 +22,9 @@ ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL
|
||||
ENV NEXT_PUBLIC_SDK_URL=$NEXT_PUBLIC_SDK_URL
|
||||
|
||||
# Ensure public directory exists (Next.js standalone needs it)
|
||||
RUN mkdir -p public
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
@@ -34,8 +37,8 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup -S -g 1001 nodejs
|
||||
RUN adduser -S -u 1001 -G nodejs nextjs
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
@@ -48,12 +48,12 @@ describe('Ingestion Script: ingest-industry-compliance.sh', () => {
|
||||
expect(scriptContent).toContain('chunk_strategy=recursive')
|
||||
})
|
||||
|
||||
it('should use chunk_size=512', () => {
|
||||
expect(scriptContent).toContain('chunk_size=512')
|
||||
it('should use chunk_size=1024', () => {
|
||||
expect(scriptContent).toContain('chunk_size=1024')
|
||||
})
|
||||
|
||||
it('should use chunk_overlap=50', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=50')
|
||||
it('should use chunk_overlap=128', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=128')
|
||||
})
|
||||
|
||||
it('should validate minimum file size', () => {
|
||||
|
||||
@@ -591,12 +591,43 @@ async function handleV2Draft(body: Record<string, unknown>): Promise<NextRespons
|
||||
cacheStats: proseCache.getStats(),
|
||||
}
|
||||
|
||||
// Anti-Fake-Evidence: Truth label for all LLM-generated content
|
||||
const truthLabel = {
|
||||
generation_mode: 'draft_assistance',
|
||||
truth_status: 'generated',
|
||||
may_be_used_as_evidence: false,
|
||||
generated_by: 'system',
|
||||
}
|
||||
|
||||
// Fire-and-forget: persist LLM audit trail to backend
|
||||
try {
|
||||
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
|
||||
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entity_type: 'document',
|
||||
entity_id: null,
|
||||
generation_mode: 'draft_assistance',
|
||||
truth_status: 'generated',
|
||||
may_be_used_as_evidence: false,
|
||||
llm_model: LLM_MODEL,
|
||||
llm_provider: 'ollama',
|
||||
input_summary: `${documentType} draft generation`,
|
||||
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
|
||||
}),
|
||||
}).catch(() => {/* fire-and-forget */})
|
||||
} catch {
|
||||
// LLM audit persistence failure should not block the response
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: Math.round(totalTokens),
|
||||
pipelineVersion: 'v2',
|
||||
auditTrail,
|
||||
truthLabel,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,76 @@ import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validat
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
/**
|
||||
* Anti-Fake-Evidence: Verbotene Formulierungen
|
||||
*
|
||||
* Flags formulations that falsely claim compliance without evidence.
|
||||
* Only allowed when: control_status=pass AND confidence >= E2 AND
|
||||
* truth_status in (validated_internal, accepted_by_auditor).
|
||||
*/
|
||||
interface EvidenceContext {
|
||||
controlStatus?: string
|
||||
confidenceLevel?: string
|
||||
truthStatus?: string
|
||||
}
|
||||
|
||||
const FORBIDDEN_PATTERNS: Array<{
|
||||
pattern: RegExp
|
||||
label: string
|
||||
safeAlternative: string
|
||||
}> = [
|
||||
{ pattern: /ist\s+compliant/gi, label: 'ist compliant', safeAlternative: 'soll compliant sein' },
|
||||
{ pattern: /erfüllt\s+vollständig/gi, label: 'erfüllt vollständig', safeAlternative: 'soll vollständig erfüllt werden' },
|
||||
{ pattern: /wurde\s+geprüft/gi, label: 'wurde geprüft', safeAlternative: 'soll geprüft werden' },
|
||||
{ pattern: /wurde\s+umgesetzt/gi, label: 'wurde umgesetzt', safeAlternative: 'ist zur Umsetzung vorgesehen' },
|
||||
{ pattern: /ist\s+auditiert/gi, label: 'ist auditiert', safeAlternative: 'soll auditiert werden' },
|
||||
{ pattern: /vollständig\s+implementiert/gi, label: 'vollständig implementiert', safeAlternative: 'Implementierung ist vorgesehen' },
|
||||
{ pattern: /nachweislich\s+konform/gi, label: 'nachweislich konform', safeAlternative: 'Konformität ist nachzuweisen' },
|
||||
]
|
||||
|
||||
const CONFIDENCE_ORDER: Record<string, number> = { E0: 0, E1: 1, E2: 2, E3: 3, E4: 4 }
|
||||
const VALID_TRUTH_STATUSES = new Set(['validated_internal', 'accepted_by_auditor'])
|
||||
|
||||
function checkForbiddenFormulations(
|
||||
content: string,
|
||||
evidenceContext?: EvidenceContext,
|
||||
): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = []
|
||||
|
||||
if (!content) return findings
|
||||
|
||||
// If evidence context shows sufficient proof, allow the formulations
|
||||
if (evidenceContext) {
|
||||
const { controlStatus, confidenceLevel, truthStatus } = evidenceContext
|
||||
const confLevel = CONFIDENCE_ORDER[confidenceLevel ?? 'E0'] ?? 0
|
||||
if (
|
||||
controlStatus === 'pass' &&
|
||||
confLevel >= CONFIDENCE_ORDER.E2 &&
|
||||
VALID_TRUTH_STATUSES.has(truthStatus ?? '')
|
||||
) {
|
||||
return findings // Formulations are backed by real evidence
|
||||
}
|
||||
}
|
||||
|
||||
for (const { pattern, label, safeAlternative } of FORBIDDEN_PATTERNS) {
|
||||
// Reset regex state for global patterns
|
||||
pattern.lastIndex = 0
|
||||
if (pattern.test(content)) {
|
||||
findings.push({
|
||||
id: `AFE-FORBIDDEN-${label.replace(/\s+/g, '_').toUpperCase()}`,
|
||||
severity: 'error',
|
||||
category: 'forbidden_formulation' as ValidationFinding['category'],
|
||||
title: `Verbotene Formulierung: "${label}"`,
|
||||
description: `Die Formulierung "${label}" impliziert eine nachgewiesene Compliance, die ohne ausreichenden Nachweis (Evidence >= E2, validiert) nicht verwendet werden darf.`,
|
||||
documentType: 'vvt' as ScopeDocumentType,
|
||||
suggestion: `Verwende stattdessen: "${safeAlternative}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
/**
|
||||
* Stufe 1: Deterministische Pruefung
|
||||
*/
|
||||
@@ -221,10 +291,18 @@ export async function POST(request: NextRequest) {
|
||||
// LLM unavailable, continue with deterministic results only
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Stufe 1b: Verbotene Formulierungen (Anti-Fake-Evidence)
|
||||
// ---------------------------------------------------------------
|
||||
const forbiddenFindings = checkForbiddenFormulations(
|
||||
draftContent || '',
|
||||
validationContext.evidenceContext,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Combine results
|
||||
// ---------------------------------------------------------------
|
||||
const allFindings = [...deterministicFindings, ...llmFindings]
|
||||
const allFindings = [...deterministicFindings, ...forbiddenFindings, ...llmFindings]
|
||||
const errors = allFindings.filter(f => f.severity === 'error')
|
||||
const warnings = allFindings.filter(f => f.severity === 'warning')
|
||||
const suggestions = allFindings.filter(f => f.severity === 'suggestion')
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
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/ai-registration/${id}`, {
|
||||
method: 'PUT',
|
||||
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 update registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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 tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registrations' }, { 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/ai-registration`, {
|
||||
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 registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
108
admin-compliance/app/api/sdk/v1/audit-llm/[[...path]]/route.ts
Normal file
108
admin-compliance/app/api/sdk/v1/audit-llm/[[...path]]/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* LLM Audit API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/audit-llm/* requests to ai-compliance-sdk /sdk/v1/audit/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/audit`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle export endpoints that may return CSV
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('text/csv') || contentType.includes('application/octet-stream')) {
|
||||
const blob = await response.arrayBuffer()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || 'attachment',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('LLM Audit API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/canonical?endpoint=...
|
||||
*
|
||||
* Routes to backend canonical control endpoints:
|
||||
* endpoint=frameworks → GET /api/compliance/v1/canonical/frameworks
|
||||
* endpoint=controls → GET /api/compliance/v1/canonical/controls(?severity=...&domain=...)
|
||||
* endpoint=control&id= → GET /api/compliance/v1/canonical/controls/{id}
|
||||
* endpoint=sources → GET /api/compliance/v1/canonical/sources
|
||||
* endpoint=licenses → GET /api/compliance/v1/canonical/licenses
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'frameworks'
|
||||
|
||||
let backendPath: string
|
||||
|
||||
switch (endpoint) {
|
||||
case 'frameworks':
|
||||
backendPath = '/api/compliance/v1/canonical/frameworks'
|
||||
break
|
||||
|
||||
case 'controls': {
|
||||
const controlParams = new URLSearchParams()
|
||||
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset']
|
||||
for (const key of passthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) controlParams.set(key, val)
|
||||
}
|
||||
const qs = controlParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls${qs ? `?${qs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'controls-count': {
|
||||
const countParams = new URLSearchParams()
|
||||
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||
for (const key of countPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) countParams.set(key, val)
|
||||
}
|
||||
const countQs = countParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-count${countQs ? `?${countQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'controls-meta': {
|
||||
const metaParams = new URLSearchParams()
|
||||
const metaPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||
for (const key of metaPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) metaParams.set(key, val)
|
||||
}
|
||||
const metaQs = metaParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-meta${metaQs ? `?${metaQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'control': {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'sources':
|
||||
backendPath = '/api/compliance/v1/canonical/sources'
|
||||
break
|
||||
|
||||
case 'licenses':
|
||||
backendPath = '/api/compliance/v1/canonical/licenses'
|
||||
break
|
||||
|
||||
// Generator endpoints
|
||||
case 'generate-jobs':
|
||||
backendPath = '/api/compliance/v1/canonical/generate/jobs'
|
||||
break
|
||||
|
||||
case 'generate-status': {
|
||||
const jobId = searchParams.get('jobId')
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/status/${encodeURIComponent(jobId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'review-queue': {
|
||||
const state = searchParams.get('release_state') || 'needs_review'
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review-queue?release_state=${encodeURIComponent(state)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'processed-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/generate/processed-stats'
|
||||
break
|
||||
|
||||
case 'categories':
|
||||
backendPath = '/api/compliance/v1/canonical/categories'
|
||||
break
|
||||
|
||||
case 'traceability': {
|
||||
const traceId = searchParams.get('id')
|
||||
if (!traceId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(traceId)}/traceability`
|
||||
break
|
||||
}
|
||||
|
||||
case 'provenance': {
|
||||
const provId = searchParams.get('id')
|
||||
if (!provId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(provId)}/provenance`
|
||||
break
|
||||
}
|
||||
|
||||
case 'atomic-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/controls/atomic-stats'
|
||||
break
|
||||
|
||||
case 'similar': {
|
||||
const simControlId = searchParams.get('id')
|
||||
if (!simControlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
const simThreshold = searchParams.get('threshold') || '0.85'
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(simControlId)}/similar?threshold=${simThreshold}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'blocked-sources':
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources'
|
||||
break
|
||||
|
||||
case 'v1-matches': {
|
||||
const matchId = searchParams.get('id')
|
||||
if (!matchId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(matchId)}/v1-matches`
|
||||
break
|
||||
}
|
||||
|
||||
case 'v1-enrichment-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/controls/v1-enrichment-stats'
|
||||
break
|
||||
|
||||
case 'obligation-dedup-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/obligations/dedup-stats'
|
||||
break
|
||||
|
||||
case 'controls-customer': {
|
||||
const custSeverity = searchParams.get('severity')
|
||||
const custDomain = searchParams.get('domain')
|
||||
const custParams = new URLSearchParams()
|
||||
if (custSeverity) custParams.set('severity', custSeverity)
|
||||
if (custDomain) custParams.set('domain', custDomain)
|
||||
const custQs = custParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-customer${custQs ? `?${custQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown endpoint: ${endpoint}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Canonical control proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/canonical?endpoint=...
|
||||
*
|
||||
* endpoint=create-control → POST /api/compliance/v1/canonical/controls
|
||||
* endpoint=similarity-check&id= → POST /api/compliance/v1/canonical/controls/{id}/similarity-check
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
const body = await request.json()
|
||||
|
||||
let backendPath: string
|
||||
|
||||
if (endpoint === 'create-control') {
|
||||
backendPath = '/api/compliance/v1/canonical/controls'
|
||||
} else if (endpoint === 'generate') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate'
|
||||
} else if (endpoint === 'review') {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review/${encodeURIComponent(controlId)}`
|
||||
} else if (endpoint === 'bulk-review') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
||||
} else if (endpoint === 'blocked-sources-cleanup') {
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
||||
} else if (endpoint === 'enrich-v1-matches') {
|
||||
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||
const batchSize = searchParams.get('batch_size') ?? '100'
|
||||
const enrichOffset = searchParams.get('offset') ?? '0'
|
||||
backendPath = `/api/compliance/v1/canonical/controls/enrich-v1-matches?dry_run=${dryRun}&batch_size=${batchSize}&offset=${enrichOffset}`
|
||||
} else if (endpoint === 'obligation-dedup') {
|
||||
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||
const batchSize = searchParams.get('batch_size') ?? '0'
|
||||
const dedupOffset = searchParams.get('offset') ?? '0'
|
||||
backendPath = `/api/compliance/v1/canonical/obligations/dedup?dry_run=${dryRun}&batch_size=${batchSize}&offset=${dedupOffset}`
|
||||
} else if (endpoint === 'similarity-check') {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}/similarity-check`
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unknown POST endpoint: ${endpoint}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
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(), { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Canonical control POST proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PUT /api/sdk/v1/canonical?endpoint=update-control&id=AUTH-001
|
||||
*
|
||||
* Routes to: PUT /api/compliance/v1/canonical/controls/{id}
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
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('Canonical control PUT proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/canonical?id=AUTH-001
|
||||
*
|
||||
* Routes to: DELETE /api/compliance/v1/canonical/controls/{id}
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
console.error('Canonical control DELETE proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function getIds(request: NextRequest, body?: Record<string, unknown>) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||
const projectId = searchParams.get('project_id') || (body?.project_id as string) || ''
|
||||
const qs = projectId
|
||||
? `tenant_id=${encodeURIComponent(tenantId)}&project_id=${encodeURIComponent(projectId)}`
|
||||
: `tenant_id=${encodeURIComponent(tenantId)}`
|
||||
return { tenantId, projectId, qs }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { tenantId, qs } = getIds(request)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
@@ -47,10 +56,10 @@ export async function GET(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || 'default'
|
||||
const { tenantId, qs } = getIds(request, body)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -86,11 +95,10 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||
const { tenantId, qs } = getIds(request)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@@ -124,10 +132,10 @@ export async function DELETE(request: NextRequest) {
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || 'default'
|
||||
const { tenantId, qs } = getIds(request, body)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/v1/company-profile?${qs}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/compliance-scope → Backend GET /api/v1/compliance-scope
|
||||
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
|
||||
const tenantId = searchParams.get('tenant_id') || 'default'
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
`${BACKEND_URL}/api/compliance/v1/compliance-scope?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,39 +1,52 @@
|
||||
/**
|
||||
* DSGVO API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/dsgvo/* requests to ai-compliance-sdk backend
|
||||
* Evidence Checks API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/compliance/evidence-checks/* requests to backend-compliance
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[],
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments.join('/')
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const url = `${SDK_BACKEND_URL}/sdk/v1/dsgvo/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
const basePath = `${BACKEND_URL}/api/compliance/evidence-checks`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': 'admin',
|
||||
}
|
||||
|
||||
// Forward auth headers if present
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
|
||||
const userIdHeader = request.headers.get('x-user-id')
|
||||
if (userIdHeader) {
|
||||
headers['X-User-Id'] = userIdHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
|
||||
// Add body for POST/PUT/PATCH methods
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
@@ -43,27 +56,13 @@ async function proxyRequest(
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body - continue without
|
||||
// Empty or invalid body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle non-JSON responses (e.g., PDF export)
|
||||
const responseContentType = response.headers.get('content-type')
|
||||
if (responseContentType?.includes('application/pdf') ||
|
||||
responseContentType?.includes('application/octet-stream')) {
|
||||
const blob = await response.blob()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
@@ -81,9 +80,9 @@ async function proxyRequest(
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('DSGVO API proxy error:', error)
|
||||
console.error('Evidence Checks API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
@@ -91,7 +90,7 @@ async function proxyRequest(
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
@@ -99,7 +98,7 @@ export async function GET(
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
@@ -107,7 +106,7 @@ export async function POST(
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
@@ -115,7 +114,7 @@ export async function PUT(
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
@@ -123,7 +122,7 @@ export async function PATCH(
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Process Tasks API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/compliance/process-tasks/* requests to backend-compliance
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/process-tasks`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': 'admin',
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
|
||||
const userIdHeader = request.headers.get('x-user-id')
|
||||
if (userIdHeader) {
|
||||
headers['X-User-Id'] = userIdHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text && text.trim()) {
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Process Tasks API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -22,6 +22,8 @@ async function proxyRequest(
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': 'admin',
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
@@ -34,6 +36,11 @@ async function proxyRequest(
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
|
||||
const userIdHeader = request.headers.get('x-user-id')
|
||||
if (userIdHeader) {
|
||||
headers['X-User-Id'] = userIdHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/import/:id → Backend DELETE /api/v1/import/:id
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/import/analyze → Backend POST /api/v1/import/analyze
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/import → Backend GET /api/v1/import
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Incidents/Breach Management API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/incidents/* requests to ai-compliance-sdk backend
|
||||
* Proxies all /api/sdk/v1/incidents/* requests to backend-compliance (Python)
|
||||
* Python backend is Source of Truth (migrated from Go ai-compliance-sdk)
|
||||
* Supports PDF generation for authority notification forms
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
@@ -15,7 +18,7 @@ async function proxyRequest(
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/incidents`
|
||||
const basePath = `${BACKEND_URL}/api/compliance/incidents`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
@@ -30,10 +33,8 @@ async function proxyRequest(
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
if (tenantHeader) {
|
||||
headers['X-Tenant-Id'] = tenantHeader
|
||||
}
|
||||
headers['X-Tenant-Id'] = request.headers.get('x-tenant-id') || DEFAULT_TENANT_ID
|
||||
headers['X-User-Id'] = request.headers.get('x-user-id') || DEFAULT_USER_ID
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
|
||||
111
admin-compliance/app/api/sdk/v1/isms/[[...path]]/route.ts
Normal file
111
admin-compliance/app/api/sdk/v1/isms/[[...path]]/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ISMS (ISO 27001) API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/isms/* requests to backend-compliance /api/isms/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/isms`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('ISMS API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@@ -9,7 +9,7 @@ export async function POST(
|
||||
try {
|
||||
const { moduleId } = await params
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/activate`,
|
||||
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}/activate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@@ -9,7 +9,7 @@ export async function POST(
|
||||
try {
|
||||
const { moduleId } = await params
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/deactivate`,
|
||||
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}/deactivate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/modules/:moduleId → Backend GET /api/modules/:moduleId
|
||||
@@ -13,7 +13,7 @@ export async function GET(
|
||||
const { moduleId } = await params
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}`,
|
||||
`${BACKEND_URL}/api/compliance/modules/${encodeURIComponent(moduleId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy to backend-compliance /api/modules endpoint.
|
||||
@@ -23,7 +23,7 @@ export async function GET(request: NextRequest) {
|
||||
if (aiComponents) params.set('ai_components', aiComponents)
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `${BACKEND_URL}/api/modules${queryString ? `?${queryString}` : ''}`
|
||||
const url = `${BACKEND_URL}/api/compliance/modules${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
@@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/modules`, {
|
||||
const response = await fetch(`${BACKEND_URL}/api/compliance/modules`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
119
admin-compliance/app/api/sdk/v1/portfolio/[[...path]]/route.ts
Normal file
119
admin-compliance/app/api/sdk/v1/portfolio/[[...path]]/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Portfolio API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/portfolio/* requests to ai-compliance-sdk backend
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/portfolios`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Portfolio API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/projects/{projectId}/permanent → Backend (hard delete)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}/permanent?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 permanently delete project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/projects/{projectId}/restore → Backend
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}/restore?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
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 restore project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
120
admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts
Normal file
120
admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/projects/{projectId} → Backend
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
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 get project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PATCH /api/sdk/v1/projects/{projectId} → Backend
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || request.headers.get('X-Tenant-ID') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to update project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/projects/{projectId} → Backend (soft delete)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params
|
||||
const tenantId = request.headers.get('X-Tenant-ID') ||
|
||||
new URL(request.url).searchParams.get('tenant_id') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects/${projectId}?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 archive project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
75
admin-compliance/app/api/sdk/v1/projects/route.ts
Normal file
75
admin-compliance/app/api/sdk/v1/projects/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/projects → Backend GET /api/compliance/v1/projects
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenant_id') || request.headers.get('X-Tenant-ID') || ''
|
||||
const includeArchived = searchParams.get('include_archived') || 'false'
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects?tenant_id=${encodeURIComponent(tenantId)}&include_archived=${includeArchived}`,
|
||||
{
|
||||
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 list projects:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/projects → Backend POST /api/compliance/v1/projects
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const tenantId = body.tenant_id || request.headers.get('X-Tenant-ID') || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/projects?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 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json(), { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
125
admin-compliance/app/api/sdk/v1/rbac/[[...path]]/route.ts
Normal file
125
admin-compliance/app/api/sdk/v1/rbac/[[...path]]/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* RBAC Admin API Proxy - Catch-all route
|
||||
* Proxies /api/sdk/v1/rbac/<resource>/... to ai-compliance-sdk /sdk/v1/<resource>/...
|
||||
*
|
||||
* Mapping: /rbac/tenants/... → /sdk/v1/tenants/...
|
||||
* /rbac/namespaces/... → /sdk/v1/namespaces/...
|
||||
* /rbac/roles/... → /sdk/v1/roles/...
|
||||
* /rbac/user-roles/... → /sdk/v1/user-roles/...
|
||||
* /rbac/permissions/... → /sdk/v1/permissions/...
|
||||
* /rbac/llm/policies/... → /sdk/v1/llm/policies/...
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
// Path segments come as the full sub-path after /rbac/
|
||||
// e.g. /rbac/tenants/123 → pathSegments = ['tenants', '123']
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const url = `${SDK_BACKEND_URL}/sdk/v1/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('RBAC API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Roadmap Items API Proxy - Catch-all route
|
||||
* Proxies /api/sdk/v1/roadmap-items/* to ai-compliance-sdk /sdk/v1/roadmap-items/*
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/roadmap-items`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Roadmap Items API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Vendor Compliance API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/vendors/* requests to ai-compliance-sdk backend
|
||||
* Roadmap API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/roadmap/* requests to ai-compliance-sdk backend
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
@@ -14,7 +14,7 @@ async function proxyRequest(
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/vendors`
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/roadmaps`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
@@ -24,8 +24,7 @@ async function proxyRequest(
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Forward all relevant headers
|
||||
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
@@ -33,42 +32,27 @@ async function proxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text && text.trim()) {
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body - continue without
|
||||
}
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle non-JSON responses (e.g., PDF exports)
|
||||
const responseContentType = response.headers.get('content-type')
|
||||
if (responseContentType?.includes('application/pdf') ||
|
||||
responseContentType?.includes('application/octet-stream')) {
|
||||
const blob = await response.blob()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
@@ -83,10 +67,22 @@ async function proxyRequest(
|
||||
)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/octet-stream') || contentType.includes('text/csv')) {
|
||||
const blob = await response.blob()
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Vendor Compliance API proxy error:', error)
|
||||
console.error('Roadmap API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/screening → Backend GET /api/v1/screening
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/screening/scan → Backend POST /api/v1/screening/scan
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -2,17 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Pool } from 'pg'
|
||||
|
||||
/**
|
||||
* SDK State Management API
|
||||
* SDK State Management API (Multi-Project)
|
||||
*
|
||||
* GET /api/sdk/v1/state?tenantId=xxx - Load state for a tenant
|
||||
* POST /api/sdk/v1/state - Save state for a tenant
|
||||
* DELETE /api/sdk/v1/state?tenantId=xxx - Clear state for a tenant
|
||||
* GET /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Load state for a tenant+project
|
||||
* POST /api/sdk/v1/state - Save state for a tenant+project
|
||||
* DELETE /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Clear state
|
||||
*
|
||||
* Features:
|
||||
* - Versioning for optimistic locking
|
||||
* - Last-Modified headers
|
||||
* - ETag support for caching
|
||||
* - PostgreSQL persistence (with InMemory fallback)
|
||||
* - projectId support for multi-project architecture
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
@@ -32,25 +33,31 @@ interface StoredState {
|
||||
// =============================================================================
|
||||
|
||||
interface StateStore {
|
||||
get(tenantId: string): Promise<StoredState | null>
|
||||
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState>
|
||||
delete(tenantId: string): Promise<boolean>
|
||||
get(tenantId: string, projectId?: string): Promise<StoredState | null>
|
||||
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise<StoredState>
|
||||
delete(tenantId: string, projectId?: string): Promise<boolean>
|
||||
}
|
||||
|
||||
class InMemoryStateStore implements StateStore {
|
||||
private store: Map<string, StoredState> = new Map()
|
||||
|
||||
async get(tenantId: string): Promise<StoredState | null> {
|
||||
return this.store.get(tenantId) || null
|
||||
private key(tenantId: string, projectId?: string): string {
|
||||
return projectId ? `${tenantId}:${projectId}` : tenantId
|
||||
}
|
||||
|
||||
async get(tenantId: string, projectId?: string): Promise<StoredState | null> {
|
||||
return this.store.get(this.key(tenantId, projectId)) || null
|
||||
}
|
||||
|
||||
async save(
|
||||
tenantId: string,
|
||||
state: unknown,
|
||||
userId?: string,
|
||||
expectedVersion?: number
|
||||
expectedVersion?: number,
|
||||
projectId?: string
|
||||
): Promise<StoredState> {
|
||||
const existing = this.store.get(tenantId)
|
||||
const k = this.key(tenantId, projectId)
|
||||
const existing = this.store.get(k)
|
||||
|
||||
if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) {
|
||||
const error = new Error('Version conflict') as Error & { status: number }
|
||||
@@ -72,12 +79,12 @@ class InMemoryStateStore implements StateStore {
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
this.store.set(tenantId, stored)
|
||||
this.store.set(k, stored)
|
||||
return stored
|
||||
}
|
||||
|
||||
async delete(tenantId: string): Promise<boolean> {
|
||||
return this.store.delete(tenantId)
|
||||
async delete(tenantId: string, projectId?: string): Promise<boolean> {
|
||||
return this.store.delete(this.key(tenantId, projectId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,11 +100,26 @@ class PostgreSQLStateStore implements StateStore {
|
||||
})
|
||||
}
|
||||
|
||||
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]
|
||||
)
|
||||
async get(tenantId: string, projectId?: string): Promise<StoredState | null> {
|
||||
let result
|
||||
if (projectId) {
|
||||
result = await this.pool.query(
|
||||
'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1 AND project_id = $2',
|
||||
[tenantId, projectId]
|
||||
)
|
||||
} else {
|
||||
// Backwards compatibility: find the single active project for this tenant
|
||||
result = await this.pool.query(
|
||||
`SELECT s.state, s.version, s.user_id, s.created_at, s.updated_at
|
||||
FROM sdk_states s
|
||||
LEFT JOIN compliance_projects p ON s.project_id = p.id
|
||||
WHERE s.tenant_id = $1
|
||||
AND (p.status = 'active' OR p.id IS NULL)
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT 1`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
if (result.rows.length === 0) return null
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
@@ -109,25 +131,71 @@ class PostgreSQLStateStore implements StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState> {
|
||||
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): 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])
|
||||
let result
|
||||
|
||||
if (projectId) {
|
||||
// Multi-project: UPSERT on (tenant_id, project_id)
|
||||
result = await this.pool.query(`
|
||||
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id, project_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, projectId])
|
||||
} else {
|
||||
// Backwards compatibility: find the single project for this tenant
|
||||
// First try to find an existing project
|
||||
const projectResult = await this.pool.query(
|
||||
`SELECT id FROM compliance_projects WHERE tenant_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT 1`,
|
||||
[tenantId]
|
||||
)
|
||||
|
||||
if (projectResult.rows.length > 0) {
|
||||
const foundProjectId = projectResult.rows[0].id
|
||||
result = await this.pool.query(`
|
||||
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id, project_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, foundProjectId])
|
||||
} else {
|
||||
// No project exists — create a default one
|
||||
const newProject = await this.pool.query(
|
||||
`INSERT INTO compliance_projects (tenant_id, name, customer_type, status)
|
||||
VALUES ($1, 'Projekt 1', 'new', 'active')
|
||||
RETURNING id`,
|
||||
[tenantId]
|
||||
)
|
||||
const newProjectId = newProject.rows[0].id
|
||||
result = await this.pool.query(`
|
||||
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
|
||||
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id, project_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, newProjectId])
|
||||
}
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
const error = new Error('Version conflict') as Error & { status: number }
|
||||
@@ -145,11 +213,19 @@ class PostgreSQLStateStore implements StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
async delete(tenantId: string): Promise<boolean> {
|
||||
const result = await this.pool.query(
|
||||
'DELETE FROM sdk_states WHERE tenant_id = $1',
|
||||
[tenantId]
|
||||
)
|
||||
async delete(tenantId: string, projectId?: string): Promise<boolean> {
|
||||
let result
|
||||
if (projectId) {
|
||||
result = await this.pool.query(
|
||||
'DELETE FROM sdk_states WHERE tenant_id = $1 AND project_id = $2',
|
||||
[tenantId, projectId]
|
||||
)
|
||||
} else {
|
||||
result = await this.pool.query(
|
||||
'DELETE FROM sdk_states WHERE tenant_id = $1',
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
}
|
||||
@@ -186,6 +262,7 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId')
|
||||
const projectId = searchParams.get('projectId') || undefined
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
@@ -194,7 +271,7 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const stored = await stateStore.get(tenantId)
|
||||
const stored = await stateStore.get(tenantId, projectId)
|
||||
|
||||
if (!stored) {
|
||||
return NextResponse.json(
|
||||
@@ -216,6 +293,7 @@ export async function GET(request: NextRequest) {
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
projectId: projectId || null,
|
||||
state: stored.state,
|
||||
version: stored.version,
|
||||
lastModified: stored.updatedAt,
|
||||
@@ -241,7 +319,7 @@ export async function GET(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { tenantId, state, version } = body
|
||||
const { tenantId, state, version, projectId } = body
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
@@ -261,7 +339,7 @@ export async function POST(request: NextRequest) {
|
||||
const ifMatch = request.headers.get('If-Match')
|
||||
const expectedVersion = ifMatch ? parseInt(ifMatch, 10) : version
|
||||
|
||||
const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion)
|
||||
const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion, projectId || undefined)
|
||||
|
||||
const etag = generateETag(stored.version, stored.updatedAt)
|
||||
|
||||
@@ -270,6 +348,7 @@ export async function POST(request: NextRequest) {
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
projectId: projectId || null,
|
||||
state: stored.state,
|
||||
version: stored.version,
|
||||
lastModified: stored.updatedAt,
|
||||
@@ -309,6 +388,7 @@ export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId')
|
||||
const projectId = searchParams.get('projectId') || undefined
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
@@ -317,7 +397,7 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const deleted = await stateStore.delete(tenantId)
|
||||
const deleted = await stateStore.delete(tenantId, projectId)
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,119 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
TOMGeneratorState,
|
||||
createEmptyTOMGeneratorState,
|
||||
} from '@/lib/sdk/tom-generator/types'
|
||||
|
||||
/**
|
||||
* TOM Generator State API
|
||||
* TOM Generator State API — Proxy to backend-compliance (Python/FastAPI)
|
||||
*
|
||||
* GET /api/sdk/v1/tom-generator/state?tenantId=xxx - Load TOM generator state
|
||||
* POST /api/sdk/v1/tom-generator/state - Save TOM generator state
|
||||
* DELETE /api/sdk/v1/tom-generator/state?tenantId=xxx - Clear state
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// STORAGE (In-Memory for development)
|
||||
// =============================================================================
|
||||
|
||||
interface StoredTOMState {
|
||||
state: TOMGeneratorState
|
||||
version: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
class InMemoryTOMStateStore {
|
||||
private store: Map<string, StoredTOMState> = new Map()
|
||||
|
||||
async get(tenantId: string): Promise<StoredTOMState | null> {
|
||||
return this.store.get(tenantId) || null
|
||||
}
|
||||
|
||||
async save(tenantId: string, state: TOMGeneratorState, expectedVersion?: number): Promise<StoredTOMState> {
|
||||
const existing = this.store.get(tenantId)
|
||||
|
||||
if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) {
|
||||
const error = new Error('Version conflict') as Error & { status: number }
|
||||
error.status = 409
|
||||
throw error
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const newVersion = existing ? existing.version + 1 : 1
|
||||
|
||||
const stored: StoredTOMState = {
|
||||
state: {
|
||||
...state,
|
||||
updatedAt: new Date(now),
|
||||
},
|
||||
version: newVersion,
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
this.store.set(tenantId, stored)
|
||||
return stored
|
||||
}
|
||||
|
||||
async delete(tenantId: string): Promise<boolean> {
|
||||
return this.store.delete(tenantId)
|
||||
}
|
||||
|
||||
async list(): Promise<{ tenantId: string; updatedAt: string }[]> {
|
||||
const result: { tenantId: string; updatedAt: string }[] = []
|
||||
this.store.forEach((value, key) => {
|
||||
result.push({ tenantId: key, updatedAt: value.updatedAt })
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
const stateStore = new InMemoryTOMStateStore()
|
||||
|
||||
// =============================================================================
|
||||
// HANDLERS
|
||||
// =============================================================================
|
||||
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId')
|
||||
|
||||
// List all states if no tenantId provided
|
||||
if (!tenantId) {
|
||||
const states = await stateStore.list()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: states,
|
||||
})
|
||||
}
|
||||
const url = tenantId
|
||||
? `${BACKEND_URL}/api/compliance/tom/state?tenant_id=${encodeURIComponent(tenantId)}`
|
||||
: `${BACKEND_URL}/api/compliance/tom/state`
|
||||
|
||||
const stored = await stateStore.get(tenantId)
|
||||
|
||||
if (!stored) {
|
||||
// Return empty state for new tenants
|
||||
const emptyState = createEmptyTOMGeneratorState(tenantId)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
state: emptyState,
|
||||
version: 0,
|
||||
isNew: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
state: stored.state,
|
||||
version: stored.version,
|
||||
lastModified: stored.updatedAt,
|
||||
},
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to load TOM generator state:', error)
|
||||
return NextResponse.json(
|
||||
@@ -142,65 +53,19 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Deserialize dates
|
||||
const parsedState: TOMGeneratorState = {
|
||||
...state,
|
||||
createdAt: new Date(state.createdAt),
|
||||
updatedAt: new Date(state.updatedAt),
|
||||
steps: state.steps.map((step: { id: string; completed: boolean; data: unknown; validatedAt: string | null }) => ({
|
||||
...step,
|
||||
validatedAt: step.validatedAt ? new Date(step.validatedAt) : null,
|
||||
})),
|
||||
documents: state.documents?.map((doc: { uploadedAt: string; validFrom?: string; validUntil?: string; aiAnalysis?: { analyzedAt: string } }) => ({
|
||||
...doc,
|
||||
uploadedAt: new Date(doc.uploadedAt),
|
||||
validFrom: doc.validFrom ? new Date(doc.validFrom) : null,
|
||||
validUntil: doc.validUntil ? new Date(doc.validUntil) : null,
|
||||
aiAnalysis: doc.aiAnalysis ? {
|
||||
...doc.aiAnalysis,
|
||||
analyzedAt: new Date(doc.aiAnalysis.analyzedAt),
|
||||
} : null,
|
||||
})) || [],
|
||||
derivedTOMs: state.derivedTOMs?.map((tom: { implementationDate?: string; reviewDate?: string }) => ({
|
||||
...tom,
|
||||
implementationDate: tom.implementationDate ? new Date(tom.implementationDate) : null,
|
||||
reviewDate: tom.reviewDate ? new Date(tom.reviewDate) : null,
|
||||
})) || [],
|
||||
gapAnalysis: state.gapAnalysis ? {
|
||||
...state.gapAnalysis,
|
||||
generatedAt: new Date(state.gapAnalysis.generatedAt),
|
||||
} : null,
|
||||
exports: state.exports?.map((exp: { generatedAt: string }) => ({
|
||||
...exp,
|
||||
generatedAt: new Date(exp.generatedAt),
|
||||
})) || [],
|
||||
}
|
||||
|
||||
const stored = await stateStore.save(tenantId, parsedState, version)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tenantId,
|
||||
state: stored.state,
|
||||
version: stored.version,
|
||||
lastModified: stored.updatedAt,
|
||||
},
|
||||
const res = await fetch(`${BACKEND_URL}/api/compliance/tom/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
state,
|
||||
version,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
const err = error as Error & { status?: number }
|
||||
|
||||
if (err.status === 409 || err.message === 'Version conflict') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Version conflict. State was modified by another request.',
|
||||
code: 'VERSION_CONFLICT',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
console.error('Failed to save TOM generator state:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to save state' },
|
||||
@@ -221,14 +86,16 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const deleted = await stateStore.delete(tenantId)
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/tom/state?tenant_id=${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tenantId,
|
||||
deleted,
|
||||
deletedAt: new Date().toISOString(),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: res.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete TOM generator state:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -53,7 +53,18 @@ async function proxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// Handle redirects (e.g. media stream presigned URL)
|
||||
if (response.status === 307 || response.status === 302) {
|
||||
const location = response.headers.get('location')
|
||||
if (location) {
|
||||
return NextResponse.redirect(location)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -69,6 +80,19 @@ async function proxyRequest(
|
||||
)
|
||||
}
|
||||
|
||||
// Handle binary responses (PDF, octet-stream)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) {
|
||||
const buffer = await response.arrayBuffer()
|
||||
return new NextResponse(buffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||
*/
|
||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
const subPath = path ? path.join('/') : ''
|
||||
const search = request.nextUrl.search || ''
|
||||
const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}`
|
||||
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Tenant-ID': tenantID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') {
|
||||
const body = await request.json()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxyRequest
|
||||
export const POST = proxyRequest
|
||||
export const DELETE = proxyRequest
|
||||
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
|
||||
* Returns the decision tree definition (questions, structure)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
|
||||
headers: { 'X-Tenant-ID': tenantID },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Decision tree GET 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('Decision tree proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
async function proxyRequest(request: NextRequest, method: string) {
|
||||
try {
|
||||
const pathSegments = request.nextUrl.pathname.replace('/api/sdk/v1/ucca/obligations/', '')
|
||||
const targetUrl = `${SDK_BASE_URL}/sdk/v1/ucca/obligations/${pathSegments}${request.nextUrl.search}`
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const tenantId = request.headers.get('X-Tenant-ID')
|
||||
if (tenantId) headers['X-Tenant-ID'] = tenantId
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = { method, headers }
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
|
||||
@@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Forward the request to the SDK backend
|
||||
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/assess`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Forward tenant ID if present
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
@@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Forward the request to the SDK backend
|
||||
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/export/direct`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Forward tenant ID if present
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Vendor Compliance API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/vendor-compliance/* requests to backend-compliance
|
||||
*
|
||||
* Backend routes: vendors, contracts, findings, control-instances, controls, export
|
||||
* All under /api/compliance/vendor-compliance/ prefix on backend-compliance:8002
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${BACKEND_URL}/api/compliance/vendor-compliance`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Vendor Compliance API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ContractDocument } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
// In-memory storage for demo purposes
|
||||
const contracts: Map<string, ContractDocument> = new Map()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const contractList = Array.from(contracts.values())
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: contractList,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching contracts:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch contracts' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Handle multipart form data for file upload
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const vendorId = formData.get('vendorId') as string
|
||||
const metadataStr = formData.get('metadata') as string
|
||||
|
||||
if (!file || !vendorId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'File and vendorId are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const metadata = metadataStr ? JSON.parse(metadataStr) : {}
|
||||
const id = uuidv4()
|
||||
|
||||
// In production, upload file to storage (MinIO, S3, etc.)
|
||||
const storagePath = `contracts/${id}/${file.name}`
|
||||
|
||||
const contract: ContractDocument = {
|
||||
id,
|
||||
tenantId: 'default',
|
||||
vendorId,
|
||||
fileName: `${id}-${file.name}`,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
fileSize: file.size,
|
||||
storagePath,
|
||||
documentType: metadata.documentType || 'OTHER',
|
||||
version: metadata.version || '1.0',
|
||||
previousVersionId: metadata.previousVersionId,
|
||||
parties: metadata.parties,
|
||||
effectiveDate: metadata.effectiveDate ? new Date(metadata.effectiveDate) : undefined,
|
||||
expirationDate: metadata.expirationDate ? new Date(metadata.expirationDate) : undefined,
|
||||
autoRenewal: metadata.autoRenewal,
|
||||
renewalNoticePeriod: metadata.renewalNoticePeriod,
|
||||
terminationNoticePeriod: metadata.terminationNoticePeriod,
|
||||
reviewStatus: 'PENDING',
|
||||
status: 'DRAFT',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
contracts.set(id, contract)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: contract,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error uploading contract:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to upload contract' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { CONTROLS_LIBRARY } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const domain = searchParams.get('domain')
|
||||
|
||||
let controls = [...CONTROLS_LIBRARY]
|
||||
|
||||
// Filter by domain if provided
|
||||
if (domain) {
|
||||
controls = controls.filter((c) => c.domain === domain)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: controls,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching controls:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch controls' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/vendor-compliance/export/[reportId]/download
|
||||
*
|
||||
* Download a generated report file.
|
||||
* In production, this would redirect to a signed MinIO/S3 URL or stream the file.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ reportId: string }> }
|
||||
) {
|
||||
const { reportId } = await params
|
||||
|
||||
// TODO: Implement actual file download
|
||||
// This would typically:
|
||||
// 1. Verify report exists and user has access
|
||||
// 2. Generate signed URL for MinIO/S3
|
||||
// 3. Redirect to signed URL or stream file
|
||||
|
||||
// For now, return a placeholder PDF
|
||||
const placeholderContent = `
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 200 >>
|
||||
stream
|
||||
BT
|
||||
/F1 24 Tf
|
||||
100 700 Td
|
||||
(Vendor Compliance Report) Tj
|
||||
/F1 12 Tf
|
||||
100 650 Td
|
||||
(Report ID: ${reportId}) Tj
|
||||
100 620 Td
|
||||
(Generated: ${new Date().toISOString()}) Tj
|
||||
100 580 Td
|
||||
(This is a placeholder. Implement actual report generation.) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000266 00000 n
|
||||
0000000519 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
598
|
||||
%%EOF
|
||||
`.trim()
|
||||
|
||||
// Return as PDF
|
||||
return new NextResponse(placeholderContent, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="Report_${reportId.slice(0, 8)}.pdf"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/vendor-compliance/export/[reportId]
|
||||
*
|
||||
* Get report metadata by ID.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ reportId: string }> }
|
||||
) {
|
||||
const { reportId } = await params
|
||||
|
||||
// TODO: Fetch report metadata from database
|
||||
// For now, return mock data
|
||||
|
||||
return NextResponse.json({
|
||||
id: reportId,
|
||||
status: 'completed',
|
||||
filename: `Report_${reportId.slice(0, 8)}.pdf`,
|
||||
generatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24h
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/sdk/v1/vendor-compliance/export/[reportId]
|
||||
*
|
||||
* Delete a generated report.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ reportId: string }> }
|
||||
) {
|
||||
const { reportId } = await params
|
||||
|
||||
// TODO: Delete report from storage and database
|
||||
console.log('Deleting report:', reportId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedId: reportId,
|
||||
})
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
/**
|
||||
* POST /api/sdk/v1/vendor-compliance/export
|
||||
*
|
||||
* Generate and export reports in various formats.
|
||||
* Currently returns mock data - integrate with actual report generation service.
|
||||
*/
|
||||
|
||||
interface ExportConfig {
|
||||
reportType: 'VVT_EXPORT' | 'VENDOR_AUDIT' | 'ROPA' | 'MANAGEMENT_SUMMARY' | 'DPIA_INPUT'
|
||||
format: 'PDF' | 'DOCX' | 'XLSX' | 'JSON'
|
||||
scope: {
|
||||
vendorIds: string[]
|
||||
processingActivityIds: string[]
|
||||
includeFindings: boolean
|
||||
includeControls: boolean
|
||||
includeRiskAssessment: boolean
|
||||
dateRange?: {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REPORT_TYPE_NAMES: Record<ExportConfig['reportType'], string> = {
|
||||
VVT_EXPORT: 'Verarbeitungsverzeichnis',
|
||||
VENDOR_AUDIT: 'Vendor-Audit-Pack',
|
||||
ROPA: 'RoPA',
|
||||
MANAGEMENT_SUMMARY: 'Management-Summary',
|
||||
DPIA_INPUT: 'DSFA-Input',
|
||||
}
|
||||
|
||||
const FORMAT_EXTENSIONS: Record<ExportConfig['format'], string> = {
|
||||
PDF: 'pdf',
|
||||
DOCX: 'docx',
|
||||
XLSX: 'xlsx',
|
||||
JSON: 'json',
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config = (await request.json()) as ExportConfig
|
||||
|
||||
// Validate request
|
||||
if (!config.reportType || !config.format) {
|
||||
return NextResponse.json(
|
||||
{ error: 'reportType and format are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate report ID and filename
|
||||
const reportId = uuidv4()
|
||||
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||
const filename = `${REPORT_TYPE_NAMES[config.reportType]}_${timestamp}.${FORMAT_EXTENSIONS[config.format]}`
|
||||
|
||||
// TODO: Implement actual report generation
|
||||
// This would typically:
|
||||
// 1. Fetch data from database based on scope
|
||||
// 2. Generate report using template engine (e.g., docx-templates, pdfkit)
|
||||
// 3. Store in MinIO/S3
|
||||
// 4. Return download URL
|
||||
|
||||
// Mock implementation - simulate processing time
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// In production, this would be a signed URL to MinIO/S3
|
||||
const downloadUrl = `/api/sdk/v1/vendor-compliance/export/${reportId}/download`
|
||||
|
||||
// Log export for audit trail
|
||||
console.log('Export generated:', {
|
||||
reportId,
|
||||
reportType: config.reportType,
|
||||
format: config.format,
|
||||
scope: config.scope,
|
||||
filename,
|
||||
generatedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
id: reportId,
|
||||
reportType: config.reportType,
|
||||
format: config.format,
|
||||
filename,
|
||||
downloadUrl,
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: {
|
||||
vendorCount: config.scope.vendorIds?.length || 0,
|
||||
activityCount: config.scope.processingActivityIds?.length || 0,
|
||||
includesFindings: config.scope.includeFindings,
|
||||
includesControls: config.scope.includeControls,
|
||||
includesRiskAssessment: config.scope.includeRiskAssessment,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate export' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/vendor-compliance/export
|
||||
*
|
||||
* List recent exports for the current tenant.
|
||||
*/
|
||||
export async function GET() {
|
||||
// TODO: Implement fetching recent exports from database
|
||||
// For now, return empty list
|
||||
return NextResponse.json({
|
||||
exports: [],
|
||||
totalCount: 0,
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Finding } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
// In-memory storage for demo purposes
|
||||
const findings: Map<string, Finding> = new Map()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const vendorId = searchParams.get('vendorId')
|
||||
const contractId = searchParams.get('contractId')
|
||||
const status = searchParams.get('status')
|
||||
|
||||
let findingsList = Array.from(findings.values())
|
||||
|
||||
// Filter by vendor
|
||||
if (vendorId) {
|
||||
findingsList = findingsList.filter((f) => f.vendorId === vendorId)
|
||||
}
|
||||
|
||||
// Filter by contract
|
||||
if (contractId) {
|
||||
findingsList = findingsList.filter((f) => f.contractId === contractId)
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (status) {
|
||||
findingsList = findingsList.filter((f) => f.status === status)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: findingsList,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching findings:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch findings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// This would reference the same storage as the main route
|
||||
// In production, this would be database calls
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// In production, fetch from database
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null, // Would return the activity
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching processing activity:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch processing activity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
// In production, update in database
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { id, ...body, updatedAt: new Date() },
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating processing activity:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update processing activity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// In production, delete from database
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting processing activity:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete processing activity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ProcessingActivity, generateVVTId } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
// In-memory storage for demo purposes
|
||||
// In production, this would be replaced with database calls
|
||||
const processingActivities: Map<string, ProcessingActivity> = new Map()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const activities = Array.from(processingActivities.values())
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: activities,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching processing activities:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch processing activities' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Generate IDs
|
||||
const id = uuidv4()
|
||||
const existingIds = Array.from(processingActivities.values()).map((a) => a.vvtId)
|
||||
const vvtId = body.vvtId || generateVVTId(existingIds)
|
||||
|
||||
const activity: ProcessingActivity = {
|
||||
id,
|
||||
tenantId: 'default', // Would come from auth context
|
||||
vvtId,
|
||||
name: body.name,
|
||||
responsible: body.responsible,
|
||||
dpoContact: body.dpoContact,
|
||||
purposes: body.purposes || [],
|
||||
dataSubjectCategories: body.dataSubjectCategories || [],
|
||||
personalDataCategories: body.personalDataCategories || [],
|
||||
recipientCategories: body.recipientCategories || [],
|
||||
thirdCountryTransfers: body.thirdCountryTransfers || [],
|
||||
retentionPeriod: body.retentionPeriod || { description: { de: '', en: '' } },
|
||||
technicalMeasures: body.technicalMeasures || [],
|
||||
legalBasis: body.legalBasis || [],
|
||||
dataSources: body.dataSources || [],
|
||||
systems: body.systems || [],
|
||||
dataFlows: body.dataFlows || [],
|
||||
protectionLevel: body.protectionLevel || 'MEDIUM',
|
||||
dpiaRequired: body.dpiaRequired || false,
|
||||
dpiaJustification: body.dpiaJustification,
|
||||
subProcessors: body.subProcessors || [],
|
||||
legalRetentionBasis: body.legalRetentionBasis,
|
||||
status: body.status || 'DRAFT',
|
||||
owner: body.owner || '',
|
||||
lastReviewDate: body.lastReviewDate,
|
||||
nextReviewDate: body.nextReviewDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
processingActivities.set(id, activity)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: activity,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error creating processing activity:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create processing activity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Vendor } from '@/lib/sdk/vendor-compliance'
|
||||
|
||||
// In-memory storage for demo purposes
|
||||
const vendors: Map<string, Vendor> = new Map()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const vendorList = Array.from(vendors.values())
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: vendorList,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching vendors:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch vendors' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const id = uuidv4()
|
||||
|
||||
const vendor: Vendor = {
|
||||
id,
|
||||
tenantId: 'default',
|
||||
name: body.name,
|
||||
legalForm: body.legalForm,
|
||||
country: body.country,
|
||||
address: body.address,
|
||||
website: body.website,
|
||||
role: body.role,
|
||||
serviceDescription: body.serviceDescription,
|
||||
serviceCategory: body.serviceCategory,
|
||||
dataAccessLevel: body.dataAccessLevel || 'NONE',
|
||||
processingLocations: body.processingLocations || [],
|
||||
transferMechanisms: body.transferMechanisms || [],
|
||||
certifications: body.certifications || [],
|
||||
primaryContact: body.primaryContact,
|
||||
dpoContact: body.dpoContact,
|
||||
securityContact: body.securityContact,
|
||||
contractTypes: body.contractTypes || [],
|
||||
contracts: body.contracts || [],
|
||||
inherentRiskScore: body.inherentRiskScore || 50,
|
||||
residualRiskScore: body.residualRiskScore || 50,
|
||||
manualRiskAdjustment: body.manualRiskAdjustment,
|
||||
riskJustification: body.riskJustification,
|
||||
reviewFrequency: body.reviewFrequency || 'ANNUAL',
|
||||
lastReviewDate: body.lastReviewDate,
|
||||
nextReviewDate: body.nextReviewDate,
|
||||
status: body.status || 'ACTIVE',
|
||||
processingActivityIds: body.processingActivityIds || [],
|
||||
notes: body.notes,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
vendors.set(id, vendor)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: vendor,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error creating vendor:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create vendor' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
87
admin-compliance/app/api/sdk/v1/wiki/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/wiki?endpoint=...
|
||||
*
|
||||
* Routes to backend wiki endpoints:
|
||||
* endpoint=categories → GET /api/compliance/v1/wiki/categories
|
||||
* endpoint=articles → GET /api/compliance/v1/wiki/articles(?category_id=...)
|
||||
* endpoint=search → GET /api/compliance/v1/wiki/search?q=...
|
||||
* endpoint=article&id= → GET /api/compliance/v1/wiki/articles/{id}
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'categories'
|
||||
|
||||
let backendPath: string
|
||||
|
||||
switch (endpoint) {
|
||||
case 'categories':
|
||||
backendPath = '/api/compliance/v1/wiki/categories'
|
||||
break
|
||||
|
||||
case 'articles': {
|
||||
const categoryId = searchParams.get('category_id')
|
||||
backendPath = '/api/compliance/v1/wiki/articles'
|
||||
if (categoryId) {
|
||||
backendPath += `?category_id=${encodeURIComponent(categoryId)}`
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'article': {
|
||||
const articleId = searchParams.get('id')
|
||||
if (!articleId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing article id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
backendPath = `/api/compliance/v1/wiki/articles/${encodeURIComponent(articleId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'search': {
|
||||
const query = searchParams.get('q')
|
||||
if (!query) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing search query' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
backendPath = `/api/compliance/v1/wiki/search?q=${encodeURIComponent(query)}`
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown endpoint: ${endpoint}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Wiki proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
119
admin-compliance/app/api/sdk/v1/workshops/[[...path]]/route.ts
Normal file
119
admin-compliance/app/api/sdk/v1/workshops/[[...path]]/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Workshop API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/workshops/* requests to ai-compliance-sdk backend
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/workshops`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientUserId = request.headers.get('x-user-id')
|
||||
const clientTenantId = request.headers.get('x-tenant-id')
|
||||
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
const body = await request.text()
|
||||
if (body) {
|
||||
fetchOptions.body = body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Workshop API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PATCH')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -21,6 +22,8 @@ interface AISystem {
|
||||
assessmentResult: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SKELETON
|
||||
// =============================================================================
|
||||
@@ -306,12 +309,178 @@ function AISystemCard({
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SAVED RESULTS TAB
|
||||
// =============================================================================
|
||||
|
||||
interface SavedResult {
|
||||
id: string
|
||||
system_name: string
|
||||
system_description?: string
|
||||
high_risk_result: string
|
||||
gpai_result: { gpai_category: string; is_systemic_risk: boolean }
|
||||
combined_obligations: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function SavedResultsTab() {
|
||||
const [results, setResults] = useState<SavedResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/results')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResults(data.results || [])
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Ergebnis wirklich löschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/ucca/decision-tree/results/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setResults(prev => prev.filter(r => r.id !== id))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
unacceptable: 'Unzulässig',
|
||||
high_risk: 'Hochrisiko',
|
||||
limited_risk: 'Begrenztes Risiko',
|
||||
minimal_risk: 'Minimales Risiko',
|
||||
not_applicable: 'Nicht anwendbar',
|
||||
}
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
unacceptable: 'bg-red-100 text-red-700',
|
||||
high_risk: 'bg-orange-100 text-orange-700',
|
||||
limited_risk: 'bg-yellow-100 text-yellow-700',
|
||||
minimal_risk: 'bg-green-100 text-green-700',
|
||||
not_applicable: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const gpaiLabels: Record<string, string> = {
|
||||
none: 'Kein GPAI',
|
||||
standard: 'GPAI Standard',
|
||||
systemic: 'GPAI Systemisch',
|
||||
}
|
||||
|
||||
const gpaiColors: Record<string, string> = {
|
||||
none: 'bg-gray-100 text-gray-500',
|
||||
standard: 'bg-blue-100 text-blue-700',
|
||||
systemic: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Ergebnisse vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500">Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{results.map(r => (
|
||||
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{r.system_name}</h4>
|
||||
{r.system_description && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">{r.system_description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[r.high_risk_result] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{riskLabels[r.high_risk_result] || r.high_risk_result}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${gpaiColors[r.gpai_result?.gpai_category] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'}
|
||||
</span>
|
||||
{r.gpai_result?.is_systemic_risk && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Systemisch</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{r.combined_obligations?.length || 0} Pflichten · {new Date(r.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TABS
|
||||
// =============================================================================
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Übersicht',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decision-tree',
|
||||
label: 'Entscheidungsbaum',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
label: 'Ergebnisse',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AIActPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [systems, setSystems] = useState<AISystem[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -354,7 +523,6 @@ export default function AIActPage() {
|
||||
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||
setError(null)
|
||||
if (editingSystem) {
|
||||
// Edit existing system via PUT
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
|
||||
method: 'PUT',
|
||||
@@ -380,14 +548,12 @@ export default function AIActPage() {
|
||||
setError('Speichern fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: update locally
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id ? { ...s, ...data } : s
|
||||
))
|
||||
}
|
||||
setEditingSystem(null)
|
||||
} else {
|
||||
// Create new system via POST
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
|
||||
method: 'POST',
|
||||
@@ -415,7 +581,6 @@ export default function AIActPage() {
|
||||
setError('Registrierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: add locally
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: `ai-${Date.now()}`,
|
||||
@@ -503,17 +668,37 @@ export default function AIActPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
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>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
{activeTab === 'overview' && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
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>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
@@ -522,90 +707,105 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
))}
|
||||
</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">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<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.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<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.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
{/* Tab: Decision Tree */}
|
||||
{activeTab === 'decision-tree' && (
|
||||
<DecisionTreeWizard />
|
||||
)}
|
||||
|
||||
{/* Tab: Results */}
|
||||
{activeTab === 'results' && (
|
||||
<SavedResultsTab />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface Registration {
|
||||
id: string
|
||||
system_name: string
|
||||
system_version: string
|
||||
risk_classification: string
|
||||
gpai_classification: string
|
||||
registration_status: string
|
||||
eu_database_id: string
|
||||
provider_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||
}
|
||||
|
||||
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
system_name: '',
|
||||
system_version: '1.0',
|
||||
system_description: '',
|
||||
intended_purpose: '',
|
||||
provider_name: '',
|
||||
provider_legal_form: '',
|
||||
provider_address: '',
|
||||
provider_country: 'DE',
|
||||
eu_representative_name: '',
|
||||
eu_representative_contact: '',
|
||||
risk_classification: 'not_classified',
|
||||
annex_iii_category: '',
|
||||
gpai_classification: 'none',
|
||||
conformity_assessment_type: 'internal',
|
||||
notified_body_name: '',
|
||||
notified_body_id: '',
|
||||
ce_marking: false,
|
||||
training_data_summary: '',
|
||||
}
|
||||
|
||||
export default function AIRegistrationPage() {
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
const [wizardStep, setWizardStep] = useState(1)
|
||||
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { loadRegistrations() }, [])
|
||||
|
||||
async function loadRegistrations() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setRegistrations(data.registrations || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowWizard(false)
|
||||
setForm({ ...INITIAL_FORM })
|
||||
setWizardStep(1)
|
||||
loadRegistrations()
|
||||
} else {
|
||||
const data = await resp.json()
|
||||
setError(data.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||
if (resp.ok) {
|
||||
const reg = await resp.json()
|
||||
// Build export JSON client-side
|
||||
const exportData = {
|
||||
schema_version: '1.0',
|
||||
submission_type: 'ai_system_registration',
|
||||
regulation: 'EU AI Act (EU) 2024/1689',
|
||||
article: 'Art. 49',
|
||||
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
loadRegistrations()
|
||||
} catch {
|
||||
setError('Status-Aenderung fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
+ Neue Registrierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||
const count = registrations.filter(r => r.registration_status === status).length
|
||||
const style = STATUS_STYLES[status]
|
||||
return (
|
||||
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||
<div className="text-sm text-gray-600">{style.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Registrations List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{registrations.map(reg => {
|
||||
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||
return (
|
||||
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||
{reg.gpai_classification !== 'none' && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
JSON Export
|
||||
</button>
|
||||
{reg.registration_status === 'draft' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Bereit markieren
|
||||
</button>
|
||||
)}
|
||||
{reg.registration_status === 'ready' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
Als eingereicht markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wizard Modal */}
|
||||
{showWizard && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||
</div>
|
||||
{/* Step Indicator */}
|
||||
<div className="flex gap-1">
|
||||
{STEPS.map(step => (
|
||||
<button key={step.id} onClick={() => setWizardStep(step.id)}
|
||||
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
|
||||
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
|
||||
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
|
||||
}`}>
|
||||
{wizardStep > step.id ? '✓ ' : ''}{step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Provider */}
|
||||
{wizardStep === 1 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
|
||||
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
|
||||
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Oesterreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
<option value="OTHER">Anderes Land</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
|
||||
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: System */}
|
||||
{wizardStep === 2 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
|
||||
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
|
||||
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
|
||||
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 3: Classification */}
|
||||
{wizardStep === 3 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
|
||||
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
|
||||
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_classified">Noch nicht klassifiziert</option>
|
||||
<option value="minimal_risk">Minimal Risk</option>
|
||||
<option value="limited_risk">Limited Risk</option>
|
||||
<option value="high_risk">High Risk</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.risk_classification === 'high_risk' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
|
||||
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Bitte waehlen...</option>
|
||||
<option value="biometric">1. Biometrische Identifizierung</option>
|
||||
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
|
||||
<option value="education">3. Bildung und Berufsausbildung</option>
|
||||
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
|
||||
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
|
||||
<option value="law_enforcement">6. Strafverfolgung</option>
|
||||
<option value="migration">7. Migration und Grenzkontrolle</option>
|
||||
<option value="justice">8. Rechtspflege und Demokratie</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
|
||||
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="none">Kein GPAI</option>
|
||||
<option value="standard">GPAI (Standard)</option>
|
||||
<option value="systemic">GPAI mit systemischem Risiko</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 4: Conformity */}
|
||||
{wizardStep === 4 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
|
||||
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_required">Nicht erforderlich</option>
|
||||
<option value="internal">Interne Konformitaetsbewertung</option>
|
||||
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.conformity_assessment_type === 'third_party' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
|
||||
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border 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">Notified Body ID</label>
|
||||
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 5: Training Data */}
|
||||
{wizardStep === 5 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
|
||||
<p className="text-sm text-gray-500">Art. 10 KI-VO — Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
|
||||
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Beschreibe die verwendeten Datenquellen: - Oeffentliche Daten (z.B. Wikipedia, Common Crawl) - Lizenzierte Daten (z.B. Fachpublikationen) - Synthetische Daten - Unternehmensinterne Daten" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 6: Review */}
|
||||
{wizardStep === 6 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
|
||||
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
|
||||
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
|
||||
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
|
||||
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
|
||||
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
|
||||
</div>
|
||||
{form.intended_purpose && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="p-6 border-t flex justify-between">
|
||||
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
|
||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
|
||||
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
{wizardStep < 6 ? (
|
||||
<button onClick={() => setWizardStep(wizardStep + 1)}
|
||||
disabled={wizardStep === 2 && !form.system_name}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
404
admin-compliance/app/sdk/api-docs/page.tsx
Normal file
404
admin-compliance/app/sdk/api-docs/page.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { apiModules } from '@/lib/sdk/api-docs/endpoints'
|
||||
import type { HttpMethod, BackendService, ApiExposure } from '@/lib/sdk/api-docs/types'
|
||||
|
||||
const METHOD_COLORS: Record<HttpMethod, string> = {
|
||||
GET: 'bg-green-100 text-green-800',
|
||||
POST: 'bg-blue-100 text-blue-800',
|
||||
PUT: 'bg-yellow-100 text-yellow-800',
|
||||
DELETE: 'bg-red-100 text-red-800',
|
||||
PATCH: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
const EXPOSURE_CONFIG: Record<ApiExposure, { label: string; color: string; description: string }> = {
|
||||
public: { label: 'Oeffentlich', color: 'bg-green-100 text-green-800 border-green-200', description: 'Internet-exponiert' },
|
||||
partner: { label: 'Integration', color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'API-Key/OAuth' },
|
||||
internal: { label: 'Intern', color: 'bg-gray-100 text-gray-700 border-gray-200', description: 'Nur Admin' },
|
||||
admin: { label: 'Wartung', color: 'bg-orange-100 text-orange-800 border-orange-200', description: 'Nur Setup' },
|
||||
}
|
||||
|
||||
type ServiceFilter = 'all' | BackendService
|
||||
type ExposureFilter = 'all' | ApiExposure
|
||||
|
||||
function ExposureBadge({ exposure }: { exposure: ApiExposure }) {
|
||||
const config = EXPOSURE_CONFIG[exposure]
|
||||
return (
|
||||
<span className={`inline-block text-[10px] font-medium px-1.5 py-0.5 rounded border ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [serviceFilter, setServiceFilter] = useState<ServiceFilter>('all')
|
||||
const [methodFilter, setMethodFilter] = useState<HttpMethod | 'all'>('all')
|
||||
const [exposureFilter, setExposureFilter] = useState<ExposureFilter>('all')
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set())
|
||||
const moduleRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
return apiModules
|
||||
.filter((m) => serviceFilter === 'all' || m.service === serviceFilter)
|
||||
.filter((m) => {
|
||||
if (exposureFilter === 'all') return true
|
||||
if (m.exposure === exposureFilter) return true
|
||||
return m.endpoints.some((e) => (e.exposure || m.exposure) === exposureFilter)
|
||||
})
|
||||
.map((m) => {
|
||||
const eps = m.endpoints.filter((e) => {
|
||||
if (methodFilter !== 'all' && e.method !== methodFilter) return false
|
||||
if (exposureFilter !== 'all' && (e.exposure || m.exposure) !== exposureFilter) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
e.path.toLowerCase().includes(q) ||
|
||||
e.description.toLowerCase().includes(q) ||
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.id.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
return { ...m, endpoints: eps }
|
||||
})
|
||||
.filter((m) => m.endpoints.length > 0)
|
||||
}, [search, serviceFilter, methodFilter, exposureFilter])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = apiModules.reduce((s, m) => s + m.endpoints.length, 0)
|
||||
const python = apiModules.filter((m) => m.service === 'python').reduce((s, m) => s + m.endpoints.length, 0)
|
||||
const go = apiModules.filter((m) => m.service === 'go').reduce((s, m) => s + m.endpoints.length, 0)
|
||||
|
||||
const exposureCounts = { public: 0, partner: 0, internal: 0, admin: 0 }
|
||||
apiModules.forEach((m) => {
|
||||
m.endpoints.forEach((e) => {
|
||||
const exp = e.exposure || m.exposure
|
||||
exposureCounts[exp]++
|
||||
})
|
||||
})
|
||||
|
||||
return { total, python, go, modules: apiModules.length, exposure: exposureCounts }
|
||||
}, [])
|
||||
|
||||
const filteredTotal = filteredModules.reduce((s, m) => s + m.endpoints.length, 0)
|
||||
|
||||
const toggleModule = (id: string) => {
|
||||
setExpandedModules((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const expandAll = () => setExpandedModules(new Set(filteredModules.map((m) => m.id)))
|
||||
const collapseAll = () => setExpandedModules(new Set())
|
||||
|
||||
const scrollToModule = (id: string) => {
|
||||
setExpandedModules((prev) => new Set([...prev, id]))
|
||||
setTimeout(() => {
|
||||
moduleRefs.current[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-20">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">API-Referenz</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{stats.total} Endpoints in {stats.modules} Modulen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Alle aufklappen
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Alle zuklappen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exposure Stats */}
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-xs">
|
||||
<span className="text-gray-500">Exposure:</span>
|
||||
<span className="px-2 py-0.5 rounded bg-green-50 text-green-700 font-medium">
|
||||
{stats.exposure.public} oeffentlich
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-700 font-medium">
|
||||
{stats.exposure.partner} Integration
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">
|
||||
{stats.exposure.internal} intern
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-orange-50 text-orange-700 font-medium">
|
||||
{stats.exposure.admin} Wartung
|
||||
</span>
|
||||
<span className="text-gray-400 ml-1">
|
||||
({Math.round((stats.exposure.public + stats.exposure.partner) / stats.total * 100)}% exponiert)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search + Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Endpoint, Beschreibung oder Modul suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Filter */}
|
||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden">
|
||||
{([['all', 'Alle'], ['python', 'Python/FastAPI'], ['go', 'Go/Gin']] as const).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setServiceFilter(val)}
|
||||
className={`px-3 py-2 text-xs font-medium transition-colors ${
|
||||
serviceFilter === val
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Exposure Filter */}
|
||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden">
|
||||
{([
|
||||
['all', 'Alle'],
|
||||
['public', 'Oeffentlich'],
|
||||
['partner', 'Integration'],
|
||||
['internal', 'Intern'],
|
||||
['admin', 'Wartung'],
|
||||
] as const).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setExposureFilter(val)}
|
||||
className={`px-3 py-2 text-xs font-medium transition-colors ${
|
||||
exposureFilter === val
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Method Filter */}
|
||||
<div className="flex gap-1.5">
|
||||
{(['all', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMethodFilter(m)}
|
||||
className={`px-2.5 py-1.5 text-xs font-mono font-bold rounded-md transition-colors ${
|
||||
methodFilter === m
|
||||
? m === 'all'
|
||||
? 'bg-gray-900 text-white'
|
||||
: METHOD_COLORS[m] + ' ring-2 ring-offset-1 ring-gray-400'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{m === 'all' ? 'ALLE' : m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'Endpoints gesamt', value: stats.total, color: 'text-gray-900' },
|
||||
{ label: 'Python / FastAPI', value: stats.python, color: 'text-blue-700' },
|
||||
{ label: 'Go / Gin', value: stats.go, color: 'text-emerald-700' },
|
||||
{ label: 'Module', value: stats.modules, color: 'text-purple-700' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Module Index (Sidebar) */}
|
||||
<div className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 sticky top-[170px] max-h-[calc(100vh-210px)] overflow-y-auto">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
Modul-Index ({filteredModules.length})
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{filteredModules.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => scrollToModule(m.id)}
|
||||
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-gray-100 transition-colors group flex items-center justify-between gap-1"
|
||||
>
|
||||
<span className="truncate text-gray-700 group-hover:text-gray-900">
|
||||
{m.id}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<ExposureBadge exposure={m.exposure} />
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||
m.service === 'python' ? 'bg-blue-50 text-blue-600' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
{m.endpoints.length}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{search && (
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{filteredTotal} Treffer in {filteredModules.length} Modulen
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredModules.map((m) => {
|
||||
const isExpanded = expandedModules.has(m.id)
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(el) => { moduleRefs.current[m.id] = el }}
|
||||
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{/* Module Header */}
|
||||
<button
|
||||
onClick={() => toggleModule(m.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded ${
|
||||
m.service === 'python' ? 'bg-blue-50 text-blue-700' : 'bg-emerald-50 text-emerald-700'
|
||||
}`}>
|
||||
{m.service === 'python' ? 'PY' : 'GO'}
|
||||
</span>
|
||||
<ExposureBadge exposure={m.exposure} />
|
||||
<span className="text-sm font-medium text-gray-900 truncate">{m.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
<span className="text-xs text-gray-400 font-mono">{m.basePath}</span>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{m.endpoints.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Endpoints Table */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-xs text-gray-500">
|
||||
<th className="text-left px-4 py-2 w-20">Methode</th>
|
||||
<th className="text-left px-4 py-2">Pfad</th>
|
||||
<th className="text-left px-4 py-2">Beschreibung</th>
|
||||
<th className="text-left px-4 py-2 w-24">Exposure</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.endpoints.map((e, i) => {
|
||||
const endpointExposure = e.exposure || m.exposure
|
||||
const hasOverride = e.exposure && e.exposure !== m.exposure
|
||||
return (
|
||||
<tr
|
||||
key={`${e.method}-${e.path}-${i}`}
|
||||
className="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-block text-[11px] font-mono font-bold px-2 py-0.5 rounded ${METHOD_COLORS[e.method]}`}>
|
||||
{e.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-800">
|
||||
{e.path}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-gray-600">
|
||||
{e.description}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{hasOverride ? (
|
||||
<ExposureBadge exposure={endpointExposure} />
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredModules.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p className="text-sm">Keine Endpoints gefunden</p>
|
||||
<p className="text-xs mt-1">Suchbegriff oder Filter anpassen</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -160,6 +160,13 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
'security_backlog', 'quality_entries',
|
||||
'notfallplan_incidents', 'notfallplan_templates',
|
||||
'data_processing_agreement',
|
||||
'vendor_vendors', 'vendor_contracts', 'vendor_findings',
|
||||
'vendor_control_instances', 'compliance_templates',
|
||||
'compliance_isms_scope', 'compliance_isms_context', 'compliance_isms_policy',
|
||||
'compliance_security_objectives', 'compliance_soa',
|
||||
'compliance_audit_findings', 'compliance_corrective_actions',
|
||||
'compliance_management_reviews', 'compliance_internal_audits',
|
||||
'compliance_audit_trail', 'compliance_isms_readiness_checks',
|
||||
],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [
|
||||
@@ -173,6 +180,20 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
'CRUD /api/compliance/vvt',
|
||||
'CRUD /api/compliance/loeschfristen',
|
||||
'CRUD /api/compliance/obligations',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/vendors',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/contracts',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/findings',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/control-instances',
|
||||
'CRUD /api/isms/scope',
|
||||
'CRUD /api/isms/policies',
|
||||
'CRUD /api/isms/objectives',
|
||||
'CRUD /api/isms/soa',
|
||||
'CRUD /api/isms/findings',
|
||||
'CRUD /api/isms/capa',
|
||||
'CRUD /api/isms/management-reviews',
|
||||
'CRUD /api/isms/internal-audits',
|
||||
'GET /api/isms/overview',
|
||||
'POST /api/isms/readiness-check',
|
||||
'CRUD /api/compliance/legal-documents',
|
||||
'CRUD /api/compliance/legal-templates',
|
||||
],
|
||||
@@ -252,12 +273,12 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
name: 'PostgreSQL',
|
||||
nameShort: 'PostgreSQL',
|
||||
layer: 'infrastructure',
|
||||
tech: 'PostgreSQL 16',
|
||||
port: 5432,
|
||||
tech: 'PostgreSQL 17',
|
||||
port: 54321,
|
||||
url: null,
|
||||
container: 'bp-core-postgres',
|
||||
description: 'Zentrale Datenbank. Schemas: compliance, core, public. Shared mit breakpilot-core.',
|
||||
descriptionLong: 'PostgreSQL ist die gemeinsame Datenbank fuer das gesamte BreakPilot-Oekosystem. Das compliance-Schema enthaelt alle Compliance-spezifischen Tabellen, waehrend core und public von breakpilot-core bereitgestellt werden. Der search_path ist auf compliance,core,public konfiguriert, sodass Services transparent auf Schema-uebergreifende Daten zugreifen koennen.',
|
||||
container: '46.225.100.82:54321 (extern)',
|
||||
description: 'Externe PostgreSQL-Instanz (Hetzner/meghshakka). Schemas: compliance (51 Tabellen) + public (33 compliance_*-Tabellen). Migriert 2026-03-06.',
|
||||
descriptionLong: 'Die Compliance-Daten liegen seit 2026-03-06 auf einer dedizierten externen PostgreSQL 17-Instanz bei Hetzner (meghshakka, 46.225.100.82:54321). Das compliance-Schema umfasst 51 Tabellen fuer Risiken, Kontrollen, VVT, DSFA, Einwilligungen, Loeschfristen u.v.m. Weitere 33 compliance_*-Tabellen befinden sich im public-Schema (Tenants, Namespaces, LLM-Policies, Legal Documents etc.). Die Verbindung ist TLS-verschluesselt (sslmode=require). Die lokale bp-core-postgres auf dem Mac Mini wird exklusiv von breakpilot-lehrer (NIBIS-Daten) genutzt.',
|
||||
dbTables: [],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [],
|
||||
@@ -284,12 +305,12 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
name: 'Qdrant',
|
||||
nameShort: 'Qdrant',
|
||||
layer: 'infrastructure',
|
||||
tech: 'Qdrant',
|
||||
port: 6333,
|
||||
url: null,
|
||||
container: 'bp-core-qdrant',
|
||||
description: 'Vektor-Datenbank fuer RAG-Compliance-Suche. Collections: DSGVO, AI Act, BDSG, TTDSG.',
|
||||
descriptionLong: 'Qdrant speichert Vektorembeddings von Rechtstexten und Compliance-Dokumenten in thematischen Collections (DSGVO, AI Act, BDSG, TTDSG, Templates). Der AI Compliance SDK nutzt diese fuer semantische Suchen — Nutzer koennen so in natuerlicher Sprache nach relevanten Gesetzespassagen und Compliance-Anforderungen suchen, ohne exakte Suchbegriffe kennen zu muessen.',
|
||||
tech: 'Qdrant Cloud',
|
||||
port: 443,
|
||||
url: 'https://qdrant-dev.breakpilot.ai',
|
||||
container: 'qdrant-dev.breakpilot.ai (extern)',
|
||||
description: 'Externe Vektor-Datenbank (Hetzner/meghshakka). RAG-Compliance-Suche. Collections: DSGVO, AI Act, BDSG, TTDSG. Migriert 2026-03-06.',
|
||||
descriptionLong: 'Qdrant laeuft seit 2026-03-06 als externe Instanz auf qdrant-dev.breakpilot.ai (Hetzner/meghshakka). Es speichert Vektorembeddings von Rechtstexten und Compliance-Dokumenten in thematischen Collections. Der AI Compliance SDK verbindet sich per HTTPS mit API-Key-Authentifizierung. Durch die externe Instanz skaliert der Vektorspeicher unabhaengig vom Mac Mini.',
|
||||
dbTables: [],
|
||||
ragCollections: [
|
||||
'bp_dsgvo', 'bp_ai_act', 'bp_bdsg', 'bp_ttdsg',
|
||||
@@ -300,15 +321,15 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
},
|
||||
{
|
||||
id: 'minio',
|
||||
name: 'MinIO',
|
||||
nameShort: 'MinIO',
|
||||
name: 'Hetzner Object Storage',
|
||||
nameShort: 'Object Storage',
|
||||
layer: 'infrastructure',
|
||||
tech: 'MinIO',
|
||||
port: 9000,
|
||||
url: null,
|
||||
container: 'bp-core-minio',
|
||||
description: 'Object Storage fuer TTS-Audio, generierte Videos und Dokument-Uploads.',
|
||||
descriptionLong: 'MinIO stellt S3-kompatiblen Object Storage bereit und wird von breakpilot-core verwaltet. Der Compliance-Stack nutzt es primaer fuer generierte Schulungsvideos und Audio-Dateien aus dem TTS-Service. Durch die S3-API-Kompatibilitaet koennen Standard-AWS-SDKs fuer den Zugriff verwendet werden.',
|
||||
tech: 'S3-kompatibel',
|
||||
port: 443,
|
||||
url: 'https://nbg1.your-objectstorage.com',
|
||||
container: 'nbg1.your-objectstorage.com (extern)',
|
||||
description: 'Externer S3-kompatibler Object Storage (Hetzner Nuernberg). TTS-Audio, generierte Videos. Migriert 2026-03-06.',
|
||||
descriptionLong: 'Der Object Storage laeuft seit 2026-03-06 auf dem Hetzner-Standort Nuernberg (nbg1.your-objectstorage.com) und ist vollstaendig S3-kompatibel. Der Compliance TTS-Service speichert dort generierte Schulungsvideos und Audio-Dateien. Die Verbindung ist TLS-verschluesselt (MINIO_SECURE=true). Die lokale bp-core-minio-Instanz auf dem Mac Mini wird nicht mehr vom Compliance-Stack genutzt.',
|
||||
dbTables: [],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [],
|
||||
|
||||
468
admin-compliance/app/sdk/assertions/page.tsx
Normal file
468
admin-compliance/app/sdk/assertions/page.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Assertion {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
sentence_text: string
|
||||
sentence_index: number
|
||||
assertion_type: string // 'assertion' | 'fact' | 'rationale'
|
||||
evidence_ids: string[]
|
||||
confidence: number
|
||||
normative_tier: string | null // 'pflicht' | 'empfehlung' | 'kann'
|
||||
verified_by: string | null
|
||||
verified_at: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface AssertionSummary {
|
||||
total_assertions: number
|
||||
total_facts: number
|
||||
total_rationale: number
|
||||
unverified_count: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const TIER_COLORS: Record<string, string> = {
|
||||
pflicht: 'bg-red-100 text-red-700',
|
||||
empfehlung: 'bg-yellow-100 text-yellow-700',
|
||||
kann: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
pflicht: 'Pflicht',
|
||||
empfehlung: 'Empfehlung',
|
||||
kann: 'Kann',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
assertion: 'bg-orange-100 text-orange-700',
|
||||
fact: 'bg-green-100 text-green-700',
|
||||
rationale: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
assertion: 'Behauptung',
|
||||
fact: 'Fakt',
|
||||
rationale: 'Begruendung',
|
||||
}
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance'
|
||||
|
||||
type TabKey = 'overview' | 'list' | 'extract'
|
||||
|
||||
// =============================================================================
|
||||
// ASSERTION CARD
|
||||
// =============================================================================
|
||||
|
||||
function AssertionCard({
|
||||
assertion,
|
||||
onVerify,
|
||||
}: {
|
||||
assertion: Assertion
|
||||
onVerify: (id: string) => void
|
||||
}) {
|
||||
const tierColor = assertion.normative_tier ? TIER_COLORS[assertion.normative_tier] || 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
|
||||
const tierLabel = assertion.normative_tier ? TIER_LABELS[assertion.normative_tier] || assertion.normative_tier : '—'
|
||||
const typeColor = TYPE_COLORS[assertion.assertion_type] || 'bg-gray-100 text-gray-600'
|
||||
const typeLabel = TYPE_LABELS[assertion.assertion_type] || assertion.assertion_type
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded font-medium ${tierColor}`}>
|
||||
{tierLabel}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${typeColor}`}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
{assertion.entity_type && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">
|
||||
{assertion.entity_type}: {assertion.entity_id?.slice(0, 8) || '—'}
|
||||
</span>
|
||||
)}
|
||||
{assertion.confidence > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Konfidenz: {(assertion.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-900 leading-relaxed">
|
||||
“{assertion.sentence_text}”
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400">
|
||||
{assertion.verified_by && (
|
||||
<span className="text-green-600">
|
||||
Verifiziert von {assertion.verified_by} am {assertion.verified_at ? new Date(assertion.verified_at).toLocaleDateString('de-DE') : '—'}
|
||||
</span>
|
||||
)}
|
||||
{assertion.evidence_ids.length > 0 && (
|
||||
<span>
|
||||
{assertion.evidence_ids.length} Evidence verknuepft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{assertion.assertion_type !== 'fact' && (
|
||||
<button
|
||||
onClick={() => onVerify(assertion.id)}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Als Fakt pruefen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AssertionsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview')
|
||||
const [summary, setSummary] = useState<AssertionSummary | null>(null)
|
||||
const [assertions, setAssertions] = useState<Assertion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [filterEntityType, setFilterEntityType] = useState('')
|
||||
const [filterAssertionType, setFilterAssertionType] = useState('')
|
||||
|
||||
// Extract tab
|
||||
const [extractText, setExtractText] = useState('')
|
||||
const [extractEntityType, setExtractEntityType] = useState('control')
|
||||
const [extractEntityId, setExtractEntityId] = useState('')
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [extractedAssertions, setExtractedAssertions] = useState<Assertion[]>([])
|
||||
|
||||
// Verify dialog
|
||||
const [verifyingId, setVerifyingId] = useState<string | null>(null)
|
||||
const [verifyEmail, setVerifyEmail] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'list') loadAssertions()
|
||||
}, [activeTab, filterEntityType, filterAssertionType]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/summary`)
|
||||
if (res.ok) setSummary(await res.json())
|
||||
} catch { /* silent */ }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const loadAssertions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterEntityType) params.set('entity_type', filterEntityType)
|
||||
if (filterAssertionType) params.set('assertion_type', filterAssertionType)
|
||||
params.set('limit', '200')
|
||||
|
||||
const res = await fetch(`${API_BASE}/assertions?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAssertions(data.assertions || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Assertions konnten nicht geladen werden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!extractText.trim()) { setError('Bitte Text eingeben'); return }
|
||||
setExtracting(true)
|
||||
setError(null)
|
||||
setExtractedAssertions([])
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: extractText,
|
||||
entity_type: extractEntityType || 'control',
|
||||
entity_id: extractEntityId || undefined,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Extraktion fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
const data = await res.json()
|
||||
setExtractedAssertions(data.assertions || [])
|
||||
// Refresh summary
|
||||
loadSummary()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraktion fehlgeschlagen')
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async (assertionId: string) => {
|
||||
setVerifyingId(assertionId)
|
||||
}
|
||||
|
||||
const submitVerify = async () => {
|
||||
if (!verifyingId || !verifyEmail.trim()) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/${verifyingId}/verify?verified_by=${encodeURIComponent(verifyEmail)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
setVerifyingId(null)
|
||||
setVerifyEmail('')
|
||||
loadAssertions()
|
||||
loadSummary()
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ detail: 'Verifizierung fehlgeschlagen' }))
|
||||
setError(typeof err.detail === 'string' ? err.detail : 'Verifizierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'overview', label: 'Uebersicht' },
|
||||
{ key: 'list', label: 'Assertion-Liste' },
|
||||
{ key: 'extract', label: 'Extraktion' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Assertions</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Behauptungen vs. Fakten in Compliance-Texten trennen und verifizieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-sm border">
|
||||
<div className="flex border-b">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-purple-600 border-b-2 border-purple-600'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Uebersicht */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : summary ? (
|
||||
<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 Assertions</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{summary.total_assertions}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Verifizierte Fakten</div>
|
||||
<div className="text-3xl font-bold text-green-600">{summary.total_facts}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
||||
<div className="text-sm text-purple-600">Begruendungen</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{summary.total_rationale}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Unverifizizt</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{summary.unverified_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Keine Assertions vorhanden. Nutzen Sie die Extraktion, um Behauptungen aus Texten zu identifizieren.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Assertion-Liste */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Entity-Typ</label>
|
||||
<select value={filterEntityType} onChange={e => setFilterEntityType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Alle</option>
|
||||
<option value="control">Control</option>
|
||||
<option value="evidence">Evidence</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Assertion-Typ</label>
|
||||
<select value={filterAssertionType} onChange={e => setFilterAssertionType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Alle</option>
|
||||
<option value="assertion">Behauptung</option>
|
||||
<option value="fact">Fakt</option>
|
||||
<option value="rationale">Begruendung</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : assertions.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Keine Assertions gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-500">{assertions.length} Assertions</p>
|
||||
{assertions.map(a => (
|
||||
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Extraktion */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'extract' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Assertions aus Text extrahieren</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Geben Sie einen Compliance-Text ein. Das System identifiziert automatisch Behauptungen, Fakten und Begruendungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-Typ</label>
|
||||
<select value={extractEntityType} onChange={e => setExtractEntityType(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="control">Control</option>
|
||||
<option value="evidence">Evidence</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
<option value="policy">Policy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-ID (optional)</label>
|
||||
<input type="text" value={extractEntityId} onChange={e => setExtractEntityId(e.target.value)}
|
||||
placeholder="z.B. GOV-001 oder UUID"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
|
||||
<textarea
|
||||
value={extractText}
|
||||
onChange={e => setExtractText(e.target.value)}
|
||||
placeholder="Die Organisation muss ein ISMS gemaess ISO 27001 implementieren. Es sollte regelmaessig ein internes Audit durchgefuehrt werden. Optional kann ein externer Auditor hinzugezogen werden."
|
||||
rows={6}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
disabled={extracting || !extractText.trim()}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition-colors ${
|
||||
extracting || !extractText.trim()
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{extracting ? 'Extrahiere...' : 'Extrahieren'}
|
||||
</button>
|
||||
|
||||
{/* Extracted results */}
|
||||
{extractedAssertions.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">{extractedAssertions.length} Assertions extrahiert:</h4>
|
||||
<div className="space-y-3">
|
||||
{extractedAssertions.map(a => (
|
||||
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verify Dialog */}
|
||||
{verifyingId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setVerifyingId(null)}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-4">Als Fakt verifizieren</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verifiziert von (E-Mail)</label>
|
||||
<input type="email" value={verifyEmail} onChange={e => setVerifyEmail(e.target.value)}
|
||||
placeholder="auditor@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setVerifyingId(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={submitVerify} disabled={!verifyEmail.trim()}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
|
||||
Verifizieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Atom, Search, ChevronRight, ChevronLeft, Filter,
|
||||
BarChart3, ChevronsLeft, ChevronsRight, ArrowUpDown,
|
||||
Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, CategoryBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge, ObligationTypeBadge, RegulationCountBadge,
|
||||
CATEGORY_OPTIONS,
|
||||
} from '../control-library/components/helpers'
|
||||
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface AtomicStats {
|
||||
total_active: number
|
||||
total_duplicate: number
|
||||
by_domain: Array<{ domain: string; count: number }>
|
||||
by_regulation: Array<{ regulation: string; count: number }>
|
||||
avg_regulation_coverage: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ATOMIC CONTROLS PAGE
|
||||
// =============================================================================
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function AtomicControlsPage() {
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [stats, setStats] = useState<AtomicStats | null>(null)
|
||||
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest'>('id')
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// Mode
|
||||
const [mode, setMode] = useState<'list' | 'detail'>('list')
|
||||
|
||||
// Debounce search
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [searchQuery])
|
||||
|
||||
// Build query params
|
||||
const buildParams = useCallback((extra?: Record<string, string>) => {
|
||||
const p = new URLSearchParams()
|
||||
p.set('control_type', 'atomic')
|
||||
// Exclude duplicates — show only active masters
|
||||
if (!extra?.release_state) {
|
||||
// Don't filter by state for count queries that already have it
|
||||
}
|
||||
if (severityFilter) p.set('severity', severityFilter)
|
||||
if (domainFilter) p.set('domain', domainFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||
return p.toString()
|
||||
}, [severityFilter, domainFilter, categoryFilter, debouncedSearch])
|
||||
|
||||
// Load stats
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=atomic-stats`)
|
||||
if (res.ok) setStats(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Load controls page
|
||||
const loadControls = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const sortField = sortBy === 'id' ? 'control_id' : 'created_at'
|
||||
const sortOrder = sortBy === 'newest' ? 'desc' : 'asc'
|
||||
const offset = (currentPage - 1) * PAGE_SIZE
|
||||
|
||||
const qs = buildParams({
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(offset),
|
||||
})
|
||||
|
||||
const countQs = buildParams()
|
||||
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
||||
])
|
||||
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
if (countRes.ok) {
|
||||
const data = await countRes.json()
|
||||
setTotalCount(data.total || 0)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [buildParams, sortBy, currentPage])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, categoryFilter, debouncedSearch, sortBy])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
// Loading
|
||||
if (loading && controls.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-violet-600 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DETAIL MODE
|
||||
if (mode === 'detail' && selectedControl) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ControlDetail
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null) }}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onReview={() => {}}
|
||||
onNavigateToControl={async (controlId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSelectedControl(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LIST VIEW
|
||||
// =========================================================================
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Atom className="w-6 h-6 text-violet-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Atomare Controls</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
Deduplizierte atomare Controls mit Herkunftsnachweis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { loadControls(); loadStats() }}
|
||||
className="p-2 text-gray-400 hover:text-violet-600"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-violet-700">{stats.total_active.toLocaleString('de-DE')}</div>
|
||||
<div className="text-xs text-violet-500">Master Controls</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.total_duplicate.toLocaleString('de-DE')}</div>
|
||||
<div className="text-xs text-gray-500">Duplikate (entfernt)</div>
|
||||
</div>
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-indigo-700">{stats.by_regulation.length}</div>
|
||||
<div className="text-xs text-indigo-500">Regulierungen</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-emerald-700">{stats.avg_regulation_coverage}</div>
|
||||
<div className="text-xs text-emerald-500">Avg. Regulierungen / Control</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Atomare Controls durchsuchen (ID, Titel, Objective)..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Domain</option>
|
||||
{stats?.by_domain.map(d => (
|
||||
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Kategorie</option>
|
||||
{CATEGORY_OPTIONS.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest')}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="id">Sortierung: ID</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Aelteste zuerst</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{stats && totalCount !== stats.total_active && ` (von ${stats.total_active.toLocaleString('de-DE')} Master Controls)`}
|
||||
{loading && <span className="ml-2 text-violet-500">Lade...</span>}
|
||||
</span>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{controls.map((ctrl) => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-violet-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{ctrl.source_citation?.source && (
|
||||
<>
|
||||
<span className="text-xs text-blue-600">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
</>
|
||||
)}
|
||||
{ctrl.parent_control_id && (
|
||||
<>
|
||||
<span className="text-xs text-violet-500">via {ctrl.parent_control_id}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
</>
|
||||
)}
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
||||
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-violet-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{controls.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
Keine atomaren Controls gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||
acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === 'dots' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p as number)}
|
||||
className={`w-8 h-8 text-sm rounded-lg ${
|
||||
currentPage === p
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'text-gray-600 hover:bg-violet-50 hover:text-violet-600'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
561
admin-compliance/app/sdk/audit-llm/page.tsx
Normal file
561
admin-compliance/app/sdk/audit-llm/page.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface LLMLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
namespace: string
|
||||
model: string
|
||||
provider: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
pii_detected: boolean
|
||||
pii_categories: string[]
|
||||
redacted: boolean
|
||||
duration_ms: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_prompt_tokens: number
|
||||
total_completion_tokens: number
|
||||
models_used: Record<string, number>
|
||||
providers_used: Record<string, number>
|
||||
avg_duration_ms: number
|
||||
pii_detection_rate: number
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
interface ComplianceReport {
|
||||
total_requests: number
|
||||
pii_incidents: number
|
||||
pii_rate: number
|
||||
redaction_rate: number
|
||||
policy_violations: number
|
||||
top_pii_categories: Record<string, number>
|
||||
namespace_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
user_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
type TabId = 'llm-log' | 'usage' | 'compliance'
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = '/api/sdk/v1/audit-llm'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function getDateRange(period: string): { from: string; to: string } {
|
||||
const now = new Date()
|
||||
const to = now.toISOString().slice(0, 10)
|
||||
const from = new Date(now)
|
||||
switch (period) {
|
||||
case '7d': from.setDate(from.getDate() - 7); break
|
||||
case '30d': from.setDate(from.getDate() - 30); break
|
||||
case '90d': from.setDate(from.getDate() - 90); break
|
||||
default: from.setDate(from.getDate() - 7)
|
||||
}
|
||||
return { from: from.toISOString().slice(0, 10), to }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AuditLLMPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('llm-log')
|
||||
const [period, setPeriod] = useState('7d')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// LLM Log state
|
||||
const [logEntries, setLogEntries] = useState<LLMLogEntry[]>([])
|
||||
const [logFilter, setLogFilter] = useState({ model: '', pii: '' })
|
||||
|
||||
// Usage state
|
||||
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||
|
||||
// Compliance state
|
||||
const [complianceReport, setComplianceReport] = useState<ComplianceReport | null>(null)
|
||||
|
||||
// ─── Load Data ───────────────────────────────────────────────────────
|
||||
|
||||
const loadLLMLog = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const params = new URLSearchParams({ from, to, limit: '100' })
|
||||
if (logFilter.model) params.set('model', logFilter.model)
|
||||
if (logFilter.pii === 'true') params.set('pii_detected', 'true')
|
||||
if (logFilter.pii === 'false') params.set('pii_detected', 'false')
|
||||
|
||||
const res = await fetch(`${API_BASE}/llm?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setLogEntries(Array.isArray(data) ? data : data.entries || data.logs || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period, logFilter])
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/usage?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setUsageStats(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const loadCompliance = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/compliance-report?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setComplianceReport(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'llm-log') loadLLMLog()
|
||||
else if (activeTab === 'usage') loadUsage()
|
||||
else if (activeTab === 'compliance') loadCompliance()
|
||||
}, [activeTab, loadLLMLog, loadUsage, loadCompliance])
|
||||
|
||||
// ─── Export ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleExport = async (type: 'llm' | 'general' | 'compliance', format: 'json' | 'csv') => {
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/export/${type}?from=${from}&to=${to}&format=${format}`)
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${type}-${from}-${to}.${format}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tabs ────────────────────────────────────────────────────────────
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'llm-log', label: 'LLM-Log' },
|
||||
{ id: 'usage', label: 'Nutzung' },
|
||||
{ id: 'compliance', label: 'Compliance' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">LLM Audit Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">Monitoring und Compliance-Analyse der LLM-Operationen</p>
|
||||
</div>
|
||||
|
||||
{/* Period + Tabs */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={period}
|
||||
onChange={e => setPeriod(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="7d">Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
<option value="90d">Letzte 90 Tage</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTab === 'llm-log') handleExport('llm', 'csv')
|
||||
else if (activeTab === 'compliance') handleExport('compliance', 'json')
|
||||
else handleExport('general', 'csv')
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-purple-200 border-t-purple-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── LLM-Log Tab ── */}
|
||||
{!loading && activeTab === 'llm-log' && (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Model filtern..."
|
||||
value={logFilter.model}
|
||||
onChange={e => setLogFilter(f => ({ ...f, model: e.target.value }))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
|
||||
/>
|
||||
<select
|
||||
value={logFilter.pii}
|
||||
onChange={e => setLogFilter(f => ({ ...f, pii: e.target.value }))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle PII-Status</option>
|
||||
<option value="true">PII erkannt</option>
|
||||
<option value="false">Kein PII</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Zeitpunkt</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Model</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Tokens</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">PII</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Dauer</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logEntries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
Keine Log-Eintraege im gewaehlten Zeitraum
|
||||
</td>
|
||||
</tr>
|
||||
) : logEntries.map(entry => (
|
||||
<tr key={entry.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(entry.created_at)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{entry.user_id?.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
|
||||
{entry.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{formatNumber(entry.total_tokens)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{entry.pii_detected ? (
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-medium">
|
||||
{entry.redacted ? 'Redacted' : 'Erkannt'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatDuration(entry.duration_ms)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
entry.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">{logEntries.length} Eintraege</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Nutzung Tab ── */}
|
||||
{!loading && activeTab === 'usage' && usageStats && (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests gesamt" value={formatNumber(usageStats.total_requests)} />
|
||||
<StatCard label="Tokens gesamt" value={formatNumber(usageStats.total_tokens)} />
|
||||
<StatCard label="Avg. Dauer" value={formatDuration(usageStats.avg_duration_ms)} />
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(usageStats.pii_detection_rate * 100).toFixed(1)}%`}
|
||||
highlight={usageStats.pii_detection_rate > 0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Model-Nutzung</h3>
|
||||
{Object.entries(usageStats.models_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.models_used).sort((a, b) => b[1] - a[1]).map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">{model}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${(count / usageStats.total_requests) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-gray-500 w-16 text-right">{formatNumber(count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Provider-Verteilung</h3>
|
||||
{Object.entries(usageStats.providers_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.providers_used).sort((a, b) => b[1] - a[1]).map(([provider, count]) => (
|
||||
<div key={provider} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 capitalize">{provider}</span>
|
||||
<span className="text-sm font-mono text-gray-500">{formatNumber(count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Details */}
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Token-Aufschluesselung</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_prompt_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Prompt Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_completion_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Completion Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{formatNumber(usageStats.total_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Compliance Tab ── */}
|
||||
{!loading && activeTab === 'compliance' && complianceReport && (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests" value={formatNumber(complianceReport.total_requests)} />
|
||||
<StatCard
|
||||
label="PII-Vorfaelle"
|
||||
value={formatNumber(complianceReport.pii_incidents)}
|
||||
highlight={complianceReport.pii_incidents > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(complianceReport.pii_rate * 100).toFixed(1)}%`}
|
||||
highlight={complianceReport.pii_rate > 0.05}
|
||||
/>
|
||||
<StatCard label="Redaction-Rate" value={`${(complianceReport.redaction_rate * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
|
||||
{complianceReport.policy_violations > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-red-700 font-semibold">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{complianceReport.policy_violations} Policy-Verletzungen im Zeitraum
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* PII Categories */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">PII-Kategorien</h3>
|
||||
{Object.entries(complianceReport.top_pii_categories || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine PII erkannt</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(complianceReport.top_pii_categories).sort((a, b) => b[1] - a[1]).map(([cat, count]) => (
|
||||
<div key={cat} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-gray-700">{cat}</span>
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-mono">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace Breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Namespace-Analyse</h3>
|
||||
{Object.entries(complianceReport.namespace_breakdown || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Namespace-Daten</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">Namespace</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.namespace_breakdown).map(([ns, data]) => (
|
||||
<tr key={ns} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{ns}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Breakdown */}
|
||||
{Object.entries(complianceReport.user_breakdown || {}).length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Top-Nutzer</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">User-ID</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII-Vorfaelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.user_breakdown)
|
||||
.sort((a, b) => b[1].requests - a[1].requests)
|
||||
.slice(0, 10)
|
||||
.map(([userId, data]) => (
|
||||
<tr key={userId} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{userId}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state for usage/compliance when no data */}
|
||||
{!loading && activeTab === 'usage' && !usageStats && !error && (
|
||||
<div className="text-center py-12 text-gray-400">Keine Nutzungsdaten verfuegbar</div>
|
||||
)}
|
||||
{!loading && activeTab === 'compliance' && !complianceReport && !error && (
|
||||
<div className="text-center py-12 text-gray-400">Kein Compliance-Report verfuegbar</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${highlight ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="text-sm text-gray-500">{label}</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${highlight ? 'text-red-700' : 'text-gray-900'}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
345
admin-compliance/app/sdk/change-requests/page.tsx
Normal file
345
admin-compliance/app/sdk/change-requests/page.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface ChangeRequest {
|
||||
id: string
|
||||
triggerType: string
|
||||
targetDocumentType: string
|
||||
targetDocumentId: string | null
|
||||
targetSection: string | null
|
||||
proposalTitle: string
|
||||
proposalBody: string | null
|
||||
proposedChanges: Record<string, unknown>
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'edited_and_accepted'
|
||||
priority: 'low' | 'normal' | 'high' | 'critical'
|
||||
decidedBy: string | null
|
||||
decidedAt: string | null
|
||||
rejectionReason: string | null
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_pending: number
|
||||
critical_count: number
|
||||
total_accepted: number
|
||||
total_rejected: number
|
||||
by_document_type: Record<string, number>
|
||||
}
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance/change-requests'
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
dsfa: 'DSFA',
|
||||
vvt: 'VVT',
|
||||
tom: 'TOM',
|
||||
loeschfristen: 'Löschfristen',
|
||||
obligation: 'Pflichten',
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
normal: 'bg-blue-100 text-blue-800',
|
||||
low: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
accepted: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
edited_and_accepted: 'bg-emerald-100 text-emerald-800',
|
||||
}
|
||||
|
||||
function snakeToCamel(obj: Record<string, unknown>): ChangeRequest {
|
||||
return {
|
||||
id: obj.id as string,
|
||||
triggerType: obj.trigger_type as string,
|
||||
targetDocumentType: obj.target_document_type as string,
|
||||
targetDocumentId: obj.target_document_id as string | null,
|
||||
targetSection: obj.target_section as string | null,
|
||||
proposalTitle: obj.proposal_title as string,
|
||||
proposalBody: obj.proposal_body as string | null,
|
||||
proposedChanges: (obj.proposed_changes || {}) as Record<string, unknown>,
|
||||
status: obj.status as ChangeRequest['status'],
|
||||
priority: obj.priority as ChangeRequest['priority'],
|
||||
decidedBy: obj.decided_by as string | null,
|
||||
decidedAt: obj.decided_at as string | null,
|
||||
rejectionReason: obj.rejection_reason as string | null,
|
||||
createdBy: obj.created_by as string,
|
||||
createdAt: obj.created_at as string,
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChangeRequestsPage() {
|
||||
const [requests, setRequests] = useState<ChangeRequest[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionModal, setActionModal] = useState<{ type: 'accept' | 'reject' | 'edit'; cr: ChangeRequest } | null>(null)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [editBody, setEditBody] = useState('')
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const statsRes = await fetch(`${API_BASE}/stats`)
|
||||
if (statsRes.ok) setStats(await statsRes.json())
|
||||
|
||||
let url = `${API_BASE}?status=${statusFilter}`
|
||||
if (filter !== 'all') url += `&target_document_type=${filter}`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRequests((Array.isArray(data) ? data : []).map(snakeToCamel))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load change requests:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filter, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const interval = setInterval(loadData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadData])
|
||||
|
||||
const handleAccept = async (cr: ChangeRequest) => {
|
||||
const res = await fetch(`${API_BASE}/${cr.id}/accept`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
setActionModal(null)
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (cr: ChangeRequest) => {
|
||||
const res = await fetch(`${API_BASE}/${cr.id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rejection_reason: rejectReason }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setActionModal(null)
|
||||
setRejectReason('')
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (cr: ChangeRequest) => {
|
||||
const res = await fetch(`${API_BASE}/${cr.id}/edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ proposal_body: editBody }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setActionModal(null)
|
||||
setEditBody('')
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Änderungsanfrage wirklich löschen?')) return
|
||||
const res = await fetch(`${API_BASE}/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) loadData()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Änderungsanfragen</h1>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-yellow-700">{stats.total_pending}</div>
|
||||
<div className="text-sm text-yellow-600">Offen</div>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-red-700">{stats.critical_count}</div>
|
||||
<div className="text-sm text-red-600">Kritisch</div>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-green-700">{stats.total_accepted}</div>
|
||||
<div className="text-sm text-green-600">Angenommen</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-gray-700">{stats.total_rejected}</div>
|
||||
<div className="text-sm text-gray-600">Abgelehnt</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{['pending', 'accepted', 'rejected'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
statusFilter === s ? 'bg-white shadow text-purple-700' : 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{s === 'pending' ? 'Offen' : s === 'accepted' ? 'Angenommen' : 'Abgelehnt'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{['all', 'dsfa', 'vvt', 'tom', 'loeschfristen', 'obligation'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setFilter(t)}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
filter === t ? 'bg-white shadow text-purple-700' : 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{t === 'all' ? 'Alle' : DOC_TYPE_LABELS[t] || t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Keine Änderungsanfragen {statusFilter === 'pending' ? 'offen' : 'gefunden'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{requests.map(cr => (
|
||||
<div key={cr.id} className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[cr.priority]}`}>
|
||||
{cr.priority}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[cr.status]}`}>
|
||||
{cr.status}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
{DOC_TYPE_LABELS[cr.targetDocumentType] || cr.targetDocumentType}
|
||||
</span>
|
||||
{cr.triggerType !== 'manual' && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600">
|
||||
{cr.triggerType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900">{cr.proposalTitle}</h3>
|
||||
{cr.proposalBody && (
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{cr.proposalBody}</p>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{new Date(cr.createdAt).toLocaleDateString('de-DE')} — {cr.createdBy}
|
||||
{cr.rejectionReason && (
|
||||
<span className="text-red-500 ml-2">Grund: {cr.rejectionReason}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cr.status === 'pending' && (
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => { setActionModal({ type: 'accept', cr }) }}
|
||||
className="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
Annehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditBody(cr.proposalBody || ''); setActionModal({ type: 'edit', cr }) }}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRejectReason(''); setActionModal({ type: 'reject', cr }) }}
|
||||
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cr.id)}
|
||||
className="px-3 py-1 text-red-600 rounded text-sm hover:bg-red-50"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Modal */}
|
||||
{actionModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setActionModal(null)}>
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||
{actionModal.type === 'accept' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold mb-4">Änderung annehmen?</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{actionModal.cr.proposalTitle}</p>
|
||||
{Object.keys(actionModal.cr.proposedChanges).length > 0 && (
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs mb-4 max-h-48 overflow-auto">
|
||||
{JSON.stringify(actionModal.cr.proposedChanges, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
||||
<button onClick={() => handleAccept(actionModal.cr)} className="px-4 py-2 bg-green-600 text-white rounded-lg">Annehmen</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{actionModal.type === 'reject' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold mb-4">Änderung ablehnen</h3>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={e => setRejectReason(e.target.value)}
|
||||
placeholder="Begründung..."
|
||||
className="w-full border rounded-lg p-3 h-24 mb-4"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
||||
<button
|
||||
onClick={() => handleReject(actionModal.cr)}
|
||||
disabled={!rejectReason.trim()}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{actionModal.type === 'edit' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold mb-4">Vorschlag bearbeiten & annehmen</h3>
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={e => setEditBody(e.target.value)}
|
||||
className="w-full border rounded-lg p-3 h-32 mb-4"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
||||
<button
|
||||
onClick={() => handleEdit(actionModal.cr)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Speichern & Annehmen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader/StepHeader'
|
||||
import {
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
import type {
|
||||
ComplianceScopeState,
|
||||
ScopeProfilingAnswer,
|
||||
ScopeDecision
|
||||
ScopeDecision,
|
||||
ApplicableRegulation,
|
||||
SupervisoryAuthorityInfo
|
||||
} from '@/lib/sdk/compliance-scope-types'
|
||||
import {
|
||||
createEmptyScopeState,
|
||||
@@ -20,6 +22,8 @@ import {
|
||||
} from '@/lib/sdk/compliance-scope-types'
|
||||
import { complianceScopeEngine } from '@/lib/sdk/compliance-scope-engine'
|
||||
import { getAllQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
||||
import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts'
|
||||
import { resolveAuthorities } from '@/lib/sdk/supervisory-authority-resolver'
|
||||
|
||||
type TabId = 'overview' | 'wizard' | 'decision' | 'export'
|
||||
|
||||
@@ -33,14 +37,31 @@ const TABS: { id: TabId; label: string; icon: string }[] = [
|
||||
export default function ComplianceScopePage() {
|
||||
const { state: sdkState, dispatch } = useSDK()
|
||||
|
||||
// Project-specific storage key
|
||||
const projectStorageKey = sdkState.projectId
|
||||
? `${STORAGE_KEY}_${sdkState.projectId}`
|
||||
: STORAGE_KEY
|
||||
|
||||
// Active tab state
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
|
||||
// Migrate old decision format: drop decision if it has old-format fields
|
||||
const migrateState = (state: ComplianceScopeState): ComplianceScopeState => {
|
||||
if (state.decision) {
|
||||
const d = state.decision as Record<string, unknown>
|
||||
// Old format had 'level' instead of 'determinedLevel', or docs with 'isMandatory'
|
||||
if (d.level || !d.determinedLevel) {
|
||||
return { ...state, decision: null }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// Local scope state
|
||||
const [scopeState, setScopeState] = useState<ComplianceScopeState>(() => {
|
||||
// Try to load from SDK context first
|
||||
if (sdkState.complianceScope) {
|
||||
return sdkState.complianceScope
|
||||
return migrateState(sdkState.complianceScope)
|
||||
}
|
||||
return createEmptyScopeState()
|
||||
})
|
||||
@@ -49,37 +70,60 @@ export default function ComplianceScopePage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isEvaluating, setIsEvaluating] = useState(false)
|
||||
|
||||
// Load from SDK context first (persisted via State API), then localStorage as fallback.
|
||||
// Runs ONCE on mount only — empty deps breaks the dispatch→sdkState→setScopeState→dispatch loop.
|
||||
// Guard against save-loop: tracks whether we're syncing FROM SDK → local state
|
||||
const syncingFromSdk = useRef(false)
|
||||
|
||||
// Regulation assessment state
|
||||
const [applicableRegulations, setApplicableRegulations] = useState<ApplicableRegulation[]>([])
|
||||
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
|
||||
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
|
||||
|
||||
// Sync from SDK context when it becomes available (handles async loading).
|
||||
// The SDK context loads state from server/localStorage asynchronously, so
|
||||
// sdkState.complianceScope may arrive AFTER this page has already mounted.
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Priority 1: SDK context (loaded from PostgreSQL via State API)
|
||||
const ctxScope = sdkState.complianceScope
|
||||
if (ctxScope && ctxScope.answers?.length > 0) {
|
||||
setScopeState(ctxScope)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ctxScope))
|
||||
} else {
|
||||
// Priority 2: localStorage fallback
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const ctxScope = sdkState.complianceScope
|
||||
if (ctxScope && ctxScope.answers?.length > 0) {
|
||||
syncingFromSdk.current = true
|
||||
setScopeState(migrateState(ctxScope))
|
||||
setIsLoading(false)
|
||||
} else if (isLoading) {
|
||||
// SDK has no scope data — try localStorage fallback, then give up
|
||||
try {
|
||||
const stored = localStorage.getItem(projectStorageKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as ComplianceScopeState
|
||||
setScopeState(parsed)
|
||||
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
|
||||
const parsed = migrateState(JSON.parse(stored) as ComplianceScopeState)
|
||||
if (parsed.answers?.length > 0) {
|
||||
setScopeState(parsed)
|
||||
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load compliance scope from localStorage:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load compliance scope state:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [sdkState.complianceScope])
|
||||
|
||||
// Save to localStorage and SDK context whenever state changes
|
||||
// Fetch regulation assessment if decision exists on mount
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (!isLoading && scopeState.decision && applicableRegulations.length === 0 && sdkState.companyProfile) {
|
||||
fetchRegulationAssessment(scopeState.decision)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading])
|
||||
|
||||
// Save to localStorage and SDK context whenever LOCAL state changes (user edits).
|
||||
// Guarded: don't save empty state, and don't echo back what we just loaded from SDK.
|
||||
useEffect(() => {
|
||||
if (!isLoading && scopeState.answers.length > 0) {
|
||||
if (syncingFromSdk.current) {
|
||||
syncingFromSdk.current = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(scopeState))
|
||||
localStorage.setItem(projectStorageKey, JSON.stringify(scopeState))
|
||||
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: scopeState })
|
||||
} catch (error) {
|
||||
console.error('Failed to save compliance scope state:', error)
|
||||
@@ -96,6 +140,41 @@ export default function ComplianceScopePage() {
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// Fetch regulation assessment from Go AI SDK
|
||||
const fetchRegulationAssessment = useCallback(async (decision: ScopeDecision) => {
|
||||
const profile = sdkState.companyProfile
|
||||
if (!profile) return
|
||||
|
||||
setRegulationAssessmentLoading(true)
|
||||
try {
|
||||
const payload = buildAssessmentPayload(profile, scopeState.answers, decision)
|
||||
const res = await fetch('/api/sdk/v1/ucca/obligations/assess-from-scope', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
|
||||
// Set applicable regulations from response
|
||||
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
||||
setApplicableRegulations(regs)
|
||||
|
||||
// Derive supervisory authorities
|
||||
const regIds = regs.map(r => r.id)
|
||||
const authorities = resolveAuthorities(
|
||||
profile.headquartersState,
|
||||
profile.headquartersCountry || 'DE',
|
||||
regIds
|
||||
)
|
||||
setSupervisoryAuthorities(authorities)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch regulation assessment:', error)
|
||||
} finally {
|
||||
setRegulationAssessmentLoading(false)
|
||||
}
|
||||
}, [sdkState.companyProfile, scopeState.answers])
|
||||
|
||||
// Handle evaluate button click
|
||||
const handleEvaluate = useCallback(async () => {
|
||||
setIsEvaluating(true)
|
||||
@@ -112,13 +191,15 @@ export default function ComplianceScopePage() {
|
||||
|
||||
// Switch to decision tab to show results
|
||||
setActiveTab('decision')
|
||||
|
||||
// Fetch regulation assessment in the background
|
||||
fetchRegulationAssessment(decision)
|
||||
} catch (error) {
|
||||
console.error('Failed to evaluate compliance scope:', error)
|
||||
// Optionally show error toast/notification
|
||||
} finally {
|
||||
setIsEvaluating(false)
|
||||
}
|
||||
}, [scopeState.answers])
|
||||
}, [scopeState.answers, fetchRegulationAssessment])
|
||||
|
||||
// Handle start profiling from overview
|
||||
const handleStartProfiling = useCallback(() => {
|
||||
@@ -130,7 +211,7 @@ export default function ComplianceScopePage() {
|
||||
const emptyState = createEmptyScopeState()
|
||||
setScopeState(emptyState)
|
||||
setActiveTab('overview')
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem(projectStorageKey)
|
||||
}, [])
|
||||
|
||||
// Calculate completion statistics
|
||||
@@ -160,6 +241,13 @@ export default function ComplianceScopePage() {
|
||||
return completionStats.isComplete
|
||||
}, [completionStats.isComplete])
|
||||
|
||||
// Mark sidebar step as complete when all required questions answered AND decision exists
|
||||
useEffect(() => {
|
||||
if (completionStats.isComplete && scopeState.decision) {
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'compliance-scope' })
|
||||
}
|
||||
}, [completionStats.isComplete, scopeState.decision, dispatch])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
@@ -283,6 +371,10 @@ export default function ComplianceScopePage() {
|
||||
canEvaluate={canEvaluate}
|
||||
onEvaluate={handleEvaluate}
|
||||
isEvaluating={isEvaluating}
|
||||
applicableRegulations={applicableRegulations}
|
||||
supervisoryAuthorities={supervisoryAuthorities}
|
||||
regulationAssessmentLoading={regulationAssessmentLoading}
|
||||
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -371,13 +463,13 @@ export default function ComplianceScopePage() {
|
||||
{scopeState.decision && (
|
||||
<>
|
||||
<div>
|
||||
<span className="font-semibold">Level:</span> {scopeState.decision.level}
|
||||
<span className="font-semibold">Level:</span> {scopeState.decision.determinedLevel}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Score:</span> {scopeState.decision.score}
|
||||
<span className="font-semibold">Score:</span> {scopeState.decision.scores?.composite_score}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Hard Triggers:</span> {scopeState.decision.hardTriggers.length}
|
||||
<span className="font-semibold">Hard Triggers:</span> {scopeState.decision.triggeredHardTriggers.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getDomain, BACKEND_URL, EMPTY_CONTROL, DOMAIN_OPTIONS, COLLECTION_OPTIONS } from '../components/helpers'
|
||||
|
||||
describe('getDomain', () => {
|
||||
it('extracts domain from control_id', () => {
|
||||
expect(getDomain('AUTH-001')).toBe('AUTH')
|
||||
expect(getDomain('NET-042')).toBe('NET')
|
||||
expect(getDomain('CRYPT-003')).toBe('CRYPT')
|
||||
})
|
||||
|
||||
it('returns empty string for invalid control_id', () => {
|
||||
expect(getDomain('')).toBe('')
|
||||
expect(getDomain('NODASH')).toBe('NODASH')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BACKEND_URL', () => {
|
||||
it('points to canonical API proxy', () => {
|
||||
expect(BACKEND_URL).toBe('/api/sdk/v1/canonical')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EMPTY_CONTROL', () => {
|
||||
it('has required fields with default values', () => {
|
||||
expect(EMPTY_CONTROL.framework_id).toBe('bp_security_v1')
|
||||
expect(EMPTY_CONTROL.severity).toBe('medium')
|
||||
expect(EMPTY_CONTROL.release_state).toBe('draft')
|
||||
expect(EMPTY_CONTROL.tags).toEqual([])
|
||||
expect(EMPTY_CONTROL.requirements).toEqual([''])
|
||||
expect(EMPTY_CONTROL.test_procedure).toEqual([''])
|
||||
expect(EMPTY_CONTROL.evidence).toEqual([{ type: '', description: '' }])
|
||||
expect(EMPTY_CONTROL.open_anchors).toEqual([{ framework: '', ref: '', url: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOMAIN_OPTIONS', () => {
|
||||
it('contains expected domains', () => {
|
||||
const values = DOMAIN_OPTIONS.map(d => d.value)
|
||||
expect(values).toContain('AUTH')
|
||||
expect(values).toContain('NET')
|
||||
expect(values).toContain('CRYPT')
|
||||
expect(values).toContain('AI')
|
||||
expect(values).toContain('COMP')
|
||||
expect(values.length).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('COLLECTION_OPTIONS', () => {
|
||||
it('contains expected collections', () => {
|
||||
const values = COLLECTION_OPTIONS.map(c => c.value)
|
||||
expect(values).toContain('bp_compliance_ce')
|
||||
expect(values).toContain('bp_compliance_gesetze')
|
||||
expect(values).toContain('bp_compliance_datenschutz')
|
||||
expect(values.length).toBe(6)
|
||||
})
|
||||
})
|
||||
322
admin-compliance/app/sdk/control-library/__tests__/page.test.tsx
Normal file
322
admin-compliance/app/sdk/control-library/__tests__/page.test.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
|
||||
import ControlLibraryPage from '../page'
|
||||
|
||||
// ============================================================================
|
||||
// Mock data
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_FRAMEWORK = {
|
||||
id: 'fw-1',
|
||||
framework_id: 'bp_security_v1',
|
||||
name: 'BreakPilot Security',
|
||||
version: '1.0',
|
||||
description: 'Test framework',
|
||||
release_state: 'draft',
|
||||
}
|
||||
|
||||
const MOCK_CONTROL = {
|
||||
id: 'ctrl-1',
|
||||
framework_id: 'fw-1',
|
||||
control_id: 'AUTH-001',
|
||||
title: 'Multi-Factor Authentication',
|
||||
objective: 'Require MFA for all admin accounts.',
|
||||
rationale: 'Passwords alone are insufficient.',
|
||||
scope: {},
|
||||
requirements: ['MFA for admin'],
|
||||
test_procedure: ['Test admin login'],
|
||||
evidence: [{ type: 'config', description: 'MFA enabled' }],
|
||||
severity: 'high',
|
||||
risk_score: 4.0,
|
||||
implementation_effort: 'm',
|
||||
evidence_confidence: null,
|
||||
open_anchors: [{ framework: 'OWASP', ref: 'V2.8', url: 'https://owasp.org' }],
|
||||
release_state: 'draft',
|
||||
tags: ['mfa'],
|
||||
license_rule: 1,
|
||||
source_original_text: null,
|
||||
source_citation: { source: 'DSGVO' },
|
||||
customer_visible: true,
|
||||
verification_method: 'automated',
|
||||
category: 'authentication',
|
||||
target_audience: 'developer',
|
||||
generation_metadata: null,
|
||||
generation_strategy: 'ungrouped',
|
||||
created_at: '2026-03-15T10:00:00+00:00',
|
||||
updated_at: '2026-03-15T10:00:00+00:00',
|
||||
}
|
||||
|
||||
const MOCK_META = {
|
||||
total: 1,
|
||||
domains: [{ domain: 'AUTH', count: 1 }],
|
||||
sources: [{ source: 'DSGVO', count: 1 }],
|
||||
no_source_count: 0,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fetch mock
|
||||
// ============================================================================
|
||||
|
||||
function createFetchMock(overrides?: Record<string, unknown>) {
|
||||
const responses: Record<string, unknown> = {
|
||||
frameworks: [MOCK_FRAMEWORK],
|
||||
controls: [MOCK_CONTROL],
|
||||
'controls-count': { total: 1 },
|
||||
'controls-meta': MOCK_META,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return vi.fn((url: string) => {
|
||||
const urlStr = typeof url === 'string' ? url : ''
|
||||
// Match endpoint param
|
||||
const match = urlStr.match(/endpoint=([^&]+)/)
|
||||
const endpoint = match?.[1] || ''
|
||||
const data = responses[endpoint] ?? []
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(data),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ControlLibraryPage', () => {
|
||||
let fetchMock: ReturnType<typeof createFetchMock>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = createFetchMock()
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders the page header', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Canonical Control Library')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows control count from meta', async () => {
|
||||
fetchMock = createFetchMock({ 'controls-meta': { ...MOCK_META, total: 42 } })
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/42 Security Controls/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders control list with data', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
|
||||
expect(screen.getByText('Multi-Factor Authentication')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows timestamp on control cards', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
// The date should be rendered in German locale format
|
||||
expect(screen.getByText(/15\.03\.26/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows source citation on control cards', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('DSGVO')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches with limit and offset params', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Find the controls fetch call
|
||||
const controlsCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls&')
|
||||
)
|
||||
expect(controlsCalls.length).toBeGreaterThan(0)
|
||||
|
||||
const url = controlsCalls[0][0] as string
|
||||
expect(url).toContain('limit=50')
|
||||
expect(url).toContain('offset=0')
|
||||
})
|
||||
|
||||
it('fetches controls-count alongside controls', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
const countCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls-count')
|
||||
)
|
||||
expect(countCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches controls-meta on mount', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
const metaCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls-meta')
|
||||
)
|
||||
expect(metaCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders domain dropdown from meta', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AUTH (1)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders source dropdown from meta', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
// The source option should appear in the dropdown
|
||||
expect(screen.getByText('DSGVO (1)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('has sort dropdown with all sort options', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sortierung: ID')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nach Quelle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Neueste zuerst')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aelteste zuerst')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('sends sort params when sorting by newest', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Clear previous calls
|
||||
fetchMock.mockClear()
|
||||
|
||||
// Change sort to newest
|
||||
const sortSelect = screen.getByDisplayValue('Sortierung: ID')
|
||||
await act(async () => {
|
||||
fireEvent.change(sortSelect, { target: { value: 'newest' } })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const controlsCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls&')
|
||||
)
|
||||
expect(controlsCalls.length).toBeGreaterThan(0)
|
||||
const url = controlsCalls[0][0] as string
|
||||
expect(url).toContain('sort=created_at')
|
||||
expect(url).toContain('order=desc')
|
||||
})
|
||||
})
|
||||
|
||||
it('sends search param after debounce', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fetchMock.mockClear()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Controls durchsuchen/)
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'encryption' } })
|
||||
})
|
||||
|
||||
// Wait for debounce (400ms)
|
||||
await waitFor(
|
||||
() => {
|
||||
const controlsCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('search=encryption')
|
||||
)
|
||||
expect(controlsCalls.length).toBeGreaterThan(0)
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('shows empty state when no controls', async () => {
|
||||
fetchMock = createFetchMock({
|
||||
controls: [],
|
||||
'controls-count': { total: 0 },
|
||||
'controls-meta': { ...MOCK_META, total: 0 },
|
||||
})
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine Controls/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Keine Controls gefunden" when filter matches nothing', async () => {
|
||||
fetchMock = createFetchMock({
|
||||
controls: [],
|
||||
'controls-count': { total: 0 },
|
||||
'controls-meta': { ...MOCK_META, total: 50 },
|
||||
})
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
render(<ControlLibraryPage />)
|
||||
|
||||
// Wait for initial load to finish
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/Controls durchsuchen/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Trigger a search to have a filter active
|
||||
const searchInput = screen.getByPlaceholderText(/Controls durchsuchen/)
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'zzzzzzz' } })
|
||||
})
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('Keine Controls gefunden.')).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('has a refresh button', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Aktualisieren')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders pagination info', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Seite 1 von 1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows pagination buttons for many controls', async () => {
|
||||
fetchMock = createFetchMock({
|
||||
'controls-count': { total: 150 },
|
||||
'controls-meta': { ...MOCK_META, total: 150 },
|
||||
})
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Seite 1 von 3/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,878 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
ArrowLeft, ExternalLink, BookOpen, Scale, FileText,
|
||||
Eye, CheckCircle2, Trash2, Pencil, Clock,
|
||||
ChevronLeft, SkipForward, GitMerge, Search, Landmark,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||
ObligationTypeBadge, GenerationStrategyBadge, isEigenentwicklung,
|
||||
ExtractionMethodBadge, RegulationCountBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
||||
} from './helpers'
|
||||
|
||||
interface SimilarControl {
|
||||
control_id: string
|
||||
title: string
|
||||
severity: string
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule: number | null
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface ParentLink {
|
||||
parent_control_id: string
|
||||
parent_title: string
|
||||
link_type: string
|
||||
confidence: number
|
||||
source_regulation: string | null
|
||||
source_article: string | null
|
||||
parent_citation: Record<string, string> | null
|
||||
obligation: {
|
||||
text: string
|
||||
action: string
|
||||
object: string
|
||||
normative_strength: string
|
||||
} | null
|
||||
}
|
||||
|
||||
interface TraceabilityData {
|
||||
control_id: string
|
||||
title: string
|
||||
is_atomic: boolean
|
||||
parent_links: ParentLink[]
|
||||
children: Array<{
|
||||
control_id: string
|
||||
title: string
|
||||
category: string
|
||||
severity: string
|
||||
decomposition_method: string
|
||||
}>
|
||||
source_count: number
|
||||
// Extended provenance fields
|
||||
obligations?: ObligationInfo[]
|
||||
obligation_count?: number
|
||||
document_references?: DocumentReference[]
|
||||
merged_duplicates?: MergedDuplicate[]
|
||||
merged_duplicates_count?: number
|
||||
regulations_summary?: RegulationSummary[]
|
||||
}
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface ControlDetailProps {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
onEdit: () => void
|
||||
onDelete: (controlId: string) => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
onRefresh?: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
||||
// Review mode navigation
|
||||
reviewMode?: boolean
|
||||
reviewIndex?: number
|
||||
reviewTotal?: number
|
||||
onReviewPrev?: () => void
|
||||
onReviewNext?: () => void
|
||||
}
|
||||
|
||||
export function ControlDetail({
|
||||
ctrl,
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReview,
|
||||
onRefresh,
|
||||
onNavigateToControl,
|
||||
onCompare,
|
||||
reviewMode,
|
||||
reviewIndex = 0,
|
||||
reviewTotal = 0,
|
||||
onReviewPrev,
|
||||
onReviewNext,
|
||||
}: ControlDetailProps) {
|
||||
const [similarControls, setSimilarControls] = useState<SimilarControl[]>([])
|
||||
const [loadingSimilar, setLoadingSimilar] = useState(false)
|
||||
const [selectedDuplicates, setSelectedDuplicates] = useState<Set<string>>(new Set())
|
||||
const [merging, setMerging] = useState(false)
|
||||
const [traceability, setTraceability] = useState<TraceabilityData | null>(null)
|
||||
const [loadingTrace, setLoadingTrace] = useState(false)
|
||||
const [v1Matches, setV1Matches] = useState<V1Match[]>([])
|
||||
const [loadingV1, setLoadingV1] = useState(false)
|
||||
const eigenentwicklung = isEigenentwicklung(ctrl)
|
||||
|
||||
const loadTraceability = useCallback(async () => {
|
||||
setLoadingTrace(true)
|
||||
try {
|
||||
// Try provenance first (extended data), fall back to traceability
|
||||
let res = await fetch(`${BACKEND_URL}?endpoint=provenance&id=${ctrl.control_id}`)
|
||||
if (!res.ok) {
|
||||
res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
||||
}
|
||||
if (res.ok) {
|
||||
setTraceability(await res.json())
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingTrace(false) }
|
||||
}, [ctrl.control_id])
|
||||
|
||||
const loadV1Matches = useCallback(async () => {
|
||||
if (!eigenentwicklung) { setV1Matches([]); return }
|
||||
setLoadingV1(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=v1-matches&id=${ctrl.control_id}`)
|
||||
if (res.ok) setV1Matches(await res.json())
|
||||
else setV1Matches([])
|
||||
} catch { setV1Matches([]) }
|
||||
finally { setLoadingV1(false) }
|
||||
}, [ctrl.control_id, eigenentwicklung])
|
||||
|
||||
useEffect(() => {
|
||||
loadSimilarControls()
|
||||
loadTraceability()
|
||||
loadV1Matches()
|
||||
setSelectedDuplicates(new Set())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctrl.control_id])
|
||||
|
||||
const loadSimilarControls = async () => {
|
||||
setLoadingSimilar(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=similar&id=${ctrl.control_id}`)
|
||||
if (res.ok) {
|
||||
setSimilarControls(await res.json())
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingSimilar(false) }
|
||||
}
|
||||
|
||||
const toggleDuplicate = (controlId: string) => {
|
||||
setSelectedDuplicates(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(controlId)) next.delete(controlId)
|
||||
else next.add(controlId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleMergeDuplicates = async () => {
|
||||
if (selectedDuplicates.size === 0) return
|
||||
if (!confirm(`${selectedDuplicates.size} Controls als Duplikate markieren und Tags/Anchors in ${ctrl.control_id} zusammenfuehren?`)) return
|
||||
|
||||
setMerging(true)
|
||||
try {
|
||||
// For each duplicate: mark as deprecated
|
||||
for (const dupId of selectedDuplicates) {
|
||||
await fetch(`${BACKEND_URL}?endpoint=update-control&id=${dupId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ release_state: 'deprecated' }),
|
||||
})
|
||||
}
|
||||
// Refresh to show updated state
|
||||
if (onRefresh) onRefresh()
|
||||
setSelectedDuplicates(new Set())
|
||||
loadSimilarControls()
|
||||
} catch {
|
||||
alert('Fehler beim Zusammenfuehren')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-1">{ctrl.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{reviewMode && (
|
||||
<div className="flex items-center gap-1 mr-3">
|
||||
<button onClick={onReviewPrev} disabled={reviewIndex === 0} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 font-medium">{reviewIndex + 1} / {reviewTotal}</span>
|
||||
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onEdit} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />Bearbeiten
|
||||
</button>
|
||||
<button onClick={() => onDelete(ctrl.control_id)} className="px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 max-w-4xl mx-auto w-full space-y-6">
|
||||
{/* Objective */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.objective}</p>
|
||||
</section>
|
||||
|
||||
{/* Rationale */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.rationale}</p>
|
||||
</section>
|
||||
|
||||
{/* Quellennachweis (Rule 1 + 2) — dynamic label based on source_type */}
|
||||
{ctrl.source_citation && (
|
||||
<section className={`border rounded-lg p-4 ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'bg-blue-50 border-blue-200' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'bg-indigo-50 border-indigo-200' :
|
||||
'bg-teal-50 border-teal-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className={`w-4 h-4 ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'text-blue-600' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-600' :
|
||||
'text-teal-600'
|
||||
}`} />
|
||||
<h3 className={`text-sm font-semibold ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'text-blue-900' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-900' :
|
||||
'text-teal-900'
|
||||
}`}>{
|
||||
ctrl.source_citation.source_type === 'law' ? 'Gesetzliche Grundlage' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'Behoerdliche Leitlinie' :
|
||||
'Standard / Best Practice'
|
||||
}</h3>
|
||||
{ctrl.source_citation.source_type === 'law' && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">Direkte gesetzliche Pflicht</span>
|
||||
)}
|
||||
{ctrl.source_citation.source_type === 'guideline' && (
|
||||
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">Aufsichtsbehoerdliche Empfehlung</span>
|
||||
)}
|
||||
{(ctrl.source_citation.source_type === 'standard' || (!ctrl.source_citation.source_type && ctrl.license_rule === 2)) && (
|
||||
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded-full">Freiwilliger Standard</span>
|
||||
)}
|
||||
{(!ctrl.source_citation.source_type && ctrl.license_rule === 1) && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">Noch nicht klassifiziert</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
{ctrl.source_citation.source ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</p>
|
||||
) : ctrl.generation_metadata?.source_regulation ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">{String(ctrl.generation_metadata.source_regulation)}</p>
|
||||
) : null}
|
||||
{ctrl.source_citation.license && (
|
||||
<p className="text-xs text-blue-600">Lizenz: {ctrl.source_citation.license}</p>
|
||||
)}
|
||||
{ctrl.source_citation.license_notice && (
|
||||
<p className="text-xs text-blue-600 mt-0.5">{ctrl.source_citation.license_notice}</p>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.source_citation.url && (
|
||||
<a
|
||||
href={ctrl.source_citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 whitespace-nowrap"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />Quelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="text-xs text-gray-600 mt-2 p-2 bg-white rounded border border-blue-100 leading-relaxed max-h-40 overflow-y-auto whitespace-pre-wrap">
|
||||
{ctrl.source_original_text}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Regulatorische Abdeckung (Eigenentwicklung) */}
|
||||
{eigenentwicklung && (
|
||||
<section className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-orange-600" />
|
||||
<h3 className="text-sm font-semibold text-orange-900">
|
||||
Regulatorische Abdeckung
|
||||
</h3>
|
||||
{loadingV1 && <span className="text-xs text-orange-400">Laden...</span>}
|
||||
</div>
|
||||
{v1Matches.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{v1Matches.map((match, i) => (
|
||||
<div key={i} className="bg-white/60 border border-orange-100 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
{match.matched_source && (
|
||||
<span className="text-xs font-semibold text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">
|
||||
{match.matched_source}
|
||||
</span>
|
||||
)}
|
||||
{match.matched_article && (
|
||||
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">
|
||||
{match.matched_article}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||
match.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||
match.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{(match.similarity_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(match.matched_control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline mr-1.5"
|
||||
>
|
||||
{match.matched_control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mr-1.5">
|
||||
{match.matched_control_id}
|
||||
</span>
|
||||
)}
|
||||
{match.matched_title}
|
||||
</p>
|
||||
</div>
|
||||
{onCompare && (
|
||||
<button
|
||||
onClick={() => onCompare(ctrl, v1Matches)}
|
||||
className="text-xs text-orange-600 border border-orange-300 rounded px-2 py-1 hover:bg-orange-100 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
Vergleichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !loadingV1 ? (
|
||||
<p className="text-sm text-orange-600">Keine regulatorische Abdeckung gefunden. Dieses Control ist eine reine Eigenentwicklung.</p>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Rechtsgrundlagen / Traceability (atomic controls) */}
|
||||
{traceability && traceability.parent_links.length > 0 && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Landmark className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">
|
||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
||||
</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{traceability.regulations_summary && traceability.regulations_summary.map(rs => (
|
||||
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
|
||||
{rs.regulation_code}
|
||||
</span>
|
||||
))}
|
||||
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{traceability.parent_links.map((link, i) => (
|
||||
<div key={i} className="bg-white/60 border border-violet-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Scale className="w-4 h-4 text-violet-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{link.source_regulation && (
|
||||
<span className="text-sm font-semibold text-violet-900">{link.source_regulation}</span>
|
||||
)}
|
||||
{link.source_article && (
|
||||
<span className="text-sm text-violet-700">{link.source_article}</span>
|
||||
)}
|
||||
{!link.source_regulation && link.parent_citation?.source && (
|
||||
<span className="text-sm font-semibold text-violet-900">
|
||||
{link.parent_citation.source}
|
||||
{link.parent_citation.article && ` — ${link.parent_citation.article}`}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
link.link_type === 'decomposition' ? 'bg-violet-100 text-violet-600' :
|
||||
link.link_type === 'dedup_merge' ? 'bg-blue-100 text-blue-600' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{link.link_type === 'decomposition' ? 'Ableitung' :
|
||||
link.link_type === 'dedup_merge' ? 'Dedup' :
|
||||
link.link_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-violet-600 mt-1">
|
||||
via{' '}
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(link.parent_control_id)}
|
||||
className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{link.parent_control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded">
|
||||
{link.parent_control_id}
|
||||
</span>
|
||||
)}
|
||||
{link.parent_title && (
|
||||
<span className="text-violet-500 ml-1">— {link.parent_title}</span>
|
||||
)}
|
||||
</p>
|
||||
{link.obligation && (
|
||||
<p className="text-xs text-violet-500 mt-1.5 bg-violet-50 rounded p-2">
|
||||
<span className={`inline-block mr-1.5 px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
link.obligation.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
link.obligation.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{link.obligation.normative_strength === 'must' ? 'MUSS' :
|
||||
link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{link.obligation.text.slice(0, 200)}
|
||||
{link.obligation.text.length > 200 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Fallback: simple parent display when traceability not loaded yet */}
|
||||
{ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<GitMerge className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">Atomares Control</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<p className="text-sm text-violet-800">
|
||||
Abgeleitet aus Eltern-Control{' '}
|
||||
<span className="font-mono font-semibold text-purple-700 bg-purple-100 px-1.5 py-0.5 rounded">
|
||||
{ctrl.parent_control_id || ctrl.parent_control_uuid}
|
||||
</span>
|
||||
{ctrl.parent_control_title && (
|
||||
<span className="text-violet-700 ml-1">— {ctrl.parent_control_title}</span>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Document References (atomic controls) */}
|
||||
{traceability && traceability.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
|
||||
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-indigo-600" />
|
||||
<h3 className="text-sm font-semibold text-indigo-900">
|
||||
Original-Dokumente ({traceability.document_references.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.document_references.map((dr, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
|
||||
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
|
||||
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
|
||||
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<ExtractionMethodBadge method={dr.extraction_method} />
|
||||
{dr.confidence !== null && (
|
||||
<span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Obligations (rich controls) */}
|
||||
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.obligations.map((ob) => (
|
||||
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{ob.normative_strength === 'must' ? 'MUSS' :
|
||||
ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
|
||||
{ob.object && <span className="text-xs text-amber-500">→ {ob.object}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
{ob.obligation_text.slice(0, 300)}
|
||||
{ob.obligation_text.length > 300 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Merged Duplicates */}
|
||||
{traceability && traceability.merged_duplicates && traceability.merged_duplicates.length > 0 && (
|
||||
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.merged_duplicates.map((dup) => (
|
||||
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(dup.control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{dup.control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{dup.control_id}</span>
|
||||
)}
|
||||
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
|
||||
{dup.source_regulation && (
|
||||
<span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Child controls (rich controls that have atomic children) */}
|
||||
{traceability && traceability.children.length > 0 && (
|
||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-emerald-900">
|
||||
Abgeleitete Controls ({traceability.children.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.children.map((child) => (
|
||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(child.control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{child.control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{child.control_id}</span>
|
||||
)}
|
||||
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
||||
<SeverityBadge severity={child.severity} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Impliziter Gesetzesbezug (Rule 3 — reformuliert, kein Originaltext) */}
|
||||
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-amber-800 font-medium">Abgeleitet aus regulatorischen Anforderungen</p>
|
||||
<p className="text-xs text-amber-700 mt-0.5">
|
||||
Dieser Control wurde aus geschuetzten Quellen reformuliert (z.B. BSI Grundschutz, ISO 27001).
|
||||
Die konkreten Massnahmen leiten sich aus den Open-Source-Referenzen unten ab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Scope */}
|
||||
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
{ctrl.scope.platforms?.length ? (
|
||||
<div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div>
|
||||
) : null}
|
||||
{ctrl.scope.components?.length ? (
|
||||
<div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div>
|
||||
) : null}
|
||||
{ctrl.scope.data_classes?.length ? (
|
||||
<div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Requirements */}
|
||||
{ctrl.requirements.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{ctrl.requirements.map((r, i) => (
|
||||
<li key={i} className="text-sm text-gray-700">{r}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Test Procedure */}
|
||||
{ctrl.test_procedure.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{ctrl.test_procedure.map((s, i) => (
|
||||
<li key={i} className="text-sm text-gray-700">{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Evidence — handles both {type, description} objects and plain strings */}
|
||||
{ctrl.evidence.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
{typeof ev === 'string' ? (
|
||||
<div>{ev}</div>
|
||||
) : (
|
||||
<div><span className="font-medium">{ev.type}:</span> {ev.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<section className="grid grid-cols-3 gap-4 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <div>Risiko-Score: <span className="text-gray-700 font-medium">{ctrl.risk_score}</span></div>}
|
||||
{ctrl.implementation_effort && <div>Aufwand: <span className="text-gray-700 font-medium">{EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span></div>}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<div className="col-span-3 flex items-center gap-1 flex-wrap">
|
||||
{ctrl.tags.map(t => (
|
||||
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({ctrl.open_anchors.length})</h3>
|
||||
</div>
|
||||
{ctrl.open_anchors.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
<span className="font-medium text-green-800">{anchor.framework}</span>
|
||||
<span className="text-green-700">{anchor.ref}</span>
|
||||
{anchor.url && (
|
||||
<a href={anchor.url} target="_blank" rel="noopener noreferrer" className="text-green-600 hover:text-green-800 underline text-xs ml-auto">
|
||||
Link
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-green-600">Keine Referenzen vorhanden.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Generation Metadata (internal) */}
|
||||
{ctrl.generation_metadata && (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
{ctrl.generation_metadata.processing_path && (
|
||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.decomposition_method && (
|
||||
<p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.pass0b_model && (
|
||||
<p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.obligation_type && (
|
||||
<p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.similarity_status && (
|
||||
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
|
||||
)}
|
||||
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
|
||||
<div>
|
||||
<p className="font-medium">Aehnliche Controls:</p>
|
||||
{(ctrl.generation_metadata.similar_controls as Array<Record<string, unknown>>).map((s, i) => (
|
||||
<p key={i} className="ml-2">{String(s.control_id)} — {String(s.title)} ({String(s.similarity)})</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Similar Controls (Dedup) */}
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Search className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Aehnliche Controls</h3>
|
||||
{loadingSimilar && <span className="text-xs text-gray-400">Laden...</span>}
|
||||
</div>
|
||||
|
||||
{similarControls.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-3 p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input type="radio" checked readOnly className="text-purple-600" />
|
||||
<span className="text-sm font-medium text-purple-700">{ctrl.control_id} — {ctrl.title}</span>
|
||||
<span className="text-xs text-gray-400 ml-auto">Behalten (Haupt-Control)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarControls.map(sim => (
|
||||
<div key={sim.control_id} className="p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDuplicates.has(sim.control_id)}
|
||||
onChange={() => toggleDuplicate(sim.control_id)}
|
||||
className="text-red-600"
|
||||
/>
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{sim.control_id}</span>
|
||||
<span className="text-sm text-gray-700 flex-1">{sim.title}</span>
|
||||
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
|
||||
{(sim.similarity * 100).toFixed(1)}%
|
||||
</span>
|
||||
<LicenseRuleBadge rule={sim.license_rule} />
|
||||
<VerificationMethodBadge method={sim.verification_method} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedDuplicates.size > 0 && (
|
||||
<button
|
||||
onClick={handleMergeDuplicates}
|
||||
disabled={merging}
|
||||
className="mt-3 flex items-center gap-1.5 px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5" />
|
||||
{merging ? 'Zusammenfuehren...' : `${selectedDuplicates.size} Duplikat(e) zusammenfuehren`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
{loadingSimilar ? 'Suche aehnliche Controls...' : 'Keine aehnlichen Controls gefunden.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Review Actions */}
|
||||
{['needs_review', 'too_close', 'duplicate'].includes(ctrl.release_state) && (
|
||||
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-yellow-700" />
|
||||
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
||||
{reviewMode && (
|
||||
<span className="text-xs text-yellow-600 ml-auto">Review-Modus aktiv</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'approve')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Akzeptieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'reject')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ueberarbeiten
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, Trash2, Save, X } from 'lucide-react'
|
||||
import { EMPTY_CONTROL, VERIFICATION_METHODS, CATEGORY_OPTIONS, TARGET_AUDIENCE_OPTIONS } from './helpers'
|
||||
|
||||
export function ControlForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
initial: typeof EMPTY_CONTROL
|
||||
onSave: (data: typeof EMPTY_CONTROL) => void
|
||||
onCancel: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [form, setForm] = useState(initial)
|
||||
const [tagInput, setTagInput] = useState(initial.tags.join(', '))
|
||||
const [platformInput, setPlatformInput] = useState((initial.scope.platforms || []).join(', '))
|
||||
const [componentInput, setComponentInput] = useState((initial.scope.components || []).join(', '))
|
||||
const [dataClassInput, setDataClassInput] = useState((initial.scope.data_classes || []).join(', '))
|
||||
|
||||
const handleSave = () => {
|
||||
const data = {
|
||||
...form,
|
||||
tags: tagInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
scope: {
|
||||
platforms: platformInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
components: componentInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
data_classes: dataClassInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
},
|
||||
requirements: form.requirements.filter(r => r.trim()),
|
||||
test_procedure: form.test_procedure.filter(r => r.trim()),
|
||||
evidence: form.evidence.filter(e => e.type.trim() || e.description.trim()),
|
||||
open_anchors: form.open_anchors.filter(a => a.framework.trim() || a.ref.trim()),
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{initial.control_id ? `Control ${initial.control_id} bearbeiten` : 'Neues Control erstellen'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<X className="w-4 h-4 inline mr-1" />Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<Save className="w-4 h-4 inline mr-1" />{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Control-ID *</label>
|
||||
<input
|
||||
value={form.control_id}
|
||||
onChange={e => setForm({ ...form, control_id: e.target.value.toUpperCase() })}
|
||||
placeholder="AUTH-003"
|
||||
disabled={!!initial.control_id}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Format: DOMAIN-NNN (z.B. AUTH-003, NET-005)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Titel *</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Control-Titel"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Schweregrad</label>
|
||||
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Risiko-Score (0-10)</label>
|
||||
<input
|
||||
type="number" min="0" max="10" step="0.5"
|
||||
value={form.risk_score ?? ''}
|
||||
onChange={e => setForm({ ...form, risk_score: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Aufwand</label>
|
||||
<select value={form.implementation_effort || ''} onChange={e => setForm({ ...form, implementation_effort: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">-</option>
|
||||
<option value="s">Klein (S)</option>
|
||||
<option value="m">Mittel (M)</option>
|
||||
<option value="l">Gross (L)</option>
|
||||
<option value="xl">Sehr gross (XL)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Ziel *</label>
|
||||
<textarea
|
||||
value={form.objective}
|
||||
onChange={e => setForm({ ...form, objective: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Begruendung *</label>
|
||||
<textarea
|
||||
value={form.rationale}
|
||||
onChange={e => setForm({ ...form, rationale: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Plattformen (komma-getrennt)</label>
|
||||
<input value={platformInput} onChange={e => setPlatformInput(e.target.value)} placeholder="web, mobile, api" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Komponenten (komma-getrennt)</label>
|
||||
<input value={componentInput} onChange={e => setComponentInput(e.target.value)} placeholder="auth-service, gateway" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datenklassen (komma-getrennt)</label>
|
||||
<input value={dataClassInput} onChange={e => setDataClassInput(e.target.value)} placeholder="credentials, tokens" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Anforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, requirements: [...form.requirements, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.requirements.map((req, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={req}
|
||||
onChange={e => { const r = [...form.requirements]; r[i] = e.target.value; setForm({ ...form, requirements: r }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, requirements: form.requirements.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Pruefverfahren</label>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: [...form.test_procedure, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.test_procedure.map((step, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={step}
|
||||
onChange={e => { const t = [...form.test_procedure]; t[i] = e.target.value; setForm({ ...form, test_procedure: t }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: form.test_procedure.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Evidence */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Nachweisanforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, evidence: [...form.evidence, { type: '', description: '' }] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={ev.type}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], type: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Typ (z.B. config, test_result)"
|
||||
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
value={ev.description}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], description: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Beschreibung"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, evidence: form.evidence.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<label className="text-xs font-semibold text-green-900">Open-Source-Referenzen *</label>
|
||||
</div>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: [...form.open_anchors, { framework: '', ref: '', url: '' }] })} className="text-xs text-green-700 hover:text-green-900">+ Hinzufuegen</button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 mb-3">Jedes Control braucht mindestens eine offene Referenz (OWASP, NIST, ENISA, etc.)</p>
|
||||
{form.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={anchor.framework}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], framework: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Framework (z.B. OWASP ASVS)"
|
||||
className="w-40 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.ref}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], ref: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Referenz (z.B. V2.8)"
|
||||
className="w-48 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.url}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], url: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="https://..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: form.open_anchors.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tags & State */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Tags (komma-getrennt)</label>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} placeholder="mfa, auth, iam" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||
<select value={form.release_state} onChange={e => setForm({ ...form, release_state: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Method, Category & Target Audience */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Nachweismethode</label>
|
||||
<select
|
||||
value={form.verification_method || ''}
|
||||
onChange={e => setForm({ ...form, verification_method: e.target.value || null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">— Nicht zugewiesen —</option>
|
||||
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Wie wird dieses Control nachgewiesen?</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={form.category || ''}
|
||||
onChange={e => setForm({ ...form, category: e.target.value || null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">— Nicht zugewiesen —</option>
|
||||
{CATEGORY_OPTIONS.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Zielgruppe</label>
|
||||
<select
|
||||
value={form.target_audience || ''}
|
||||
onChange={e => setForm({ ...form, target_audience: e.target.value || null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">— Nicht zugewiesen —</option>
|
||||
{Object.entries(TARGET_AUDIENCE_OPTIONS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Fuer wen ist dieses Control relevant?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Zap, X, RefreshCw, History, CheckCircle2 } from 'lucide-react'
|
||||
import { BACKEND_URL, DOMAIN_OPTIONS, COLLECTION_OPTIONS } from './helpers'
|
||||
|
||||
interface GeneratorModalProps {
|
||||
onClose: () => void
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function GeneratorModal({ onClose, onComplete }: GeneratorModalProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [genResult, setGenResult] = useState<Record<string, unknown> | null>(null)
|
||||
const [genDomain, setGenDomain] = useState('')
|
||||
const [genMaxControls, setGenMaxControls] = useState(10)
|
||||
const [genDryRun, setGenDryRun] = useState(true)
|
||||
const [genCollections, setGenCollections] = useState<string[]>([])
|
||||
const [showJobHistory, setShowJobHistory] = useState(false)
|
||||
const [jobHistory, setJobHistory] = useState<Array<Record<string, unknown>>>([])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
setGenResult(null)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: genDomain || null,
|
||||
collections: genCollections.length > 0 ? genCollections : null,
|
||||
max_controls: genMaxControls,
|
||||
dry_run: genDryRun,
|
||||
skip_web_search: false,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setGenResult({ status: 'error', message: err.error || err.details || 'Fehler' })
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setGenResult(data)
|
||||
if (!genDryRun) {
|
||||
onComplete()
|
||||
}
|
||||
} catch {
|
||||
setGenResult({ status: 'error', message: 'Netzwerkfehler' })
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadJobHistory = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=generate-jobs`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setJobHistory(data.jobs || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const toggleCollection = (col: string) => {
|
||||
setGenCollections(prev =>
|
||||
prev.includes(col) ? prev.filter(c => c !== col) : [...prev, col]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Control Generator</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setShowJobHistory(!showJobHistory); if (!showJobHistory) loadJobHistory() }}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title="Job-Verlauf"
|
||||
>
|
||||
<History className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showJobHistory ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">Letzte Generierungs-Jobs</h3>
|
||||
{jobHistory.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Keine Jobs vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{jobHistory.map((job, i) => (
|
||||
<div key={i} className="border border-gray-200 rounded-lg p-3 text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`px-2 py-0.5 rounded font-medium ${
|
||||
job.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
job.status === 'failed' ? 'bg-red-100 text-red-700' :
|
||||
job.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{String(job.status)}
|
||||
</span>
|
||||
<span className="text-gray-400">{String(job.created_at || '').slice(0, 16)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-gray-500 mt-1">
|
||||
<span>Chunks: {String(job.total_chunks_scanned || 0)}</span>
|
||||
<span>Generiert: {String(job.controls_generated || 0)}</span>
|
||||
<span>Verifiziert: {String(job.controls_verified || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowJobHistory(false)}
|
||||
className="w-full py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Zurueck zum Generator
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Domain (optional)</label>
|
||||
<select value={genDomain} onChange={e => setGenDomain(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">Alle Domains</option>
|
||||
{DOMAIN_OPTIONS.map(d => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">Collections (optional)</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{COLLECTION_OPTIONS.map(col => (
|
||||
<label key={col.value} className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={genCollections.includes(col.value)}
|
||||
onChange={() => toggleCollection(col.value)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{genCollections.length === 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">Keine Auswahl = alle Collections</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Max. Controls: {genMaxControls}</label>
|
||||
<input
|
||||
type="range" min="1" max="100" step="1"
|
||||
value={genMaxControls}
|
||||
onChange={e => setGenMaxControls(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dryRun"
|
||||
checked={genDryRun}
|
||||
onChange={e => setGenDryRun(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="dryRun" className="text-sm text-gray-700">Dry Run (Vorschau ohne Speicherung)</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="w-full py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{generating ? (
|
||||
<><RefreshCw className="w-4 h-4 animate-spin" /> Generiere...</>
|
||||
) : (
|
||||
<><Zap className="w-4 h-4" /> Generierung starten</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{genResult && (
|
||||
<div className={`p-4 rounded-lg text-sm ${genResult.status === 'error' ? 'bg-red-50 text-red-800' : 'bg-green-50 text-green-800'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{genResult.status !== 'error' && <CheckCircle2 className="w-4 h-4" />}
|
||||
<p className="font-medium">{String(genResult.message || genResult.status)}</p>
|
||||
</div>
|
||||
{genResult.status !== 'error' && (
|
||||
<div className="grid grid-cols-2 gap-1 text-xs mt-2">
|
||||
<span>Chunks gescannt: {String(genResult.total_chunks_scanned)}</span>
|
||||
<span>Controls generiert: {String(genResult.controls_generated)}</span>
|
||||
<span>Verifiziert: {String(genResult.controls_verified)}</span>
|
||||
<span>Review noetig: {String(genResult.controls_needs_review)}</span>
|
||||
<span>Zu aehnlich: {String(genResult.controls_too_close)}</span>
|
||||
<span>Duplikate: {String(genResult.controls_duplicates_found)}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(genResult.errors) && (genResult.errors as string[]).length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{(genResult.errors as string[]).slice(0, 3).map((e, i) => <p key={i}>{e}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ArrowLeft, CheckCircle2, Trash2, Pencil, SkipForward,
|
||||
ChevronLeft, Scale, BookOpen, ExternalLink, AlertTriangle,
|
||||
FileText, Clock,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, CategoryBadge, TargetAudienceBadge,
|
||||
} from './helpers'
|
||||
|
||||
// =============================================================================
|
||||
// Compact Control Panel (used on both sides of the comparison)
|
||||
// =============================================================================
|
||||
|
||||
export function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full overflow-y-auto ${highlight ? 'bg-yellow-50' : 'bg-white'}`}>
|
||||
{/* Panel Header */}
|
||||
<div className={`sticky top-0 z-10 px-4 py-3 border-b ${highlight ? 'bg-yellow-100 border-yellow-200' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{label}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mt-1 leading-snug">{ctrl.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Panel Content */}
|
||||
<div className="p-4 space-y-4 text-sm">
|
||||
{/* Objective */}
|
||||
<section>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ziel</h4>
|
||||
<p className="text-gray-700 leading-relaxed">{ctrl.objective}</p>
|
||||
</section>
|
||||
|
||||
{/* Rationale */}
|
||||
{ctrl.rationale && (
|
||||
<section>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Begruendung</h4>
|
||||
<p className="text-gray-700 leading-relaxed">{ctrl.rationale}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Source Citation (Rule 1+2) */}
|
||||
{ctrl.source_citation && (
|
||||
<section className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Scale className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span className="text-xs font-semibold text-blue-900">Gesetzliche Grundlage</span>
|
||||
</div>
|
||||
{ctrl.source_citation.source && (
|
||||
<p className="text-xs text-blue-800">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Requirements */}
|
||||
{ctrl.requirements.length > 0 && (
|
||||
<section>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Anforderungen</h4>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{ctrl.requirements.map((r, i) => (
|
||||
<li key={i} className="text-gray-700 text-xs leading-relaxed">{r}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Test Procedure */}
|
||||
{ctrl.test_procedure.length > 0 && (
|
||||
<section>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Pruefverfahren</h4>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{ctrl.test_procedure.map((s, i) => (
|
||||
<li key={i} className="text-gray-700 text-xs leading-relaxed">{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Open Anchors */}
|
||||
{ctrl.open_anchors.length > 0 && (
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<BookOpen className="w-3.5 h-3.5 text-green-700" />
|
||||
<span className="text-xs font-semibold text-green-900">Referenzen ({ctrl.open_anchors.length})</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ctrl.open_anchors.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
<ExternalLink className="w-3 h-3 text-green-600 flex-shrink-0" />
|
||||
<span className="font-medium text-green-800">{a.framework}</span>
|
||||
<span className="text-green-700">{a.ref}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{ctrl.tags.map(t => (
|
||||
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ReviewCompare — Side-by-Side Duplicate Comparison
|
||||
// =============================================================================
|
||||
|
||||
interface ReviewCompareProps {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
onEdit: () => void
|
||||
reviewIndex: number
|
||||
reviewTotal: number
|
||||
onReviewPrev: () => void
|
||||
onReviewNext: () => void
|
||||
}
|
||||
|
||||
export function ReviewCompare({
|
||||
ctrl,
|
||||
onBack,
|
||||
onReview,
|
||||
onEdit,
|
||||
reviewIndex,
|
||||
reviewTotal,
|
||||
onReviewPrev,
|
||||
onReviewNext,
|
||||
}: ReviewCompareProps) {
|
||||
const [suspectedDuplicate, setSuspectedDuplicate] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [similarity, setSimilarity] = useState<number | null>(null)
|
||||
|
||||
// Load the suspected duplicate from generation_metadata.similar_controls
|
||||
useEffect(() => {
|
||||
const loadDuplicate = async () => {
|
||||
const similarControls = ctrl.generation_metadata?.similar_controls as Array<{ control_id: string; title: string; similarity: number }> | undefined
|
||||
if (!similarControls || similarControls.length === 0) {
|
||||
setSuspectedDuplicate(null)
|
||||
setSimilarity(null)
|
||||
return
|
||||
}
|
||||
|
||||
const suspect = similarControls[0]
|
||||
setSimilarity(suspect.similarity)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(suspect.control_id)}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSuspectedDuplicate(data)
|
||||
} else {
|
||||
setSuspectedDuplicate(null)
|
||||
}
|
||||
} catch {
|
||||
setSuspectedDuplicate(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadDuplicate()
|
||||
}, [ctrl.control_id, ctrl.generation_metadata])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-semibold text-gray-900">Duplikat-Vergleich</span>
|
||||
{similarity !== null && (
|
||||
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
|
||||
{(similarity * 100).toFixed(1)}% Aehnlichkeit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-1 mr-3">
|
||||
<button onClick={onReviewPrev} disabled={reviewIndex === 0} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 font-medium">{reviewIndex + 1} / {reviewTotal}</span>
|
||||
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'approve')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />Behalten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'reject')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />Duplikat
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side-by-Side Panels */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Control to review */}
|
||||
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
|
||||
<ControlPanel ctrl={ctrl} label="Zu pruefen" highlight />
|
||||
</div>
|
||||
|
||||
{/* Right: Suspected duplicate */}
|
||||
<div className="w-1/2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
) : suspectedDuplicate ? (
|
||||
<ControlPanel ctrl={suspectedDuplicate} label="Bestehendes Control (Verdacht)" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||
Kein Duplikat-Kandidat gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ArrowLeft, ChevronLeft, SkipForward, Scale,
|
||||
} from 'lucide-react'
|
||||
import { CanonicalControl, BACKEND_URL } from './helpers'
|
||||
import { ControlPanel } from './ReviewCompare'
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface V1CompareViewProps {
|
||||
v1Control: CanonicalControl
|
||||
matches: V1Match[]
|
||||
onBack: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
}
|
||||
|
||||
export function V1CompareView({ v1Control, matches, onBack, onNavigateToControl }: V1CompareViewProps) {
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
|
||||
const [matchedControl, setMatchedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const currentMatch = matches[currentMatchIndex]
|
||||
|
||||
// Load the full matched control when index changes
|
||||
useEffect(() => {
|
||||
if (!currentMatch) return
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(currentMatch.matched_control_id)}`)
|
||||
if (res.ok) {
|
||||
setMatchedControl(await res.json())
|
||||
} else {
|
||||
setMatchedControl(null)
|
||||
}
|
||||
} catch {
|
||||
setMatchedControl(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [currentMatch])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm font-semibold text-gray-900">V1-Vergleich</span>
|
||||
{currentMatch && (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
currentMatch.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||
currentMatch.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{(currentMatch.similarity_score * 100).toFixed(1)}% Aehnlichkeit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentMatchIndex(Math.max(0, currentMatchIndex - 1))}
|
||||
disabled={currentMatchIndex === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{currentMatchIndex + 1} / {matches.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentMatchIndex(Math.min(matches.length - 1, currentMatchIndex + 1))}
|
||||
disabled={currentMatchIndex >= matches.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigate to matched control */}
|
||||
{onNavigateToControl && matchedControl && (
|
||||
<button
|
||||
onClick={() => { onBack(); onNavigateToControl(matchedControl.control_id) }}
|
||||
className="px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50"
|
||||
>
|
||||
Zum Control
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source info bar */}
|
||||
{currentMatch && (currentMatch.matched_source || currentMatch.matched_article) && (
|
||||
<div className="px-6 py-2 bg-blue-50 border-b border-blue-200 flex items-center gap-2 text-sm">
|
||||
<Scale className="w-3.5 h-3.5 text-blue-600" />
|
||||
{currentMatch.matched_source && (
|
||||
<span className="font-semibold text-blue-900">{currentMatch.matched_source}</span>
|
||||
)}
|
||||
{currentMatch.matched_article && (
|
||||
<span className="text-blue-700">{currentMatch.matched_article}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side-by-Side Panels */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: V1 Eigenentwicklung */}
|
||||
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
|
||||
<ControlPanel ctrl={v1Control} label="Eigenentwicklung" highlight />
|
||||
</div>
|
||||
|
||||
{/* Right: Regulatory match */}
|
||||
<div className="w-1/2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
) : matchedControl ? (
|
||||
<ControlPanel ctrl={matchedControl} label="Regulatorisch gedeckt" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||
Control konnte nicht geladen werden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
408
admin-compliance/app/sdk/control-library/components/helpers.tsx
Normal file
408
admin-compliance/app/sdk/control-library/components/helpers.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface OpenAnchor {
|
||||
framework: string
|
||||
ref: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
type: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CanonicalControl {
|
||||
id: string
|
||||
framework_id: string
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
scope: {
|
||||
platforms?: string[]
|
||||
components?: string[]
|
||||
data_classes?: string[]
|
||||
}
|
||||
requirements: string[]
|
||||
test_procedure: string[]
|
||||
evidence: (EvidenceItem | string)[]
|
||||
severity: string
|
||||
risk_score: number | null
|
||||
implementation_effort: string | null
|
||||
evidence_confidence: number | null
|
||||
open_anchors: OpenAnchor[]
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule?: number | null
|
||||
source_original_text?: string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
customer_visible?: boolean
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
evidence_type: string | null
|
||||
target_audience: string | string[] | null
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
generation_strategy?: string | null
|
||||
parent_control_uuid?: string | null
|
||||
parent_control_id?: string | null
|
||||
parent_control_title?: string | null
|
||||
decomposition_method?: string | null
|
||||
pipeline_version?: number | string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
id: string
|
||||
framework_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const BACKEND_URL = '/api/sdk/v1/canonical'
|
||||
|
||||
export const SEVERITY_CONFIG: Record<string, { bg: string; label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch', icon: AlertTriangle },
|
||||
high: { bg: 'bg-orange-100 text-orange-800', label: 'Hoch', icon: AlertTriangle },
|
||||
medium: { bg: 'bg-yellow-100 text-yellow-800', label: 'Mittel', icon: Info },
|
||||
low: { bg: 'bg-green-100 text-green-800', label: 'Niedrig', icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
export const EFFORT_LABELS: Record<string, string> = {
|
||||
s: 'Klein (S)',
|
||||
m: 'Mittel (M)',
|
||||
l: 'Gross (L)',
|
||||
xl: 'Sehr gross (XL)',
|
||||
}
|
||||
|
||||
export const EMPTY_CONTROL = {
|
||||
framework_id: 'bp_security_v1',
|
||||
control_id: '',
|
||||
title: '',
|
||||
objective: '',
|
||||
rationale: '',
|
||||
scope: { platforms: [] as string[], components: [] as string[], data_classes: [] as string[] },
|
||||
requirements: [''],
|
||||
test_procedure: [''],
|
||||
evidence: [{ type: '', description: '' }],
|
||||
severity: 'medium',
|
||||
risk_score: null as number | null,
|
||||
implementation_effort: 'm' as string | null,
|
||||
open_anchors: [{ framework: '', ref: '', url: '' }],
|
||||
release_state: 'draft',
|
||||
tags: [] as string[],
|
||||
verification_method: null as string | null,
|
||||
category: null as string | null,
|
||||
evidence_type: null as string | null,
|
||||
target_audience: null as string | null,
|
||||
}
|
||||
|
||||
export const DOMAIN_OPTIONS = [
|
||||
{ value: 'AUTH', label: 'AUTH — Authentifizierung' },
|
||||
{ value: 'CRYPT', label: 'CRYPT — Kryptographie' },
|
||||
{ value: 'NET', label: 'NET — Netzwerk' },
|
||||
{ value: 'DATA', label: 'DATA — Datenschutz' },
|
||||
{ value: 'LOG', label: 'LOG — Logging' },
|
||||
{ value: 'ACC', label: 'ACC — Zugriffskontrolle' },
|
||||
{ value: 'SEC', label: 'SEC — Sicherheit' },
|
||||
{ value: 'INC', label: 'INC — Incident Response' },
|
||||
{ value: 'AI', label: 'AI — Kuenstliche Intelligenz' },
|
||||
{ value: 'COMP', label: 'COMP — Compliance' },
|
||||
]
|
||||
|
||||
export const VERIFICATION_METHODS: Record<string, { bg: string; label: string }> = {
|
||||
code_review: { bg: 'bg-blue-100 text-blue-700', label: 'Code Review' },
|
||||
document: { bg: 'bg-amber-100 text-amber-700', label: 'Dokument' },
|
||||
tool: { bg: 'bg-teal-100 text-teal-700', label: 'Tool' },
|
||||
hybrid: { bg: 'bg-purple-100 text-purple-700', label: 'Hybrid' },
|
||||
}
|
||||
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ value: 'encryption', label: 'Verschluesselung & Kryptographie' },
|
||||
{ value: 'authentication', label: 'Authentisierung & Zugriffskontrolle' },
|
||||
{ value: 'network', label: 'Netzwerksicherheit' },
|
||||
{ value: 'data_protection', label: 'Datenschutz & Datensicherheit' },
|
||||
{ value: 'logging', label: 'Logging & Monitoring' },
|
||||
{ value: 'incident', label: 'Vorfallmanagement' },
|
||||
{ value: 'continuity', label: 'Notfall & Wiederherstellung' },
|
||||
{ value: 'compliance', label: 'Compliance & Audit' },
|
||||
{ value: 'supply_chain', label: 'Lieferkettenmanagement' },
|
||||
{ value: 'physical', label: 'Physische Sicherheit' },
|
||||
{ value: 'personnel', label: 'Personal & Schulung' },
|
||||
{ value: 'application', label: 'Anwendungssicherheit' },
|
||||
{ value: 'system', label: 'Systemhaertung & -betrieb' },
|
||||
{ value: 'risk', label: 'Risikomanagement' },
|
||||
{ value: 'governance', label: 'Sicherheitsorganisation' },
|
||||
{ value: 'hardware', label: 'Hardware & Plattformsicherheit' },
|
||||
{ value: 'identity', label: 'Identitaetsmanagement' },
|
||||
]
|
||||
|
||||
export const EVIDENCE_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
code: { bg: 'bg-sky-100 text-sky-700', label: 'Code' },
|
||||
process: { bg: 'bg-amber-100 text-amber-700', label: 'Prozess' },
|
||||
hybrid: { bg: 'bg-violet-100 text-violet-700', label: 'Hybrid' },
|
||||
}
|
||||
|
||||
export const EVIDENCE_TYPE_OPTIONS = [
|
||||
{ value: 'code', label: 'Code — Technisch (Source Code, IaC, CI/CD)' },
|
||||
{ value: 'process', label: 'Prozess — Organisatorisch (Dokumente, Policies)' },
|
||||
{ value: 'hybrid', label: 'Hybrid — Code + Prozess' },
|
||||
]
|
||||
|
||||
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
|
||||
// Legacy English keys
|
||||
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
authority: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
|
||||
provider: { bg: 'bg-violet-100 text-violet-700', label: 'Anbieter' },
|
||||
all: { bg: 'bg-gray-100 text-gray-700', label: 'Alle' },
|
||||
// German keys from LLM generation
|
||||
unternehmen: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
behoerden: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
|
||||
entwickler: { bg: 'bg-sky-100 text-sky-700', label: 'Entwickler' },
|
||||
datenschutzbeauftragte: { bg: 'bg-purple-100 text-purple-700', label: 'DSB' },
|
||||
geschaeftsfuehrung: { bg: 'bg-amber-100 text-amber-700', label: 'GF' },
|
||||
'it-abteilung': { bg: 'bg-blue-100 text-blue-700', label: 'IT' },
|
||||
rechtsabteilung: { bg: 'bg-fuchsia-100 text-fuchsia-700', label: 'Recht' },
|
||||
'compliance-officer': { bg: 'bg-indigo-100 text-indigo-700', label: 'Compliance' },
|
||||
personalwesen: { bg: 'bg-pink-100 text-pink-700', label: 'Personal' },
|
||||
einkauf: { bg: 'bg-lime-100 text-lime-700', label: 'Einkauf' },
|
||||
produktion: { bg: 'bg-orange-100 text-orange-700', label: 'Produktion' },
|
||||
vertrieb: { bg: 'bg-teal-100 text-teal-700', label: 'Vertrieb' },
|
||||
gesundheitswesen: { bg: 'bg-red-100 text-red-700', label: 'Gesundheit' },
|
||||
finanzwesen: { bg: 'bg-emerald-100 text-emerald-700', label: 'Finanzen' },
|
||||
oeffentlicher_dienst: { bg: 'bg-rose-100 text-rose-700', label: 'Oeffentl. Dienst' },
|
||||
}
|
||||
|
||||
export const COLLECTION_OPTIONS = [
|
||||
{ value: 'bp_compliance_ce', label: 'CE (OWASP, ENISA, BSI)' },
|
||||
{ value: 'bp_compliance_gesetze', label: 'Gesetze (EU, DE, BSI)' },
|
||||
{ value: 'bp_compliance_datenschutz', label: 'Datenschutz' },
|
||||
{ value: 'bp_compliance_recht', label: 'Recht' },
|
||||
{ value: 'bp_dsfa_corpus', label: 'DSFA Corpus' },
|
||||
{ value: 'bp_legal_templates', label: 'Legal Templates' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// BADGE COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.medium
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StateBadge({ state }: { state: string }) {
|
||||
const config: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
deprecated: 'bg-red-100 text-red-600',
|
||||
needs_review: 'bg-yellow-100 text-yellow-800',
|
||||
too_close: 'bg-red-100 text-red-700',
|
||||
duplicate: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
needs_review: 'Review noetig',
|
||||
too_close: 'Zu aehnlich',
|
||||
duplicate: 'Duplikat',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config[state] || config.draft}`}>
|
||||
{labels[state] || state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||
if (!rule) return null
|
||||
const config: Record<number, { bg: string; label: string }> = {
|
||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
||||
}
|
||||
const c = config[rule]
|
||||
if (!c) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
}
|
||||
|
||||
export function VerificationMethodBadge({ method }: { method: string | null }) {
|
||||
if (!method) return null
|
||||
const config = VERIFICATION_METHODS[method]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function CategoryBadge({ category }: { category: string | null }) {
|
||||
if (!category) return null
|
||||
const opt = CATEGORY_OPTIONS.find(c => c.value === category)
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-50 text-indigo-700">
|
||||
{opt?.label || category}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function EvidenceTypeBadge({ type }: { type: string | null }) {
|
||||
if (!type) return null
|
||||
const config = EVIDENCE_TYPE_CONFIG[type]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
|
||||
if (!audience) return null
|
||||
|
||||
// Parse JSON array string from DB (e.g. '["unternehmen", "einkauf"]')
|
||||
let items: string[] = []
|
||||
if (typeof audience === 'string') {
|
||||
if (audience.startsWith('[')) {
|
||||
try { items = JSON.parse(audience) } catch { items = [audience] }
|
||||
} else {
|
||||
items = [audience]
|
||||
}
|
||||
} else if (Array.isArray(audience)) {
|
||||
items = audience
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||
{items.map((item, i) => {
|
||||
const config = TARGET_AUDIENCE_OPTIONS[item]
|
||||
if (!config) return <span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">{item}</span>
|
||||
return <span key={i} className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CanonicalControlPipelineInfo {
|
||||
pipeline_version?: number | string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
parent_control_uuid?: string | null
|
||||
}
|
||||
|
||||
export function isEigenentwicklung(ctrl: CanonicalControlPipelineInfo & { generation_strategy?: string | null }): boolean {
|
||||
return (
|
||||
(!ctrl.generation_strategy || ctrl.generation_strategy === 'ungrouped') &&
|
||||
(!ctrl.pipeline_version || String(ctrl.pipeline_version) === '1') &&
|
||||
!ctrl.source_citation &&
|
||||
!ctrl.parent_control_uuid
|
||||
)
|
||||
}
|
||||
|
||||
export function GenerationStrategyBadge({ strategy, pipelineInfo }: {
|
||||
strategy: string | null | undefined
|
||||
pipelineInfo?: CanonicalControlPipelineInfo & { generation_strategy?: string | null }
|
||||
}) {
|
||||
// Eigenentwicklung detection: v1 + no source + no parent
|
||||
if (pipelineInfo && isEigenentwicklung(pipelineInfo)) {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-700">Eigenentwicklung</span>
|
||||
}
|
||||
if (!strategy || strategy === 'ungrouped') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">v1</span>
|
||||
}
|
||||
if (strategy === 'document_grouped') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">v2</span>
|
||||
}
|
||||
if (strategy === 'phase74_gap_fill') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">v5 Gap</span>
|
||||
}
|
||||
if (strategy === 'pass0b_atomic' || strategy === 'pass0b') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">Atomar</span>
|
||||
}
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">{strategy}</span>
|
||||
}
|
||||
|
||||
export const OBLIGATION_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
pflicht: { bg: 'bg-red-100 text-red-700', label: 'Pflicht' },
|
||||
empfehlung: { bg: 'bg-amber-100 text-amber-700', label: 'Empfehlung' },
|
||||
kann: { bg: 'bg-green-100 text-green-700', label: 'Kann' },
|
||||
}
|
||||
|
||||
export function ObligationTypeBadge({ type }: { type: string | null | undefined }) {
|
||||
if (!type) return null
|
||||
const config = OBLIGATION_TYPE_CONFIG[type]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROVENANCE TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ObligationInfo {
|
||||
candidate_id: string
|
||||
obligation_text: string
|
||||
action: string | null
|
||||
object: string | null
|
||||
normative_strength: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
export interface DocumentReference {
|
||||
regulation_code: string
|
||||
article: string | null
|
||||
paragraph: string | null
|
||||
extraction_method: string
|
||||
confidence: number | null
|
||||
}
|
||||
|
||||
export interface MergedDuplicate {
|
||||
control_id: string
|
||||
title: string
|
||||
source_regulation: string | null
|
||||
}
|
||||
|
||||
export interface RegulationSummary {
|
||||
regulation_code: string
|
||||
articles: string[]
|
||||
link_types: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROVENANCE BADGES
|
||||
// =============================================================================
|
||||
|
||||
const EXTRACTION_METHOD_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
exact_match: { bg: 'bg-green-100 text-green-700', label: 'Exakt' },
|
||||
embedding_match: { bg: 'bg-blue-100 text-blue-700', label: 'Embedding' },
|
||||
llm_extracted: { bg: 'bg-violet-100 text-violet-700', label: 'LLM' },
|
||||
inferred: { bg: 'bg-gray-100 text-gray-600', label: 'Abgeleitet' },
|
||||
}
|
||||
|
||||
export function ExtractionMethodBadge({ method }: { method: string }) {
|
||||
const config = EXTRACTION_METHOD_CONFIG[method] || EXTRACTION_METHOD_CONFIG.inferred
|
||||
return <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function RegulationCountBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">
|
||||
{count} {count === 1 ? 'Regulierung' : 'Regulierungen'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
1000
admin-compliance/app/sdk/control-library/page.tsx
Normal file
1000
admin-compliance/app/sdk/control-library/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
739
admin-compliance/app/sdk/control-provenance/page.tsx
Normal file
739
admin-compliance/app/sdk/control-provenance/page.tsx
Normal file
@@ -0,0 +1,739 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Shield, BookOpen, ExternalLink, CheckCircle2, AlertTriangle,
|
||||
Lock, Scale, FileText, Eye, ArrowLeft,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface LicenseInfo {
|
||||
license_id: string
|
||||
name: string
|
||||
terms_url: string | null
|
||||
commercial_use: string
|
||||
ai_training_restriction: string | null
|
||||
tdm_allowed_under_44b: string | null
|
||||
deletion_required: boolean
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
interface SourceInfo {
|
||||
source_id: string
|
||||
title: string
|
||||
publisher: string
|
||||
url: string | null
|
||||
version_label: string | null
|
||||
language: string
|
||||
license_id: string
|
||||
license_name: string
|
||||
commercial_use: string
|
||||
allowed_analysis: boolean
|
||||
allowed_store_excerpt: boolean
|
||||
allowed_ship_embeddings: boolean
|
||||
allowed_ship_in_product: boolean
|
||||
vault_retention_days: number
|
||||
vault_access_tier: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATIC PROVENANCE DOCUMENTATION
|
||||
// =============================================================================
|
||||
|
||||
const PROVENANCE_SECTIONS = [
|
||||
{
|
||||
id: 'methodology',
|
||||
title: 'Methodik der Control-Erstellung',
|
||||
content: `## Unabhaengige Formulierung
|
||||
|
||||
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
|
||||
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
|
||||
aus geschuetzten Quellen uebernommen.
|
||||
|
||||
### Dreistufiger Prozess
|
||||
|
||||
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
|
||||
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
|
||||
|
||||
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
|
||||
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
|
||||
|
||||
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
|
||||
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
|
||||
Status PASS oder WARN (+ Human Review) werden freigegeben.
|
||||
|
||||
### Rechtliche Grundlage
|
||||
|
||||
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
|
||||
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
|
||||
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
|
||||
ausschliesslich als Analysegrundlage, nicht im Produkt`,
|
||||
},
|
||||
{
|
||||
id: 'filters',
|
||||
title: 'Filter in der Control Library',
|
||||
content: `## Dropdown-Filter
|
||||
|
||||
Die Control Library bietet 7 Filter-Dropdowns, um die ueber 3.000 Controls effizient zu durchsuchen:
|
||||
|
||||
### Schweregrad (Severity)
|
||||
|
||||
| Stufe | Farbe | Bedeutung |
|
||||
|-------|-------|-----------|
|
||||
| **Kritisch** | Rot | Sicherheitskritische Controls — Verstoesse fuehren zu schwerwiegenden Risiken |
|
||||
| **Hoch** | Orange | Wichtige Controls — sollten zeitnah umgesetzt werden |
|
||||
| **Mittel** | Gelb | Standardmaessige Controls — empfohlene Umsetzung |
|
||||
| **Niedrig** | Gruen | Nice-to-have Controls — zusaetzliche Haertung |
|
||||
|
||||
### Domain
|
||||
|
||||
Das Praefix der Control-ID (z.B. \`AUTH-001\`, \`SEC-042\`). Kennzeichnet den thematischen Bereich.
|
||||
Die haeufigsten Domains:
|
||||
|
||||
| Domain | Anzahl | Thema |
|
||||
|--------|--------|-------|
|
||||
| SEC | ~700 | Allgemeine Sicherheit, Systemhaertung |
|
||||
| COMP | ~470 | Compliance, Regulierung, Nachweispflichten |
|
||||
| DATA | ~400 | Datenschutz, Datenklassifizierung, DSGVO |
|
||||
| AI | ~290 | KI-Regulierung (AI Act, Transparenz, Erklaerbarkeit) |
|
||||
| LOG | ~230 | Logging, Monitoring, SIEM |
|
||||
| AUTH | ~200 | Authentifizierung, Zugriffskontrolle |
|
||||
| NET | ~150 | Netzwerksicherheit, Transport, Firewall |
|
||||
| CRYP | ~90 | Kryptographie, Schluesselmanagement |
|
||||
| ACC | ~25 | Zugriffskontrolle (Access Control) |
|
||||
| INC | ~25 | Incident Response, Vorfallmanagement |
|
||||
|
||||
Zusaetzlich existieren spezialisierte Domains wie CRA, ARC (Architektur), API, PKI, SUP (Supply Chain) u.v.m.
|
||||
|
||||
### Status (Release State)
|
||||
|
||||
| Status | Bedeutung |
|
||||
|--------|-----------|
|
||||
| **Draft** | Entwurf — noch nicht freigegeben |
|
||||
| **Approved** | Freigegeben fuer Kunden |
|
||||
| **Review noetig** | Muss manuell geprueft werden |
|
||||
| **Zu aehnlich** | Too-Close-Check hat Warnung ausgeloest |
|
||||
| **Duplikat** | Wurde als Duplikat eines anderen Controls erkannt |
|
||||
|
||||
### Nachweis (Verification Method)
|
||||
|
||||
| Methode | Farbe | Beschreibung |
|
||||
|---------|-------|-------------|
|
||||
| **Code Review** | Blau | Nachweis durch Quellcode-Inspektion |
|
||||
| **Dokument** | Amber | Nachweis durch Richtlinien, Prozesse, Schulungen |
|
||||
| **Tool** | Teal | Nachweis durch automatisierte Scans/Monitoring |
|
||||
| **Hybrid** | Lila | Kombination aus mehreren Methoden |
|
||||
|
||||
### Kategorie
|
||||
|
||||
Thematische Einordnung (17 Kategorien). Kategorien sind **thematisch**, Domains **strukturell**.
|
||||
Ein AUTH-Control kann z.B. die Kategorie "Netzwerksicherheit" haben.
|
||||
|
||||
### Zielgruppe (Target Audience)
|
||||
|
||||
| Zielgruppe | Bedeutung |
|
||||
|------------|-----------|
|
||||
| **Unternehmen** | Fuer Endkunden/Firmen relevant |
|
||||
| **Behoerden** | Spezifisch fuer oeffentliche Verwaltung |
|
||||
| **Anbieter** | Fuer SaaS/Plattform-Anbieter |
|
||||
| **Alle** | Allgemein anwendbar |
|
||||
|
||||
### Dokumentenursprung (Source)
|
||||
|
||||
Filtert nach der Quelldokument-Herkunft des Controls. Zeigt alle Quellen sortiert nach
|
||||
Haeufigkeit. Die wichtigsten Quellen:
|
||||
|
||||
| Quelle | Typ |
|
||||
|--------|-----|
|
||||
| KI-Verordnung (EU) 2024/1689 | EU-Recht |
|
||||
| Cyber Resilience Act (EU) 2024/2847 | EU-Recht |
|
||||
| DSGVO (EU) 2016/679 | EU-Recht |
|
||||
| NIS2-Richtlinie (EU) 2022/2555 | EU-Recht |
|
||||
| NIST SP 800-53, CSF 2.0, SSDF | US-Standards |
|
||||
| OWASP Top 10, ASVS, SAMM | Open Source |
|
||||
| ENISA Guidelines | EU-Agentur |
|
||||
| CISA Secure by Design | US-Behoerde |
|
||||
| BDSG, TKG, GewO, HGB | Deutsche Gesetze |
|
||||
| EDPB Leitlinien | EU Datenschutz |`,
|
||||
},
|
||||
{
|
||||
id: 'badges',
|
||||
title: 'Badges & Lizenzregeln',
|
||||
content: `## Badges in der Control Library
|
||||
|
||||
Jedes Control zeigt mehrere farbige Badges:
|
||||
|
||||
### Lizenzregel-Badge (Rule 1 / 2 / 3)
|
||||
|
||||
Die Lizenzregel bestimmt, wie ein Control erstellt und genutzt werden darf:
|
||||
|
||||
| Badge | Farbe | Regel | Bedeutung |
|
||||
|-------|-------|-------|-----------|
|
||||
| **Free Use** | Gruen | Rule 1 | Quelle ist Public Domain oder EU-Recht — Originaltext darf gezeigt werden |
|
||||
| **Zitation** | Blau | Rule 2 | Quelle ist CC-BY oder aehnlich — Zitation + Quellenangabe erforderlich |
|
||||
| **Reformuliert** | Amber | Rule 3 | Quelle hat eingeschraenkte Lizenz — Control wurde eigenstaendig reformuliert, kein Originaltext |
|
||||
|
||||
### Processing-Path
|
||||
|
||||
| Pfad | Bedeutung |
|
||||
|------|-----------|
|
||||
| **structured** | Control wurde direkt aus strukturierten Daten (Tabellen, Listen) extrahiert |
|
||||
| **llm_reform** | Control wurde mit LLM eigenstaendig formuliert (bei Rule 3 zwingend) |
|
||||
|
||||
### Referenzen (Open Anchors)
|
||||
|
||||
Zeigt die Anzahl der verlinkten Open-Source-Referenzen (OWASP, NIST, ENISA etc.).
|
||||
Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
|
||||
|
||||
### Weitere Badges
|
||||
|
||||
| Badge | Bedeutung |
|
||||
|-------|-----------|
|
||||
| Score | Risiko-Score (0-10) |
|
||||
| Severity-Badge | Schweregrad (Kritisch/Hoch/Mittel/Niedrig) |
|
||||
| State-Badge | Freigabestatus (Draft/Approved/etc.) |
|
||||
| Kategorie-Badge | Thematische Kategorie |
|
||||
| Zielgruppe-Badge | Enterprise/Behoerden/Anbieter/Alle |`,
|
||||
},
|
||||
{
|
||||
id: 'taxonomy',
|
||||
title: 'Unabhaengige Taxonomie',
|
||||
content: `## Eigenes Klassifikationssystem
|
||||
|
||||
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
|
||||
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
|
||||
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
|
||||
|
||||
### Top-10 Domains
|
||||
|
||||
| Domain | Anzahl | Thema | Hauptquellen |
|
||||
|--------|--------|-------|-------------|
|
||||
| SEC | ~700 | Allgemeine Sicherheit | CRA, NIS2, BSI, ENISA |
|
||||
| COMP | ~470 | Compliance & Regulierung | DSGVO, AI Act, Richtlinien |
|
||||
| DATA | ~400 | Datenschutz & Datenklassifizierung | DSGVO, BDSG, EDPB |
|
||||
| AI | ~290 | KI-Regulierung & Ethik | AI Act, HLEG, OECD |
|
||||
| LOG | ~230 | Logging & Monitoring | NIST, OWASP |
|
||||
| AUTH | ~200 | Authentifizierung & Session | NIST SP 800-63, OWASP |
|
||||
| NET | ~150 | Netzwerksicherheit | NIST, ENISA |
|
||||
| CRYP | ~90 | Kryptographie & Schluessel | NIST SP 800-57 |
|
||||
| ACC | ~25 | Zugriffskontrolle | OWASP ASVS |
|
||||
| INC | ~25 | Incident Response | NIS2, CRA |
|
||||
|
||||
### Spezialisierte Domains
|
||||
|
||||
Neben den Top-10 gibt es ueber 90 weitere Domains fuer spezifische Themen:
|
||||
|
||||
- **CRA** — Cyber Resilience Act spezifisch
|
||||
- **ARC** — Sichere Architektur
|
||||
- **API** — API-Security
|
||||
- **PKI** — Public Key Infrastructure
|
||||
- **SUP** — Supply Chain Security
|
||||
- **VUL** — Vulnerability Management
|
||||
- **BCP** — Business Continuity
|
||||
- **PHY** — Physische Sicherheit
|
||||
- u.v.m.
|
||||
|
||||
### ID-Format
|
||||
|
||||
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
|
||||
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
|
||||
allgemein ueblichen Nummerierungsschema.`,
|
||||
},
|
||||
{
|
||||
id: 'open-sources',
|
||||
title: 'Offene Referenzquellen',
|
||||
content: `## Primaere offene Quellen
|
||||
|
||||
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
|
||||
|
||||
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
|
||||
- **ASVS** — Application Security Verification Standard v4.0.3
|
||||
- **MASVS** — Mobile Application Security Verification Standard v2.1
|
||||
- **Top 10** — OWASP Top 10 (2021)
|
||||
- **Cheat Sheets** — OWASP Cheat Sheet Series
|
||||
- **SAMM** — Software Assurance Maturity Model
|
||||
|
||||
### NIST (Public Domain — keine Einschraenkungen)
|
||||
- **SP 800-53 Rev.5** — Security and Privacy Controls
|
||||
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
|
||||
- **SP 800-57** — Key Management Recommendations
|
||||
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
|
||||
- **SP 800-92** — Log Management Guide
|
||||
- **SP 800-218 (SSDF)** — Secure Software Development Framework
|
||||
- **SP 800-60** — Information Types to Security Categories
|
||||
|
||||
### ENISA (CC BY 4.0 — kommerziell erlaubt)
|
||||
- Good Practices for IoT/Mobile Security
|
||||
- Data Protection Engineering
|
||||
- Algorithms, Key Sizes and Parameters Report
|
||||
|
||||
### Weitere offene Quellen
|
||||
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
|
||||
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
|
||||
},
|
||||
{
|
||||
id: 'restricted-sources',
|
||||
title: 'Geschuetzte Quellen — Nur interne Analyse',
|
||||
content: `## Quellen mit eingeschraenkter Nutzung
|
||||
|
||||
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
|
||||
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
|
||||
|
||||
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
|
||||
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
|
||||
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
|
||||
- Kein Shipping von Zitaten, Embeddings oder Strukturen
|
||||
|
||||
### ISO/IEC (Kostenpflichtig — kein Shipping)
|
||||
- ISO 27001, ISO 27002
|
||||
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
|
||||
|
||||
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
|
||||
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
|
||||
|
||||
### Trennungsprinzip
|
||||
|
||||
| Ebene | Geschuetzte Quelle | Offene Quelle |
|
||||
|-------|--------------------|---------------|
|
||||
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
|
||||
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
|
||||
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
|
||||
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
|
||||
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
|
||||
},
|
||||
{
|
||||
id: 'verification-methods',
|
||||
title: 'Verifikationsmethoden',
|
||||
content: `## Nachweis-Klassifizierung
|
||||
|
||||
Jedes Control wird einer von vier Verifikationsmethoden zugeordnet. Dies bestimmt,
|
||||
**wie** ein Kunde den Nachweis fuer die Einhaltung erbringen kann:
|
||||
|
||||
| Methode | Beschreibung | Beispiele |
|
||||
|---------|-------------|-----------|
|
||||
| **Code Review** | Nachweis durch Quellcode-Inspektion | Input-Validierung, Encryption-Konfiguration, Auth-Logic |
|
||||
| **Dokument** | Nachweis durch Richtlinien, Prozesse, Schulungen | Notfallplaene, Schulungsnachweise, Datenschutzkonzepte |
|
||||
| **Tool** | Nachweis durch automatisierte Tools/Scans | SIEM-Logs, Vulnerability-Scans, Monitoring-Dashboards |
|
||||
| **Hybrid** | Kombination aus mehreren Methoden | Zugriffskontrollen (Code + Policy + Tool) |
|
||||
|
||||
### Bedeutung fuer Kunden
|
||||
|
||||
- **Code Review Controls** koennen direkt im SDK-Scan geprueft werden
|
||||
- **Dokument Controls** erfordern manuelle Uploads (PDFs, Links)
|
||||
- **Tool Controls** koennen per API-Integration automatisch nachgewiesen werden
|
||||
- **Hybrid Controls** benoetigen mehrere Nachweisarten`,
|
||||
},
|
||||
{
|
||||
id: 'categories',
|
||||
title: 'Thematische Kategorien',
|
||||
content: `## 17 Sicherheitskategorien
|
||||
|
||||
Controls sind in thematische Kategorien gruppiert, um Kunden eine
|
||||
uebersichtliche Navigation zu ermoeglichen:
|
||||
|
||||
| Kategorie | Beschreibung |
|
||||
|-----------|-------------|
|
||||
| Verschluesselung & Kryptographie | TLS, Key Management, Algorithmen |
|
||||
| Authentisierung & Zugriffskontrolle | Login, MFA, RBAC, Session-Management |
|
||||
| Netzwerksicherheit | Firewall, Segmentierung, VPN, DNS |
|
||||
| Datenschutz & Datensicherheit | DSGVO, Datenklassifizierung, Anonymisierung |
|
||||
| Logging & Monitoring | SIEM, Audit-Logs, Alerting |
|
||||
| Vorfallmanagement | Incident Response, Meldepflichten |
|
||||
| Notfall & Wiederherstellung | BCM, Disaster Recovery, Backups |
|
||||
| Compliance & Audit | Zertifizierungen, Audits, Berichtspflichten |
|
||||
| Lieferkettenmanagement | Vendor Risk, SBOM, Third-Party |
|
||||
| Physische Sicherheit | Zutritt, Gebaeudesicherheit |
|
||||
| Personal & Schulung | Security Awareness, Rollenkonzepte |
|
||||
| Anwendungssicherheit | SAST, DAST, Secure Coding |
|
||||
| Systemhaertung & -betrieb | Patching, Konfiguration, Hardening |
|
||||
| Risikomanagement | Risikoanalyse, Bewertung, Massnahmen |
|
||||
| Sicherheitsorganisation | ISMS, Richtlinien, Governance |
|
||||
| Hardware & Plattformsicherheit | TPM, Secure Boot, Firmware |
|
||||
| Identitaetsmanagement | SSO, Federation, Directory |
|
||||
|
||||
### Abgrenzung zu Domains
|
||||
|
||||
Kategorien sind **thematisch**, Domains (AUTH, NET, etc.) sind **strukturell**.
|
||||
Ein Control AUTH-005 (Domain AUTH) hat die Kategorie "authentication",
|
||||
aber ein Control NET-012 (Domain NET) koennte ebenfalls die Kategorie
|
||||
"authentication" haben, wenn es um Netzwerk-Authentifizierung geht.`,
|
||||
},
|
||||
{
|
||||
id: 'master-library',
|
||||
title: 'Master Library Strategie',
|
||||
content: `## RAG-First Ansatz
|
||||
|
||||
Die Canonical Control Library folgt einer **RAG-First-Strategie**:
|
||||
|
||||
### Schritt 1: Rule 1+2 Controls aus RAG generieren
|
||||
|
||||
Prioritaet haben Controls aus Quellen mit **Originaltext-Erlaubnis**:
|
||||
|
||||
| Welle | Quellen | Lizenzregel | Vorteil |
|
||||
|-------|---------|------------|---------|
|
||||
| 1 | OWASP (ASVS, MASVS, Top10) | Rule 2 (CC-BY-SA, Zitation) | Originaltext + Zitation |
|
||||
| 2 | NIST (SP 800-53, CSF, SSDF) | Rule 1 (Public Domain) | Voller Text, keine Einschraenkungen |
|
||||
| 3 | EU-Verordnungen (DSGVO, AI Act, NIS2, CRA) | Rule 1 (EU Law) | Gesetzestext + Erklaerung |
|
||||
| 4 | Deutsche Gesetze (BDSG, TTDSG, TKG) | Rule 1 (DE Law) | Gesetzestext + Erklaerung |
|
||||
|
||||
### Schritt 2: Dedup gegen BSI Rule-3 Controls
|
||||
|
||||
Die ~880 BSI Rule-3 Controls werden **gegen** die neuen Rule 1+2 Controls abgeglichen:
|
||||
|
||||
- Wenn ein BSI-Control ein Duplikat eines OWASP/NIST-Controls ist → **OWASP/NIST bevorzugt**
|
||||
(weil Originaltext + Zitation erlaubt)
|
||||
- BSI-Duplikate werden als \`deprecated\` markiert
|
||||
- Tags und Anchors werden in den behaltenen Control zusammengefuehrt
|
||||
|
||||
### Schritt 3: Aktueller Stand
|
||||
|
||||
Aktuell: **~3.100+ Controls** (Stand Maerz 2026), davon:
|
||||
- Viele mit \`source_original_text\` (Originaltext fuer Kunden sichtbar)
|
||||
- Viele mit \`source_citation\` (Quellenangabe mit Lizenz)
|
||||
- Klare Nachweismethode (\`verification_method\`)
|
||||
- Thematische Kategorie (\`category\`)
|
||||
|
||||
### Verstaendliche Texte
|
||||
|
||||
Zusaetzlich zum Originaltext (der oft juristisch/technisch formuliert ist)
|
||||
enthaelt jedes Control ein eigenstaendig formuliertes **Ziel** (objective)
|
||||
und eine **Begruendung** (rationale) in verstaendlicher Sprache.`,
|
||||
},
|
||||
{
|
||||
id: 'validation',
|
||||
title: 'Automatisierte Validierung',
|
||||
content: `## CI/CD-Pruefungen
|
||||
|
||||
Jedes Control wird bei jedem Commit automatisch geprueft:
|
||||
|
||||
### 1. Schema-Validierung
|
||||
- Alle Pflichtfelder vorhanden
|
||||
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
|
||||
- Severity: low, medium, high, critical
|
||||
- Risk Score: 0-10
|
||||
|
||||
### 2. No-Leak Scanner
|
||||
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
|
||||
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
|
||||
- \`TR-03161\` — Direkte BSI-TR-Referenzen
|
||||
- \`BSI-TR-\` — BSI-spezifische Locators
|
||||
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
|
||||
|
||||
### 3. Open Anchor Check
|
||||
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
|
||||
|
||||
### 4. Too-Close Detektor (5 Metriken)
|
||||
|
||||
| Metrik | Warn | Fail | Beschreibung |
|
||||
|--------|------|------|-------------|
|
||||
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
|
||||
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
|
||||
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
|
||||
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
|
||||
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
|
||||
|
||||
**Entscheidungslogik:**
|
||||
- **PASS** — Kein Fail + max 1 Warn
|
||||
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
|
||||
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ControlProvenancePage() {
|
||||
const [licenses, setLicenses] = useState<LicenseInfo[]>([])
|
||||
const [sources, setSources] = useState<SourceInfo[]>([])
|
||||
const [activeSection, setActiveSection] = useState('methodology')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [licRes, srcRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/canonical?endpoint=licenses'),
|
||||
fetch('/api/sdk/v1/canonical?endpoint=sources'),
|
||||
])
|
||||
if (licRes.ok) setLicenses(await licRes.json())
|
||||
if (srcRes.ok) setSources(await srcRes.json())
|
||||
} catch {
|
||||
// silently continue — static content still shown
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const currentSection = PROVENANCE_SECTIONS.find(s => s.id === activeSection)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-6 h-6 text-green-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Control Provenance Wiki</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
Dokumentation der unabhaengigen Herkunft aller Security Controls — rechtssicherer Nachweis
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/sdk/control-library"
|
||||
className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Zur Control Library
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left: Navigation */}
|
||||
<div className="w-72 border-r border-gray-200 bg-gray-50 overflow-y-auto flex-shrink-0">
|
||||
<div className="p-3 space-y-1">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Dokumentation</p>
|
||||
{PROVENANCE_SECTIONS.map(section => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'bg-green-100 text-green-900 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{section.title}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="border-t border-gray-200 mt-3 pt-3">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Live-Daten</p>
|
||||
<button
|
||||
onClick={() => setActiveSection('license-matrix')}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeSection === 'license-matrix'
|
||||
? 'bg-green-100 text-green-900 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Lizenz-Matrix
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection('source-registry')}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeSection === 'source-registry'
|
||||
? 'bg-green-100 text-green-900 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Quellenregister
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Static documentation sections */}
|
||||
{currentSection && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{currentSection.title}</h2>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<MarkdownRenderer content={currentSection.content} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License Matrix (live data) */}
|
||||
{activeSection === 'license-matrix' && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{licenses.map(lic => (
|
||||
<tr key={lic.license_id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 border-b">
|
||||
<div className="font-medium text-gray-900">{lic.license_id}</div>
|
||||
<div className="text-xs text-gray-500">{lic.name}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<UsageBadge value={lic.commercial_use} />
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<UsageBadge value={lic.ai_training_restriction || 'n/a'} />
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} />
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
{lic.deletion_required ? (
|
||||
<span className="text-red-600 text-xs font-medium">Ja</span>
|
||||
) : (
|
||||
<span className="text-green-600 text-xs font-medium">Nein</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Registry (live data) */}
|
||||
{activeSection === 'source-registry' && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Alle registrierten Quellen mit ihren Berechtigungen.
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map(src => (
|
||||
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
|
||||
<p className="text-xs text-gray-500">{src.publisher} — {src.license_name}</p>
|
||||
</div>
|
||||
{src.url && (
|
||||
<a
|
||||
href={src.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Quelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
|
||||
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
|
||||
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
|
||||
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function UsageBadge({ value }: { value: string }) {
|
||||
const config: Record<string, { bg: string; label: string }> = {
|
||||
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
|
||||
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
|
||||
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
|
||||
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
|
||||
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
|
||||
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
|
||||
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
|
||||
}
|
||||
const c = config[value] || config.unclear
|
||||
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
}
|
||||
|
||||
function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{allowed ? (
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
) : (
|
||||
<Lock className="w-3 h-3 text-red-400" />
|
||||
)}
|
||||
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownRenderer({ content }: { content: string }) {
|
||||
let html = content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Code blocks
|
||||
html = html.replace(
|
||||
/^```[\w]*\n([\s\S]*?)^```$/gm,
|
||||
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
|
||||
)
|
||||
|
||||
// Tables
|
||||
html = html.replace(
|
||||
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
|
||||
(_m, header: string, _sep: string, body: string) => {
|
||||
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
||||
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
|
||||
).join('')
|
||||
const rows = body.trim().split('\n').map((row: string) => {
|
||||
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
||||
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
|
||||
).join('')
|
||||
return `<tr>${tds}</tr>`
|
||||
}).join('')
|
||||
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
|
||||
}
|
||||
)
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
|
||||
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
|
||||
|
||||
// Lists
|
||||
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
|
||||
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
|
||||
|
||||
// Numbered lists
|
||||
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
|
||||
|
||||
// Paragraphs
|
||||
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
@@ -196,7 +196,15 @@ function ControlCard({
|
||||
{/* Linked Evidence */}
|
||||
{control.linkedEvidence.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 mb-1 block">Nachweise:</span>
|
||||
<span className="text-xs text-gray-500 mb-1 block">
|
||||
Nachweise: {control.linkedEvidence.length}
|
||||
{(() => {
|
||||
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
|
||||
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
|
||||
).length
|
||||
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
|
||||
})()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{control.linkedEvidence.map(ev => (
|
||||
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
|
||||
@@ -205,6 +213,9 @@ function ControlCard({
|
||||
'bg-yellow-50 text-yellow-700'
|
||||
}`}>
|
||||
{ev.title}
|
||||
{(ev as { confidenceLevel?: string }).confidenceLevel && (
|
||||
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -359,6 +370,49 @@ interface RAGControlSuggestion {
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
function TransitionErrorBanner({
|
||||
controlId,
|
||||
violations,
|
||||
onDismiss,
|
||||
}: {
|
||||
controlId: string
|
||||
violations: string[]
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-800">
|
||||
Status-Transition blockiert ({controlId})
|
||||
</h4>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{violations.map((v, i) => (
|
||||
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
|
||||
<span className="text-orange-400 mt-0.5">•</span>
|
||||
<span>{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Evidence hinzufuegen →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ControlsPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const router = useRouter()
|
||||
@@ -373,6 +427,9 @@ export default function ControlsPage() {
|
||||
const [showRagPanel, setShowRagPanel] = useState(false)
|
||||
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
|
||||
|
||||
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
|
||||
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
||||
|
||||
// Track effectiveness locally as it's not in the SDK state type
|
||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||
// Track linked evidence per control
|
||||
@@ -385,7 +442,7 @@ export default function ControlsPage() {
|
||||
const data = await res.json()
|
||||
const allEvidence = data.evidence || data
|
||||
if (Array.isArray(allEvidence)) {
|
||||
const map: Record<string, { id: string; title: string; status: string }[]> = {}
|
||||
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
|
||||
for (const ev of allEvidence) {
|
||||
const ctrlId = ev.control_id || ''
|
||||
if (!map[ctrlId]) map[ctrlId] = []
|
||||
@@ -393,6 +450,7 @@ export default function ControlsPage() {
|
||||
id: ev.id,
|
||||
title: ev.title || ev.name || 'Nachweis',
|
||||
status: ev.status || 'pending',
|
||||
confidenceLevel: ev.confidence_level || undefined,
|
||||
})
|
||||
}
|
||||
setEvidenceMap(map)
|
||||
@@ -483,20 +541,56 @@ export default function ControlsPage() {
|
||||
: 0
|
||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
||||
|
||||
const handleStatusChange = async (controlId: string, status: ImplementationStatus) => {
|
||||
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
|
||||
// Remember old status for rollback
|
||||
const oldControl = state.controls.find(c => c.id === controlId)
|
||||
const oldStatus = oldControl?.implementationStatus
|
||||
|
||||
// Optimistic update
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: status } },
|
||||
payload: { id: controlId, data: { implementationStatus: newStatus } },
|
||||
})
|
||||
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ implementation_status: status }),
|
||||
body: JSON.stringify({ implementation_status: newStatus }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// Rollback optimistic update
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
|
||||
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
|
||||
|
||||
if (res.status === 409 && err.detail?.violations) {
|
||||
setTransitionError({ controlId, violations: err.detail.violations })
|
||||
} else {
|
||||
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
|
||||
setError(msg)
|
||||
}
|
||||
} else {
|
||||
// Clear any previous transition error for this control
|
||||
if (transitionError?.controlId === controlId) {
|
||||
setTransitionError(null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — SDK state is already updated
|
||||
// Network error — rollback
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
setError('Netzwerkfehler bei Status-Aenderung')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,6 +839,15 @@ export default function ControlsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
|
||||
{transitionError && (
|
||||
<TransitionErrorBanner
|
||||
controlId={transitionError.controlId}
|
||||
violations={transitionError.violations}
|
||||
onDismiss={() => setTransitionError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && !loading && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
|
||||
@@ -515,7 +515,7 @@ export function setContextPath(ctx: TemplateContext, dotPath: string, value: unk
|
||||
return {
|
||||
...ctx,
|
||||
[section]: {
|
||||
...(ctx[section] as Record<string, unknown>),
|
||||
...(ctx[section] as unknown as Record<string, unknown>),
|
||||
[key]: value,
|
||||
},
|
||||
}
|
||||
@@ -526,6 +526,6 @@ export function setContextPath(ctx: TemplateContext, dotPath: string, value: unk
|
||||
*/
|
||||
export function getContextPath(ctx: TemplateContext, dotPath: string): unknown {
|
||||
const [section, ...rest] = dotPath.split('.') as [keyof TemplateContext, ...string[]]
|
||||
const sectionObj = ctx[section] as Record<string, unknown>
|
||||
const sectionObj = ctx[section] as unknown as Record<string, unknown>
|
||||
return sectionObj?.[rest.join('.')]
|
||||
}
|
||||
|
||||
@@ -32,18 +32,28 @@ import {
|
||||
|
||||
const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
||||
{ key: 'all', label: 'Alle', types: null },
|
||||
// Legal / Vertragsvorlagen
|
||||
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
|
||||
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
|
||||
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
|
||||
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
|
||||
{ key: 'nda', label: 'NDA', types: ['nda'] },
|
||||
{ key: 'sla', label: 'SLA', types: ['sla'] },
|
||||
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
|
||||
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
|
||||
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
|
||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
||||
// Sicherheitskonzepte (Migration 051)
|
||||
{ key: 'security', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'cybersecurity_policy'] },
|
||||
// Policy-Bibliothek (Migration 071/072)
|
||||
{ key: 'it_security_policies', label: 'IT-Sicherheit Policies', types: ['information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy', 'logging_policy', 'backup_policy', 'incident_response_policy', 'change_management_policy', 'patch_management_policy', 'asset_management_policy', 'cloud_security_policy', 'devsecops_policy', 'secrets_management_policy', 'vulnerability_management_policy'] },
|
||||
{ key: 'data_policies', label: 'Daten-Policies', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
|
||||
{ key: 'hr_policies', label: 'Personal-Policies', types: ['employee_security_policy', 'security_awareness_policy', 'acceptable_use', 'remote_work_policy', 'offboarding_policy'] },
|
||||
{ key: 'vendor_policies', label: 'Lieferanten-Policies', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy'] },
|
||||
{ key: 'bcm_policies', label: 'BCM/Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy'] },
|
||||
// Modul-Dokumente (Migration 073)
|
||||
{ key: 'module_docs', label: 'DSGVO-Dokumente', types: ['vvt_register', 'tom_documentation', 'loeschkonzept', 'pflichtenregister'] },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
@@ -313,7 +323,7 @@ function ContextSectionForm({
|
||||
onChange: (section: keyof TemplateContext, key: string, value: unknown) => void
|
||||
}) {
|
||||
const fields = SECTION_FIELDS[section]
|
||||
const sectionData = context[section] as Record<string, unknown>
|
||||
const sectionData = context[section] as unknown as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@@ -523,7 +533,7 @@ function GeneratorSection({
|
||||
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Computed flags pills config
|
||||
const flagPills: { key: keyof typeof ruleResult.computedFlags; label: string; color: string }[] = ruleResult ? [
|
||||
const flagPills: { key: string; label: string; color: string }[] = ruleResult ? [
|
||||
{ key: 'IS_B2C', label: 'B2C', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'SERVICE_IS_SAAS', label: 'SaaS', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'HAS_PENALTY', label: 'Vertragsstrafe', color: 'bg-orange-100 text-orange-700' },
|
||||
@@ -842,7 +852,7 @@ function DocumentGeneratorPageInner() {
|
||||
useEffect(() => {
|
||||
if (state?.companyProfile) {
|
||||
const profile = state.companyProfile
|
||||
const p = profile as Record<string, string>
|
||||
const p = profile as unknown as Record<string, string>
|
||||
setContext((prev) => ({
|
||||
...prev,
|
||||
PROVIDER: {
|
||||
@@ -919,7 +929,7 @@ function DocumentGeneratorPageInner() {
|
||||
(section: keyof TemplateContext, key: string, value: unknown) => {
|
||||
setContext((prev) => ({
|
||||
...prev,
|
||||
[section]: { ...(prev[section] as Record<string, unknown>), [key]: value },
|
||||
[section]: { ...(prev[section] as unknown as Record<string, unknown>), [key]: value },
|
||||
}))
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -13,7 +13,21 @@ import {
|
||||
DSRCommunication,
|
||||
DSRVerifyIdentityRequest
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
import { fetchSDKDSR, updateSDKDSRStatus } from '@/lib/sdk/dsr/api'
|
||||
import {
|
||||
fetchSDKDSR,
|
||||
updateSDKDSRStatus,
|
||||
verifyDSRIdentity,
|
||||
assignDSR,
|
||||
extendDSRDeadline,
|
||||
completeDSR,
|
||||
rejectDSR,
|
||||
fetchDSRCommunications,
|
||||
sendDSRCommunication,
|
||||
fetchDSRExceptionChecks,
|
||||
initDSRExceptionChecks,
|
||||
updateDSRExceptionCheck,
|
||||
fetchDSRHistory,
|
||||
} from '@/lib/sdk/dsr/api'
|
||||
import {
|
||||
DSRWorkflowStepper,
|
||||
DSRIdentityModal,
|
||||
@@ -22,12 +36,6 @@ import {
|
||||
DSRDataExportComponent
|
||||
} from '@/components/sdk/dsr'
|
||||
|
||||
// =============================================================================
|
||||
// MOCK COMMUNICATIONS
|
||||
// =============================================================================
|
||||
|
||||
// TODO: Backend fehlt — Communications API noch nicht implementiert
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
@@ -155,56 +163,38 @@ function ActionButtons({
|
||||
)
|
||||
}
|
||||
|
||||
function AuditLog({ request }: { request: DSRRequest }) {
|
||||
type AuditEvent = { action: string; timestamp: string; user: string }
|
||||
|
||||
const events: AuditEvent[] = [
|
||||
{ action: 'Erstellt', timestamp: request.createdAt, user: request.createdBy }
|
||||
]
|
||||
|
||||
if (request.assignment.assignedAt) {
|
||||
events.push({
|
||||
action: `Zugewiesen an ${request.assignment.assignedTo}`,
|
||||
timestamp: request.assignment.assignedAt,
|
||||
user: request.assignment.assignedBy || 'System'
|
||||
})
|
||||
}
|
||||
|
||||
if (request.identityVerification.verifiedAt) {
|
||||
events.push({
|
||||
action: 'Identitaet verifiziert',
|
||||
timestamp: request.identityVerification.verifiedAt,
|
||||
user: request.identityVerification.verifiedBy || 'System'
|
||||
})
|
||||
}
|
||||
|
||||
if (request.completedAt) {
|
||||
events.push({
|
||||
action: request.status === 'rejected' ? 'Abgelehnt' : 'Abgeschlossen',
|
||||
timestamp: request.completedAt,
|
||||
user: request.updatedBy || 'System'
|
||||
})
|
||||
}
|
||||
|
||||
function AuditLog({ history }: { history: any[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700">Aktivitaeten</h4>
|
||||
<div className="space-y-2">
|
||||
{events.map((event, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-xs">
|
||||
{history.length === 0 && (
|
||||
<div className="text-xs text-gray-400">Keine Eintraege</div>
|
||||
)}
|
||||
{history.map((entry, idx) => (
|
||||
<div key={entry.id || idx} className="flex items-start gap-2 text-xs">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 mt-1.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-gray-900">{event.action}</div>
|
||||
<div className="text-gray-900">
|
||||
{entry.previous_status
|
||||
? `${entry.previous_status} → ${entry.new_status}`
|
||||
: entry.new_status
|
||||
}
|
||||
{entry.comment && `: ${entry.comment}`}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{new Date(event.timestamp).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
{entry.created_at
|
||||
? new Date(entry.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: ''
|
||||
}
|
||||
{' - '}
|
||||
{event.user}
|
||||
{entry.changed_by || 'System'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,10 +215,17 @@ export default function DSRDetailPage() {
|
||||
|
||||
const [request, setRequest] = useState<DSRRequest | null>(null)
|
||||
const [communications, setCommunications] = useState<DSRCommunication[]>([])
|
||||
const [history, setHistory] = useState<any[]>([])
|
||||
const [exceptionChecks, setExceptionChecks] = useState<any[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false)
|
||||
const [activeContentTab, setActiveContentTab] = useState<'details' | 'communication' | 'type-specific'>('details')
|
||||
|
||||
const reloadRequest = async () => {
|
||||
const found = await fetchSDKDSR(requestId)
|
||||
if (found) setRequest(found)
|
||||
}
|
||||
|
||||
// Load data from SDK backend
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
@@ -237,8 +234,40 @@ export default function DSRDetailPage() {
|
||||
const found = await fetchSDKDSR(requestId)
|
||||
if (found) {
|
||||
setRequest(found)
|
||||
// TODO: Backend fehlt — Communications API noch nicht implementiert
|
||||
setCommunications([])
|
||||
// Load communications, history, and exception checks in parallel
|
||||
const [comms, hist] = await Promise.all([
|
||||
fetchDSRCommunications(requestId).catch(() => []),
|
||||
fetchDSRHistory(requestId).catch(() => []),
|
||||
])
|
||||
setCommunications(comms.map((c: any) => ({
|
||||
id: c.id,
|
||||
dsrId: c.dsr_id,
|
||||
type: c.communication_type,
|
||||
channel: c.channel,
|
||||
subject: c.subject,
|
||||
content: c.content,
|
||||
createdAt: c.created_at,
|
||||
createdBy: c.created_by,
|
||||
sentAt: c.sent_at,
|
||||
sentBy: c.sent_by,
|
||||
})))
|
||||
setHistory(hist)
|
||||
|
||||
// Load exception checks for erasure requests
|
||||
if (found.type === 'erasure') {
|
||||
try {
|
||||
const checks = await fetchDSRExceptionChecks(requestId)
|
||||
if (checks.length === 0) {
|
||||
// Auto-initialize if none exist
|
||||
const initialized = await initDSRExceptionChecks(requestId)
|
||||
setExceptionChecks(initialized)
|
||||
} else {
|
||||
setExceptionChecks(checks)
|
||||
}
|
||||
} catch {
|
||||
setExceptionChecks([])
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load DSR:', error)
|
||||
@@ -252,46 +281,109 @@ export default function DSRDetailPage() {
|
||||
const handleVerifyIdentity = async (verification: DSRVerifyIdentityRequest) => {
|
||||
if (!request) return
|
||||
try {
|
||||
await updateSDKDSRStatus(request.id, 'verified')
|
||||
setRequest({
|
||||
...request,
|
||||
identityVerification: {
|
||||
verified: true,
|
||||
method: verification.method,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
verifiedBy: 'Current User',
|
||||
notes: verification.notes
|
||||
},
|
||||
status: request.status === 'identity_verification' ? 'processing' : request.status
|
||||
const updated = await verifyDSRIdentity(request.id, {
|
||||
method: verification.method,
|
||||
notes: verification.notes,
|
||||
document_ref: verification.documentRef,
|
||||
})
|
||||
setRequest(updated)
|
||||
// Reload history
|
||||
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||
} catch (err) {
|
||||
console.error('Failed to verify identity:', err)
|
||||
// Still update locally as fallback
|
||||
setRequest({
|
||||
...request,
|
||||
identityVerification: {
|
||||
verified: true,
|
||||
method: verification.method,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
verifiedBy: 'Current User',
|
||||
notes: verification.notes
|
||||
},
|
||||
status: request.status === 'identity_verification' ? 'processing' : request.status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!request) return
|
||||
const assignee = prompt('Zuweisen an (Name/ID):')
|
||||
if (!assignee) return
|
||||
try {
|
||||
const updated = await assignDSR(request.id, assignee)
|
||||
setRequest(updated)
|
||||
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||
} catch (err) {
|
||||
console.error('Failed to assign:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtendDeadline = async () => {
|
||||
if (!request) return
|
||||
const reason = prompt('Grund fuer die Fristverlaengerung:')
|
||||
if (!reason) return
|
||||
const daysStr = prompt('Um wie viele Tage verlaengern? (Standard: 60)', '60')
|
||||
const days = parseInt(daysStr || '60', 10) || 60
|
||||
try {
|
||||
const updated = await extendDSRDeadline(request.id, reason, days)
|
||||
setRequest(updated)
|
||||
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||
} catch (err) {
|
||||
console.error('Failed to extend deadline:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!request) return
|
||||
const summary = prompt('Zusammenfassung der Bearbeitung:')
|
||||
if (summary === null) return
|
||||
try {
|
||||
const updated = await completeDSR(request.id, summary || undefined)
|
||||
setRequest(updated)
|
||||
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||
} catch (err) {
|
||||
console.error('Failed to complete:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!request) return
|
||||
const reason = prompt('Ablehnungsgrund:')
|
||||
if (!reason) return
|
||||
const legalBasis = prompt('Rechtsgrundlage (optional):')
|
||||
try {
|
||||
const updated = await rejectDSR(request.id, reason, legalBasis || undefined)
|
||||
setRequest(updated)
|
||||
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||
} catch (err) {
|
||||
console.error('Failed to reject:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCommunication = async (message: any) => {
|
||||
const newComm: DSRCommunication = {
|
||||
id: `comm-${Date.now()}`,
|
||||
dsrId: requestId,
|
||||
...message,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'Current User',
|
||||
sentAt: message.type === 'outgoing' ? new Date().toISOString() : undefined,
|
||||
sentBy: message.type === 'outgoing' ? 'Current User' : undefined
|
||||
if (!request) return
|
||||
try {
|
||||
const result = await sendDSRCommunication(requestId, {
|
||||
communication_type: message.type || 'outgoing',
|
||||
channel: message.channel || 'email',
|
||||
subject: message.subject,
|
||||
content: message.content,
|
||||
})
|
||||
// Map backend response to frontend format
|
||||
const newComm: DSRCommunication = {
|
||||
id: result.id,
|
||||
dsrId: result.dsr_id,
|
||||
type: result.communication_type,
|
||||
channel: result.channel,
|
||||
subject: result.subject,
|
||||
content: result.content,
|
||||
createdAt: result.created_at,
|
||||
createdBy: result.created_by || 'Current User',
|
||||
sentAt: result.sent_at,
|
||||
sentBy: result.sent_by,
|
||||
}
|
||||
setCommunications(prev => [newComm, ...prev])
|
||||
} catch (err) {
|
||||
console.error('Failed to send communication:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExceptionCheckChange = async (checkId: string, applies: boolean, notes?: string) => {
|
||||
try {
|
||||
const updated = await updateDSRExceptionCheck(requestId, checkId, { applies, notes })
|
||||
setExceptionChecks(prev => prev.map(c => c.id === checkId ? updated : c))
|
||||
} catch (err) {
|
||||
console.error('Failed to update exception check:', err)
|
||||
}
|
||||
setCommunications(prev => [newComm, ...prev])
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -528,10 +620,48 @@ export default function DSRDetailPage() {
|
||||
<div>
|
||||
{/* Art. 17 - Erasure */}
|
||||
{request.type === 'erasure' && (
|
||||
<DSRErasureChecklistComponent
|
||||
checklist={request.erasureChecklist}
|
||||
onChange={(checklist) => setRequest({ ...request, erasureChecklist: checklist })}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<DSRErasureChecklistComponent
|
||||
checklist={request.erasureChecklist}
|
||||
onChange={(checklist) => setRequest({ ...request, erasureChecklist: checklist })}
|
||||
/>
|
||||
|
||||
{/* Art. 17(3) Exception Checks from Backend */}
|
||||
{exceptionChecks.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Art. 17(3) Ausnahmepruefung</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Pruefen Sie, ob eine der gesetzlichen Ausnahmen zur Loeschpflicht greift.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{exceptionChecks.map((check) => (
|
||||
<div key={check.id} className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={check.applies || false}
|
||||
onChange={(e) => handleExceptionCheckChange(check.id, e.target.checked, check.notes)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 text-sm">
|
||||
{check.article}: {check.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{check.description}</div>
|
||||
{check.checked_by && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Geprueft von {check.checked_by} am{' '}
|
||||
{check.checked_at ? new Date(check.checked_at).toLocaleDateString('de-DE') : '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Art. 15/20 - Data Export */}
|
||||
@@ -697,16 +827,16 @@ export default function DSRDetailPage() {
|
||||
<ActionButtons
|
||||
request={request}
|
||||
onVerifyIdentity={() => setShowIdentityModal(true)}
|
||||
onExtendDeadline={() => alert('Fristverlaengerung - Coming soon')}
|
||||
onComplete={() => alert('Abschliessen - Coming soon')}
|
||||
onReject={() => alert('Ablehnen - Coming soon')}
|
||||
onAssign={() => alert('Zuweisen - Coming soon')}
|
||||
onExtendDeadline={handleExtendDeadline}
|
||||
onComplete={handleComplete}
|
||||
onReject={handleReject}
|
||||
onAssign={handleAssign}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audit Log Card */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<AuditLog request={request} />
|
||||
<AuditLog history={history} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -808,15 +808,31 @@ export default function DSRPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
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>
|
||||
Anfrage erfassen
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const link = document.createElement('a')
|
||||
link.href = '/api/sdk/v1/compliance/dsr/export?format=csv'
|
||||
link.download = 'dsr_export.csv'
|
||||
link.click()
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
CSV Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
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>
|
||||
Anfrage erfassen
|
||||
</button>
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { StepHeader } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -321,16 +321,7 @@ export default function EmailTemplatesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader stepId="email-templates" explanation={STEP_EXPLANATIONS['email-templates'] || {
|
||||
title: 'E-Mail-Templates',
|
||||
description: 'Verwalten Sie Vorlagen fuer alle DSGVO-relevanten Benachrichtigungen.',
|
||||
steps: [
|
||||
'Template-Typen und Variablen pruefen',
|
||||
'Inhalte im Editor anpassen',
|
||||
'Vorschau pruefen und publizieren',
|
||||
'Branding-Einstellungen konfigurieren',
|
||||
],
|
||||
}} />
|
||||
<StepHeader stepId="email-templates" />
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
const badgeBase = "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confidence Level Badge (E0–E4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const confidenceColors: Record<string, string> = {
|
||||
E0: "bg-red-100 text-red-800",
|
||||
E1: "bg-yellow-100 text-yellow-800",
|
||||
E2: "bg-blue-100 text-blue-800",
|
||||
E3: "bg-green-100 text-green-800",
|
||||
E4: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
|
||||
const confidenceLabels: Record<string, string> = {
|
||||
E0: "E0 — Generiert",
|
||||
E1: "E1 — Manuell",
|
||||
E2: "E2 — Intern validiert",
|
||||
E3: "E3 — System-beobachtet",
|
||||
E4: "E4 — Extern auditiert",
|
||||
}
|
||||
|
||||
export function ConfidenceLevelBadge({ level }: { level?: string | null }) {
|
||||
if (!level) return null
|
||||
const color = confidenceColors[level] || "bg-gray-100 text-gray-800"
|
||||
const label = confidenceLabels[level] || level
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Truth Status Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const truthColors: Record<string, string> = {
|
||||
generated: "bg-violet-100 text-violet-800",
|
||||
uploaded: "bg-gray-100 text-gray-800",
|
||||
observed: "bg-blue-100 text-blue-800",
|
||||
validated: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
audited: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
|
||||
const truthLabels: Record<string, string> = {
|
||||
generated: "Generiert",
|
||||
uploaded: "Hochgeladen",
|
||||
observed: "Beobachtet",
|
||||
validated: "Validiert",
|
||||
rejected: "Abgelehnt",
|
||||
audited: "Auditiert",
|
||||
}
|
||||
|
||||
export function TruthStatusBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return null
|
||||
const color = truthColors[status] || "bg-gray-100 text-gray-800"
|
||||
const label = truthLabels[status] || status
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generation Mode Badge (sparkles icon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function GenerationModeBadge({ mode }: { mode?: string | null }) {
|
||||
if (!mode) return null
|
||||
return (
|
||||
<span className={`${badgeBase} bg-violet-100 text-violet-800`}>
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0v-1H3a1 1 0 010-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 7.512a1 1 0 010 1.976l-3.354.313-1.18 4.456a1 1 0 01-1.932 0l-1.18-4.456-3.354-.313a1 1 0 010-1.976l3.354-.313 1.18-4.456A1 1 0 0112 2z" />
|
||||
</svg>
|
||||
KI-generiert
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval Status Badge (Four-Eyes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const approvalColors: Record<string, string> = {
|
||||
none: "bg-gray-100 text-gray-600",
|
||||
pending_first: "bg-yellow-100 text-yellow-800",
|
||||
first_approved: "bg-blue-100 text-blue-800",
|
||||
approved: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
const approvalLabels: Record<string, string> = {
|
||||
none: "Kein Review",
|
||||
pending_first: "Warte auf 1. Review",
|
||||
first_approved: "1. Review OK",
|
||||
approved: "Genehmigt (4-Augen)",
|
||||
rejected: "Abgelehnt",
|
||||
}
|
||||
|
||||
export function ApprovalStatusBadge({
|
||||
status,
|
||||
requiresFourEyes,
|
||||
}: {
|
||||
status?: string | null
|
||||
requiresFourEyes?: boolean | null
|
||||
}) {
|
||||
if (!requiresFourEyes) return null
|
||||
const s = status || "none"
|
||||
const color = approvalColors[s] || "bg-gray-100 text-gray-600"
|
||||
const label = approvalLabels[s] || s
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,46 @@ interface Component {
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
library_component_id?: string
|
||||
energy_source_ids?: string[]
|
||||
}
|
||||
|
||||
interface LibraryComponent {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
category: string
|
||||
description_de: string
|
||||
typical_hazard_categories: string[]
|
||||
typical_energy_sources: string[]
|
||||
maps_to_component_type: string
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface EnergySource {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
description_de: string
|
||||
typical_components: string[]
|
||||
typical_hazard_categories: string[]
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
const LIBRARY_CATEGORIES: Record<string, string> = {
|
||||
mechanical: 'Mechanik',
|
||||
structural: 'Struktur',
|
||||
drive: 'Antrieb',
|
||||
hydraulic: 'Hydraulik',
|
||||
pneumatic: 'Pneumatik',
|
||||
electrical: 'Elektrik',
|
||||
control: 'Steuerung',
|
||||
sensor: 'Sensorik',
|
||||
actuator: 'Aktorik',
|
||||
safety: 'Sicherheit',
|
||||
it_network: 'IT/Netzwerk',
|
||||
}
|
||||
|
||||
const COMPONENT_TYPES = [
|
||||
@@ -98,6 +138,11 @@ function ComponentTreeNode({
|
||||
Sicherheitsrelevant
|
||||
</span>
|
||||
)}
|
||||
{component.library_component_id && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
Bibliothek
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
@@ -289,6 +334,289 @@ function buildTree(components: Component[]): Component[] {
|
||||
return roots
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Library Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function ComponentLibraryModal({
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
|
||||
const [energySources, setEnergySources] = useState<EnergySource[]>([])
|
||||
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
|
||||
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCategory, setFilterCategory] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [compRes, enRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/iace/component-library'),
|
||||
fetch('/api/sdk/v1/iace/energy-sources'),
|
||||
])
|
||||
if (compRes.ok) {
|
||||
const json = await compRes.json()
|
||||
setLibraryComponents(json.components || [])
|
||||
}
|
||||
if (enRes.ok) {
|
||||
const json = await enRes.json()
|
||||
setEnergySources(json.energy_sources || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch library:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
function toggleComponent(id: string) {
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleEnergySource(id: string) {
|
||||
setSelectedEnergySources(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAllInCategory(category: string) {
|
||||
const items = libraryComponents.filter(c => c.category === category)
|
||||
const allIds = items.map(i => i.id)
|
||||
const allSelected = allIds.every(id => selectedComponents.has(id))
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
|
||||
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
|
||||
onAdd(selComps, selEnergy)
|
||||
}
|
||||
|
||||
const filtered = libraryComponents.filter(c => {
|
||||
if (filterCategory && c.category !== filterCategory) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
|
||||
if (!acc[c.category]) acc[c.category] = []
|
||||
acc[c.category].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const categories = Object.keys(LIBRARY_CATEGORIES)
|
||||
const totalSelected = selectedComponents.size + selectedEnergySources.size
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
|
||||
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Komponentenbibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('components')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Komponenten ({libraryComponents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('energy')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Energiequellen ({energySources.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'components' && (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={e => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'components' ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped)
|
||||
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
|
||||
.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{LIBRARY_CATEGORIES[category] || category}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">({items.length})</span>
|
||||
<button
|
||||
onClick={() => toggleAllInCategory(category)}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 ml-auto"
|
||||
>
|
||||
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(comp => (
|
||||
<label
|
||||
key={comp.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedComponents.has(comp.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedComponents.has(comp.id)}
|
||||
onChange={() => toggleComponent(comp.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
|
||||
<ComponentTypeIcon type={comp.maps_to_component_type} />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
|
||||
{comp.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{energySources.map(es => (
|
||||
<label
|
||||
key={es.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedEnergySources.has(es.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEnergySources.has(es.id)}
|
||||
onChange={() => toggleEnergySource(es.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{es.id}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
|
||||
{es.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={totalSelected === 0}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
totalSelected > 0
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page
|
||||
// ============================================================================
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
@@ -297,6 +625,7 @@ export default function ComponentsPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
@@ -365,6 +694,32 @@ export default function ComponentsPage() {
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
||||
setShowLibrary(false)
|
||||
const energySourceIds = energySrcs.map(e => e.id)
|
||||
|
||||
for (const comp of libraryComps) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: comp.name_de,
|
||||
type: comp.maps_to_component_type,
|
||||
description: comp.description_de,
|
||||
safety_relevant: false,
|
||||
library_component_id: comp.id,
|
||||
energy_source_ids: energySourceIds,
|
||||
tags: comp.tags,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to add component ${comp.id}:`, err)
|
||||
}
|
||||
}
|
||||
await fetchComponents()
|
||||
}
|
||||
|
||||
const tree = buildTree(components)
|
||||
|
||||
if (loading) {
|
||||
@@ -386,22 +741,41 @@ export default function ComponentsPage() {
|
||||
</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
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>
|
||||
Komponente hinzufuegen
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowLibrary(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
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>
|
||||
Komponente hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Library Modal */}
|
||||
{showLibrary && (
|
||||
<ComponentLibraryModal
|
||||
onAdd={handleAddFromLibrary}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<ComponentForm
|
||||
@@ -454,12 +828,20 @@ export default function ComponentsPage() {
|
||||
Beginnen Sie mit der Erfassung aller relevanten Komponenten Ihrer Maschine.
|
||||
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Komponente hinzufuegen
|
||||
</button>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowLibrary(true)}
|
||||
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user