Files
opencrm/frontend/src/pages/settings/ProviderList.tsx
T
duffyduckandClaude Opus 4.7 8b10316683 Anbieter: Kontakt + Kündigung als Stammdaten
Sieben neue optionale Felder am Provider (contactEmail,
contactPhone, contactFax, contactAddress, cancellationEmail,
cancellationFax, cancellationAddress). Postadressen TEXT,
Rest VARCHAR(191). Migration mit IF NOT EXISTS.

Modal "Anbieter bearbeiten" bekommt neue Sektion "Kontakt &
Kündigung" mit zwei Untergruppen. Backend validiert Emails
gegen isValidEmail (Header-Injection-Schutz), Telefon/Fax
gegen sanitizePhoneField (kein CRLF), Postadressen via
sanitizeNotes mit 500-Cap. Factory-Defaults Export/Import
mitgezogen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 13:10:59 +02:00

654 lines
22 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 { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { providerApi, tariffApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { Provider, Tariff } from '../../types';
export default function ProviderList() {
const [showModal, setShowModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [showInactive, setShowInactive] = useState(false);
const [expandedProviders, setExpandedProviders] = useState<Set<number>>(new Set());
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['providers', showInactive],
queryFn: () => providerApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: providerApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
},
onError: (error: Error) => {
alert(error.message);
},
});
const toggleExpanded = (providerId: number) => {
setExpandedProviders(prev => {
const next = new Set(prev);
if (next.has(providerId)) {
next.delete(providerId);
} else {
next.add(providerId);
}
return next;
});
};
const handleEdit = (provider: Provider) => {
setEditingProvider(provider);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingProvider(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Anbieter & Tarife</h1>
{hasPermission('providers:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neuer Anbieter
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="space-y-2">
{data.data.map((provider) => (
<ProviderRow
key={provider.id}
provider={provider}
isExpanded={expandedProviders.has(provider.id)}
onToggle={() => toggleExpanded(provider.id)}
onEdit={() => handleEdit(provider)}
onDelete={() => {
if (confirm('Anbieter wirklich löschen?')) {
deleteMutation.mutate(provider.id);
}
}}
hasPermission={hasPermission}
showInactive={showInactive}
/>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Anbieter vorhanden.</div>
)}
</Card>
<ProviderModal
isOpen={showModal}
onClose={handleClose}
provider={editingProvider}
/>
</div>
);
}
function ProviderRow({
provider,
isExpanded,
onToggle,
onEdit,
onDelete,
hasPermission,
showInactive,
}: {
provider: Provider;
isExpanded: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
hasPermission: (permission: string) => boolean;
showInactive: boolean;
}) {
const [showTariffModal, setShowTariffModal] = useState(false);
const [editingTariff, setEditingTariff] = useState<Tariff | null>(null);
const queryClient = useQueryClient();
const deleteTariffMutation = useMutation({
mutationFn: tariffApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
},
onError: (error: Error) => {
alert(error.message);
},
});
const tariffs = provider.tariffs?.filter(t => showInactive || t.isActive) || [];
return (
<div className="border rounded-lg">
<div className="flex items-center p-4 hover:bg-gray-50">
<button onClick={onToggle} className="mr-3 p-1 hover:bg-gray-200 rounded">
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronRight className="w-5 h-5 text-gray-400" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{provider.name}</span>
<Badge variant={provider.isActive ? 'success' : 'danger'}>
{provider.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
<span className="text-sm text-gray-500">
({tariffs.length} Tarife, {provider._count?.contracts || 0} Verträge)
</span>
</div>
{provider.portalUrl && (
<a
href={provider.portalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1"
>
<ExternalLink className="w-3 h-3" />
{provider.portalUrl}
</a>
)}
</div>
<div className="flex gap-2 ml-4">
{hasPermission('providers:update') && (
<Button variant="ghost" size="sm" onClick={onEdit} title="Bearbeiten">
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('providers:delete') && (
<Button variant="ghost" size="sm" onClick={onDelete} title="Löschen">
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</div>
{isExpanded && (
<div className="border-t bg-gray-50 p-4">
<div className="flex justify-between items-center mb-3">
<h4 className="font-medium text-gray-700">Tarife</h4>
{hasPermission('providers:create') && (
<Button size="sm" onClick={() => setShowTariffModal(true)}>
<Plus className="w-4 h-4 mr-1" />
Tarif hinzufügen
</Button>
)}
</div>
{tariffs.length > 0 ? (
<div className="space-y-2">
{tariffs.map((tariff) => (
<div key={tariff.id} className="flex items-center justify-between bg-white p-3 rounded border">
<div className="flex items-center gap-2">
<span>{tariff.name}</span>
<Badge variant={tariff.isActive ? 'success' : 'danger'} className="text-xs">
{tariff.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
{tariff._count?.contracts !== undefined && (
<span className="text-xs text-gray-500">
({tariff._count.contracts} Verträge)
</span>
)}
</div>
<div className="flex gap-1">
{hasPermission('providers:update') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingTariff(tariff);
setShowTariffModal(true);
}}
title="Bearbeiten"
>
<Edit className="w-3 h-3" />
</Button>
)}
{hasPermission('providers:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Tarif wirklich löschen?')) {
deleteTariffMutation.mutate(tariff.id);
}
}}
title="Löschen"
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">Keine Tarife vorhanden.</p>
)}
</div>
)}
<TariffModal
isOpen={showTariffModal}
onClose={() => {
setShowTariffModal(false);
setEditingTariff(null);
}}
providerId={provider.id}
tariff={editingTariff}
/>
</div>
);
}
function ProviderModal({
isOpen,
onClose,
provider,
}: {
isOpen: boolean;
onClose: () => void;
provider: Provider | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
contactEmail: '',
contactPhone: '',
contactFax: '',
contactAddress: '',
cancellationEmail: '',
cancellationFax: '',
cancellationAddress: '',
isActive: true,
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
// Admin sein eigenes Passwort mitsenden Schutz gegen kompromittierten
// JWT, der sonst Phishing-URLs auf existierende Anbieter setzen könnte.
currentPassword: '',
});
const originalPortalUrl = provider?.portalUrl ?? '';
// Pentest 49.1: Jede URL-Änderung (inkl. Pfad/Query) braucht Re-Auth
// nicht nur Host-Wechsel. Normalisierung (Trailing-Slash, Whitespace,
// Case) passend zum Backend, damit der Banner mit der Backend-Prüfung
// übereinstimmt.
const normalizeUrl = (u: string) => u.trim().replace(/\/+$/, '').toLowerCase();
const portalUrlChanged = normalizeUrl(formData.portalUrl) !== normalizeUrl(originalPortalUrl);
const portalUrlSetOnCreate = !provider && !!formData.portalUrl.trim();
const needsReAuth = portalUrlChanged || portalUrlSetOnCreate;
useEffect(() => {
if (isOpen) {
if (provider) {
setFormData({
name: provider.name,
portalUrl: provider.portalUrl || '',
usernameFieldName: provider.usernameFieldName || '',
passwordFieldName: provider.passwordFieldName || '',
contactEmail: provider.contactEmail || '',
contactPhone: provider.contactPhone || '',
contactFax: provider.contactFax || '',
contactAddress: provider.contactAddress || '',
cancellationEmail: provider.cancellationEmail || '',
cancellationFax: provider.cancellationFax || '',
cancellationAddress: provider.cancellationAddress || '',
isActive: provider.isActive,
currentPassword: '',
});
} else {
setFormData({
name: '',
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
contactEmail: '',
contactPhone: '',
contactFax: '',
contactAddress: '',
cancellationEmail: '',
cancellationFax: '',
cancellationAddress: '',
isActive: true,
currentPassword: '',
});
}
}
}, [isOpen, provider]);
const createMutation = useMutation({
mutationFn: providerApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Provider>) =>
providerApi.update(provider!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (needsReAuth && !formData.currentPassword) {
alert('Bitte das eigene Passwort zur Bestätigung der Portal-URL eingeben.');
return;
}
// currentPassword wird nur mitgesendet wenn überhaupt nötig
const payload: any = { ...formData };
if (!needsReAuth) delete payload.currentPassword;
if (provider) {
updateMutation.mutate(payload);
} else {
createMutation.mutate(payload);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={provider ? 'Anbieter bearbeiten' : 'Neuer Anbieter'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Anbietername *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Vodafone, E.ON, Allianz"
/>
<Input
label="Portal-URL (Login-Seite)"
value={formData.portalUrl}
onChange={(e) => setFormData({ ...formData, portalUrl: e.target.value })}
placeholder="https://kundenportal.anbieter.de/login"
/>
{needsReAuth && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg space-y-2">
<p className="text-sm text-amber-800">
<strong>Bestätigung erforderlich:</strong>{' '}
{provider
? 'Die Portal-URL wurde geändert. Diese URL ist anschließend für alle Portal-Kunden dieses Anbieters klickbar.'
: 'Mit dem Speichern wird die Portal-URL für alle Portal-Kunden dieses Anbieters klickbar.'}
{' '}Zur Sicherheit ist eine Bestätigung mit dem eigenen Passwort nötig.
</p>
<Input
label="Eigenes Passwort zur Bestätigung *"
type="password"
value={formData.currentPassword}
onChange={(e) => setFormData({ ...formData, currentPassword: e.target.value })}
required
autoComplete="current-password"
/>
</div>
)}
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
<p className="text-sm text-gray-600">
<strong>Auto-Login Felder</strong> (optional)<br />
Feldnamen für URL-Parameter beim Auto-Login:
</p>
<Input
label="Benutzername-Feldname"
value={formData.usernameFieldName}
onChange={(e) => setFormData({ ...formData, usernameFieldName: e.target.value })}
placeholder="z.B. username, email, login"
/>
<Input
label="Passwort-Feldname"
value={formData.passwordFieldName}
onChange={(e) => setFormData({ ...formData, passwordFieldName: e.target.value })}
placeholder="z.B. password, pwd, kennwort"
/>
</div>
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
<p className="text-sm text-gray-600">
<strong>Kontakt & Kündigung</strong> (optional)<br />
Erreichbarkeit des Anbieters wird im CRM zum Nachschlagen
angezeigt, nicht an Portal-Kunden ausgespielt.
</p>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Kontakt</div>
<Input
label="Kontakt-Emailadresse"
type="email"
value={formData.contactEmail}
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
placeholder="z.B. service@anbieter.de"
/>
<Input
label="Kontakt-Telefonnummer"
value={formData.contactPhone}
onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
placeholder="z.B. +49 30 1234567"
/>
<Input
label="Kontakt-Faxnummer"
value={formData.contactFax}
onChange={(e) => setFormData({ ...formData, contactFax: e.target.value })}
placeholder="z.B. +49 30 7654321"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kontakt-Postadresse
</label>
<textarea
value={formData.contactAddress}
onChange={(e) => setFormData({ ...formData, contactAddress: e.target.value })}
rows={3}
maxLength={500}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Musteranbieter GmbH&#10;Musterstraße 1&#10;12345 Berlin"
/>
</div>
<div className="pt-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Kündigung</div>
<Input
label="Kündigungs-Emailadresse"
type="email"
value={formData.cancellationEmail}
onChange={(e) => setFormData({ ...formData, cancellationEmail: e.target.value })}
placeholder="z.B. kuendigung@anbieter.de"
/>
<Input
label="Kündigungs-Faxnummer"
value={formData.cancellationFax}
onChange={(e) => setFormData({ ...formData, cancellationFax: e.target.value })}
placeholder="z.B. +49 30 9876543"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kündigungs-Postadresse
</label>
<textarea
value={formData.cancellationAddress}
onChange={(e) => setFormData({ ...formData, cancellationAddress: e.target.value })}
rows={3}
maxLength={500}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Musteranbieter GmbH&#10;Abteilung Kündigung&#10;Musterstraße 1&#10;12345 Berlin"
/>
</div>
</div>
{provider && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
function TariffModal({
isOpen,
onClose,
providerId,
tariff,
}: {
isOpen: boolean;
onClose: () => void;
providerId: number;
tariff: Tariff | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
isActive: true,
});
useEffect(() => {
if (isOpen) {
if (tariff) {
setFormData({
name: tariff.name,
isActive: tariff.isActive,
});
} else {
setFormData({ name: '', isActive: true });
}
}
}, [isOpen, tariff]);
const createMutation = useMutation({
mutationFn: (data: { name: string }) => providerApi.createTariff(providerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Tariff>) => tariffApi.update(tariff!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (tariff) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={tariff ? 'Tarif bearbeiten' : 'Neuer Tarif'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Tarifname *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Comfort Plus, Basic 100"
/>
{tariff && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}