Files
opencrm/frontend/src/pages/portal/PortalPrivacy.tsx
T
duffyduckandClaude Opus 4.7 7b6b586033 fix: PortalPrivacy weiße Seite – Hooks-Reihenfolge nach early-return
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>
2026-05-17 09:33:40 +02:00

205 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}