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>
This commit is contained in:
2026-06-21 13:10:59 +02:00
parent 26959ec909
commit 8b10316683
7 changed files with 221 additions and 20 deletions
@@ -0,0 +1,13 @@
-- Provider: separate Kontakt- + Kündigungs-Daten als Stammsatz.
-- Vorher musste der CRM-Mitarbeiter Tel/Email/Adresse pro Anbieter
-- selbst nachschlagen; jetzt direkt im Anbieter-Datensatz hinterlegt.
-- Postadressen sind TEXT (mehrzeilig), alle anderen VARCHAR(191).
ALTER TABLE `Provider`
ADD COLUMN IF NOT EXISTS `contactEmail` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactPhone` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactFax` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactAddress` TEXT NULL,
ADD COLUMN IF NOT EXISTS `cancellationEmail` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `cancellationFax` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `cancellationAddress` TEXT NULL;
+9
View File
@@ -576,6 +576,15 @@ model Provider {
portalUrl String? // Kundenkontourl (Login-Seite)
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
// Kontaktdaten beim Anbieter (für CRM-Mitarbeiter zum Nachschlagen)
contactEmail String? // Allgemeine Kontakt-Emailadresse
contactPhone String? // Kontakt-Telefonnummer
contactFax String? // Kontakt-Faxnummer
contactAddress String? @db.Text // Kontakt-Postadresse (mehrzeilig)
// Dedizierte Kündigungs-Endpunkte (wenn separat vom allgemeinen Kontakt)
cancellationEmail String? // Kündigungs-Emailadresse
cancellationFax String? // Kündigungs-Faxnummer
cancellationAddress String? @db.Text // Kündigungs-Postadresse (mehrzeilig)
isActive Boolean @default(true)
tariffs Tariff[]
contracts Contract[]
@@ -46,6 +46,13 @@ export interface ProviderExport {
portalUrl: string | null;
usernameFieldName: string | null;
passwordFieldName: string | null;
contactEmail: string | null;
contactPhone: string | null;
contactFax: string | null;
contactAddress: string | null;
cancellationEmail: string | null;
cancellationFax: string | null;
cancellationAddress: string | null;
isActive: boolean;
tariffs: { name: string; isActive: boolean }[];
}
@@ -90,6 +97,13 @@ export async function collectFactoryDefaults() {
portalUrl: p.portalUrl,
usernameFieldName: p.usernameFieldName,
passwordFieldName: p.passwordFieldName,
contactEmail: p.contactEmail,
contactPhone: p.contactPhone,
contactFax: p.contactFax,
contactAddress: p.contactAddress,
cancellationEmail: p.cancellationEmail,
cancellationFax: p.cancellationFax,
cancellationAddress: p.cancellationAddress,
isActive: p.isActive,
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
})),
@@ -284,6 +298,13 @@ export async function importFactoryDefaults(
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
contactEmail: p.contactEmail ?? null,
contactPhone: p.contactPhone ?? null,
contactFax: p.contactFax ?? null,
contactAddress: p.contactAddress ?? null,
cancellationEmail: p.cancellationEmail ?? null,
cancellationFax: p.cancellationFax ?? null,
cancellationAddress: p.cancellationAddress ?? null,
isActive: p.isActive ?? true,
},
create: {
@@ -291,6 +312,13 @@ export async function importFactoryDefaults(
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
contactEmail: p.contactEmail ?? null,
contactPhone: p.contactPhone ?? null,
contactFax: p.contactFax ?? null,
contactAddress: p.contactAddress ?? null,
cancellationEmail: p.cancellationEmail ?? null,
cancellationFax: p.cancellationFax ?? null,
cancellationAddress: p.cancellationAddress ?? null,
isActive: p.isActive ?? true,
},
});
+55 -20
View File
@@ -1,5 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
import { stripHtml, isValidEmail, sanitizePhoneField, sanitizeNotes } from '../utils/sanitize.js';
import { validateHttpUrl } from '../utils/url.js';
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
@@ -51,48 +51,83 @@ export async function getProviderById(id: number) {
// Aktuell escapt React das Textnode, also kein direkter XSS aber neue
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
function stripProviderStrings<T extends { name?: string; usernameFieldName?: string; passwordFieldName?: string }>(data: T): T {
//
// 2026-06-21: contactEmail/cancellationEmail laufen zusätzlich durch
// isValidEmail (Header-Injection-Schutz für künftige Mail-Templates),
// contactPhone/contactFax/cancellationFax durch sanitizePhoneField
// (kein CRLF/Control-Char), Postadressen durch sanitizeNotes mit
// 500-Cap (mehrzeilig, normalisierte Newlines).
function stripProviderStrings<T extends object>(data: T): T {
const out: any = { ...data };
if (typeof out.name === 'string') out.name = stripHtml(out.name);
if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName);
if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName);
for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) {
if (typeof out[k] === 'string') out[k] = stripHtml(out[k]);
}
for (const k of ['contactEmail', 'cancellationEmail'] as const) {
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
if (out[k] === undefined) continue;
const stripped = typeof out[k] === 'string' ? stripHtml(out[k]) : out[k];
const value = typeof stripped === 'string' ? stripped.trim() : stripped;
if (value === '' ) { out[k] = null; continue; }
if (!isValidEmail(value)) {
throw new Error(`${k === 'contactEmail' ? 'Kontakt-Emailadresse' : 'Kündigungs-Emailadresse'} ist ungültig.`);
}
out[k] = value;
}
const phoneLabels: Record<string, string> = {
contactPhone: 'Kontakt-Telefonnummer',
contactFax: 'Kontakt-Faxnummer',
cancellationFax: 'Kündigungs-Faxnummer',
};
for (const k of ['contactPhone', 'contactFax', 'cancellationFax'] as const) {
if (out[k] === undefined) continue;
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
const v = sanitizePhoneField(out[k], phoneLabels[k]);
out[k] = v === undefined ? null : v;
}
for (const k of ['contactAddress', 'cancellationAddress'] as const) {
if (out[k] === undefined) continue;
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
out[k] = sanitizeNotes(out[k], 500);
}
return out;
}
export async function createProvider(data: {
name: string;
interface ProviderWritable {
name?: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
}) {
contactEmail?: string | null;
contactPhone?: string | null;
contactFax?: string | null;
contactAddress?: string | null;
cancellationEmail?: string | null;
cancellationFax?: string | null;
cancellationAddress?: string | null;
isActive?: boolean;
}
export async function createProvider(data: ProviderWritable & { name: string }) {
const clean = stripProviderStrings(data);
const portalUrl = assertValidPortalUrl(clean.portalUrl);
return prisma.provider.create({
data: {
...clean,
name: clean.name,
portalUrl,
isActive: true,
},
});
}
export async function updateProvider(
id: number,
data: {
name?: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
isActive?: boolean;
}
) {
export async function updateProvider(id: number, data: ProviderWritable) {
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
// damit Prisma nicht den alten Wert hält.
const updateData: typeof data = stripProviderStrings(data);
const updateData: any = stripProviderStrings(data);
if (data.portalUrl !== undefined) {
const validated = assertValidPortalUrl(data.portalUrl);
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
updateData.portalUrl = validated ?? null;
}
return prisma.provider.update({
where: { id },
+19
View File
@@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten**
- Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`,
`contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`,
`cancellationFax`, `cancellationAddress`. Postadressen als `TEXT`
(mehrzeilig), Rest `VARCHAR(191)`. Migration
`20260621100000_provider_contact_and_cancellation` mit `IF NOT EXISTS`.
- Modal „Anbieter bearbeiten" bekommt eine neue Sektion **Kontakt &
Kündigung** unterhalb der Auto-Login-Felder, getrennt in zwei
Untergruppen (Kontakt / Kündigung) mit kleinen Headern.
Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen
als `<textarea rows={3}>` mit `maxLength={500}`.
- Backend-Validierung: contactEmail/cancellationEmail laufen durch
`isValidEmail` (Header-Injection-Schutz für Mail-Templates),
contactPhone/contactFax/cancellationFax durch `sanitizePhoneField`
(kein CRLF/Control-Char), Postadressen durch `sanitizeNotes` mit
500-Cap.
- Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen
Felder beim Backup/Restore verloren.
- [x] **🔒 Pentest R87 Whitelist vor Sanitizer (silent-mutation-Schutz)**
- R87.1 (LOW): `stripHtml` lief im R86-Fix VOR der Whitelist.
Tags wurden still weggestrippt → 200 OK mit mutierten Werten,
@@ -292,6 +292,13 @@ function ProviderModal({
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
@@ -316,6 +323,13 @@ function ProviderModal({
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: '',
});
@@ -325,6 +339,13 @@ function ProviderModal({
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
contactEmail: '',
contactPhone: '',
contactFax: '',
contactAddress: '',
cancellationEmail: '',
cancellationFax: '',
cancellationAddress: '',
isActive: true,
currentPassword: '',
});
@@ -434,6 +455,75 @@ function ProviderModal({
/>
</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
+7
View File
@@ -396,6 +396,13 @@ export interface Provider {
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
contactEmail?: string;
contactPhone?: string;
contactFax?: string;
contactAddress?: string;
cancellationEmail?: string;
cancellationFax?: string;
cancellationAddress?: string;
isActive: boolean;
tariffs?: Tariff[];
_count?: {