useState + useEffect für den Download-Token standen nach dem `if (isLoading) return <Laden />` early-return. Beim ersten Render gab es 2 Hooks, beim zweiten 4 → React-Hook-Order-Mismatch → Crash → weiße Seite. Mein Fehler aus der Download-Token-Migration (Runde 11). Hooks vor den early-return verschoben. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
205 lines
7.8 KiB
TypeScript
205 lines
7.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useAuth } from '../../context/AuthContext';
|
||
import { gdprApi, authApi } from '../../services/api';
|
||
import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types';
|
||
import {
|
||
Shield,
|
||
ShieldCheck,
|
||
ShieldX,
|
||
ShieldAlert,
|
||
FileDown,
|
||
CheckCircle2,
|
||
} from 'lucide-react';
|
||
import Card from '../../components/ui/Card';
|
||
import DOMPurify from 'dompurify';
|
||
|
||
const SANITIZE_OPTIONS = {
|
||
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
|
||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'formaction'],
|
||
};
|
||
|
||
const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
|
||
DATA_PROCESSING: {
|
||
label: 'Datenverarbeitung',
|
||
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
|
||
},
|
||
MARKETING_EMAIL: {
|
||
label: 'Elektronisches Marketing',
|
||
description: 'Zusendung von Werbung und Angeboten über elektronische Kommunikationswege (E-Mail, Messenger etc.)',
|
||
},
|
||
MARKETING_PHONE: {
|
||
label: 'Telefonmarketing',
|
||
description: 'Kontaktaufnahme zu Werbezwecken per Telefon',
|
||
},
|
||
DATA_SHARING_PARTNER: {
|
||
label: 'Datenweitergabe an Partner',
|
||
description: 'Weitergabe Ihrer Daten an ausgewählte Partnerunternehmen',
|
||
},
|
||
};
|
||
|
||
export default function PortalPrivacy() {
|
||
const { user } = useAuth();
|
||
const queryClient = useQueryClient();
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['my-privacy'],
|
||
queryFn: () => gdprApi.getMyPrivacy(),
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ consentType, status }: { consentType: ConsentType; status: ConsentStatus }) =>
|
||
gdprApi.updateConsent(user!.customerId!, consentType, { status, source: 'portal' }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['my-privacy'] });
|
||
queryClient.invalidateQueries({ queryKey: ['my-consent-status'] });
|
||
},
|
||
});
|
||
|
||
// Kurzlebigen Download-Token (60s) für den PDF-Link. Hooks MÜSSEN vor jedem
|
||
// early-return stehen (Rules of Hooks) – sonst weiße Seite beim ersten Render.
|
||
const [pdfToken, setPdfToken] = useState<string | null>(null);
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
authApi.getDownloadToken().then((t) => { if (!cancelled) setPdfToken(t); });
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
const handleToggle = (consent: CustomerConsent) => {
|
||
const newStatus: ConsentStatus = consent.status === 'GRANTED' ? 'WITHDRAWN' : 'GRANTED';
|
||
updateMutation.mutate({ consentType: consent.consentType, status: newStatus });
|
||
};
|
||
|
||
const getStatusIcon = (status: ConsentStatus) => {
|
||
switch (status) {
|
||
case 'GRANTED':
|
||
return <ShieldCheck className="w-5 h-5 text-green-500" />;
|
||
case 'WITHDRAWN':
|
||
return <ShieldX className="w-5 h-5 text-red-500" />;
|
||
case 'PENDING':
|
||
return <ShieldAlert className="w-5 h-5 text-yellow-500" />;
|
||
default:
|
||
return <Shield className="w-5 h-5 text-gray-400" />;
|
||
}
|
||
};
|
||
|
||
const getStatusLabel = (status: ConsentStatus) => {
|
||
switch (status) {
|
||
case 'GRANTED':
|
||
return <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700">Erteilt</span>;
|
||
case 'WITHDRAWN':
|
||
return <span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700">Widerrufen</span>;
|
||
case 'PENDING':
|
||
return <span className="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Ausstehend</span>;
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return <div className="text-center py-8 text-gray-500">Laden...</div>;
|
||
}
|
||
|
||
const consents = data?.data?.consents || [];
|
||
const privacyPolicyHtml = data?.data?.privacyPolicyHtml || '';
|
||
const allGranted = consents.every((c) => c.status === 'GRANTED');
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<Shield className="w-6 h-6 text-blue-600" />
|
||
<h1 className="text-2xl font-bold">Datenschutz</h1>
|
||
</div>
|
||
|
||
{/* Erfolgs-Banner wenn alle erteilt */}
|
||
{allGranted && (
|
||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6 flex items-center gap-3">
|
||
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
|
||
<p className="text-sm text-green-700">
|
||
Sie haben allen Einwilligungen zugestimmt. Vielen Dank!
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Einwilligungen */}
|
||
<Card title="Ihre Einwilligungen" className="mb-6">
|
||
<p className="text-sm text-gray-500 mb-4">
|
||
Hier können Sie Ihre Datenschutz-Einwilligungen verwalten. Alle Einwilligungen sind erforderlich, damit wir Sie beraten können.
|
||
</p>
|
||
<div className="space-y-3">
|
||
{consents.map((consent) => {
|
||
const typeInfo = CONSENT_TYPE_LABELS[consent.consentType] || { label: consent.consentType, description: '' };
|
||
|
||
return (
|
||
<div key={consent.consentType} className="border rounded-lg p-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-start gap-3">
|
||
{getStatusIcon(consent.status)}
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h4 className="font-medium">{typeInfo.label}</h4>
|
||
{getStatusLabel(consent.status)}
|
||
</div>
|
||
<p className="text-sm text-gray-500 mt-0.5">{typeInfo.description}</p>
|
||
{consent.grantedAt && consent.status === 'GRANTED' && (
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
Erteilt am {new Date(consent.grantedAt).toLocaleDateString('de-DE', {
|
||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||
})}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => handleToggle(consent)}
|
||
disabled={updateMutation.isPending}
|
||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||
consent.status === 'GRANTED'
|
||
? 'bg-green-500'
|
||
: 'bg-gray-300'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||
consent.status === 'GRANTED' ? 'translate-x-6' : 'translate-x-1'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{updateMutation.isError && (
|
||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||
Fehler beim Speichern. Bitte versuchen Sie es erneut.
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Datenschutzerklärung */}
|
||
<Card title="Datenschutzerklärung" className="mb-6">
|
||
<div className="flex justify-end mb-4">
|
||
{pdfToken && (
|
||
<a
|
||
href={`${gdprApi.getMyPrivacyPdfUrl}?token=${pdfToken}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
|
||
>
|
||
<FileDown className="w-4 h-4" />
|
||
Als PDF herunterladen
|
||
</a>
|
||
)}
|
||
</div>
|
||
<div
|
||
className="prose prose-sm max-w-none"
|
||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
|
||
/>
|
||
</Card>
|
||
|
||
</div>
|
||
);
|
||
}
|