feat: DSFA — VVT-Verknüpfung + Residual Risk + Bundesland-Blacklists

1. VVT-Verknüpfung: Dropdown "Verknüpfte VVT-Aktivität" in Step 1,
   lädt Aktivitäten via API, auto-fills Verarbeitungstätigkeit bei Auswahl

2. Residual Risk: Neuer Step 5 im Wizard — Bewertung des Restrisikos
   nach Maßnahmen. Bei hoch/kritisch → Art. 36 Vorabkonsultation Warnung

3. Bundesland-Blacklists (Art. 35 Abs. 4): 16 Landesbehörden mit
   DSK-Muss-Liste (10 gemeinsame Kriterien) + länderspezifische
   Ergänzungen (Bayern: Whistleblower/Drohnen, NRW: Social-Media-
   Monitoring, Berlin: Mieterbonitätsprüfung). Automatische Prüfung
   gegen Scope-Antworten. Blacklist-Matches im DSFA-Banner angezeigt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-04 21:48:59 +02:00
parent 84b21cad08
commit 2b4ff9f422
4 changed files with 228 additions and 8 deletions
@@ -19,7 +19,21 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>(riskMap2[prefill?.riskLevel || ''] || 'low')
const [residualRisk, setResidualRisk] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
const [selectedMeasures, setSelectedMeasures] = useState<string[]>(prefill?.measures || [])
const [linkedVvtId, setLinkedVvtId] = useState('')
const [vvtActivities, setVvtActivities] = useState<Array<{ id: string; name: string }>>([])
// Load VVT activities for linking
React.useEffect(() => {
fetch('/api/sdk/v1/compliance/vvt')
.then(r => r.ok ? r.json() : [])
.then(data => {
const items = Array.isArray(data) ? data : data.activities || []
setVvtActivities(items.map((a: any) => ({ id: a.id, name: a.name || a.processing_name || a.title || 'Unbenannt' })))
})
.catch(() => {})
}, [])
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
@@ -39,6 +53,8 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
...(prefill?.federalState ? { federal_state: prefill.federalState } : {}),
...(prefill?.involvesAi ? { involves_ai: true } : {}),
...(prefill?.legalBasis ? { legal_basis: prefill.legalBasis } : {}),
...(linkedVvtId ? { linked_vvt_id: linkedVvtId } : {}),
...(residualRisk !== 'low' ? { residual_risk_level: residualRisk } : {}),
} as Partial<DSFA>)
onClose()
} finally {
@@ -59,7 +75,7 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-6">
{[1, 2, 3, 4].map(s => (
{[1, 2, 3, 4, 5].map(s => (
<React.Fragment key={s}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s < step ? 'bg-green-500 text-white' :
@@ -71,7 +87,7 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
</svg>
) : s}
</div>
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
{s < 5 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
</React.Fragment>
))}
</div>
@@ -100,6 +116,20 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
{vvtActivities.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte VVT-Aktivitaet (Art. 30)</label>
<select value={linkedVvtId} onChange={e => {
setLinkedVvtId(e.target.value)
const selected = vvtActivities.find(a => a.id === e.target.value)
if (selected && !processingActivity) setProcessingActivity(selected.name)
}} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white">
<option value=""> Keine Verknuepfung </option>
{vvtActivities.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<p className="text-xs text-gray-400 mt-1">Ordnen Sie diese DSFA einer VVT-Verarbeitungstaetigkeit zu.</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
<input
@@ -178,6 +208,43 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
</div>
</div>
)}
{step === 5 && (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Restrisiko nach Massnahmen</label>
<p className="text-xs text-gray-500 mb-3">
Bewerten Sie das verbleibende Risiko NACH Umsetzung der Schutzmassnahmen.
Bei hohem Restrisiko Art. 36 Vorabkonsultation der Aufsichtsbehoerde.
</p>
<div className="grid grid-cols-2 gap-2">
{[
{ value: 'low' as const, label: 'Niedrig', desc: 'Risiko ausreichend gemindert', color: 'border-green-300 bg-green-50' },
{ value: 'medium' as const, label: 'Mittel', desc: 'Akzeptables Restrisiko', color: 'border-yellow-300 bg-yellow-50' },
{ value: 'high' as const, label: 'Hoch', desc: 'Art. 36 Konsultation pruefen', color: 'border-orange-300 bg-orange-50' },
{ value: 'critical' as const, label: 'Kritisch', desc: 'Art. 36 Konsultation PFLICHT', color: 'border-red-300 bg-red-50' },
].map(r => (
<label key={r.value} className={`flex items-start gap-2 p-3 border-2 rounded-lg cursor-pointer ${
residualRisk === r.value ? r.color : 'border-gray-200 hover:border-gray-300'
}`}>
<input type="radio" name="residualRisk" value={r.value} checked={residualRisk === r.value}
onChange={() => setResidualRisk(r.value)} className="mt-0.5" />
<div>
<span className="text-sm font-medium">{r.label}</span>
<p className="text-xs text-gray-500">{r.desc}</p>
</div>
</label>
))}
</div>
{(residualRisk === 'high' || residualRisk === 'critical') && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-700 font-medium">Vorabkonsultation erforderlich (Art. 36 DSGVO)</p>
<p className="text-xs text-red-600 mt-1">
Bei hohem Restrisiko muss die Aufsichtsbehoerde VOR Beginn der Verarbeitung konsultiert werden.
</p>
</div>
)}
</div>
)}
</div>
{/* Navigation */}
@@ -190,11 +257,11 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
{step === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
<button
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
onClick={() => step < 5 ? setStep(step + 1) : handleSubmit()}
disabled={saving || (step === 1 && !title.trim())}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
{step === 5 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
</button>
</div>
</div>
+19 -1
View File
@@ -24,7 +24,10 @@ export default function DSFAPage() {
() => prefillDSFAFromScope(state.companyProfile || null, scopeAnswers),
[state.companyProfile, scopeAnswers]
)
const dsfaCheck = useMemo(() => isDSFARequired(scopeAnswers), [scopeAnswers])
const dsfaCheck = useMemo(
() => isDSFARequired(scopeAnswers, state.companyProfile?.headquartersState),
[scopeAnswers, state.companyProfile?.headquartersState]
)
const loadDSFAs = useCallback(async () => {
setIsLoading(true)
@@ -142,6 +145,21 @@ export default function DSFAPage() {
</li>
))}
</ul>
{dsfaCheck.blacklistMatches.length > 0 && (
<div className="mt-3 pt-3 border-t border-red-200">
<p className="text-xs font-medium text-red-800 mb-1">
Blacklist {dsfaCheck.authority || 'Aufsichtsbehoerde'} (Art. 35 Abs. 4):
</p>
<ul className="space-y-1">
{dsfaCheck.blacklistMatches.map(m => (
<li key={m} className="text-xs text-red-600 flex items-center gap-2">
<span className="w-1 h-1 bg-red-400 rounded-full flex-shrink-0" />
{m}
</li>
))}
</ul>
</div>
)}
</div>
)}