Compare commits
58 Commits
da1934aa2d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b23ebeefc3 | |||
| 818f801939 | |||
| 386d206ff1 | |||
| 67d6fd4941 | |||
| 1680dcb0fe | |||
| a4895374b9 | |||
| ebaee024b6 | |||
| f1b05c56e5 | |||
| 5293af18a5 | |||
| 4ab0340473 | |||
| f1102a24b7 | |||
| c013e1e747 | |||
| caa283e66f | |||
| 18a2e1173b | |||
| 993f2d10f0 | |||
| f02824fe7d | |||
| 8b10316683 | |||
| 26959ec909 | |||
| c8b86ca9a7 | |||
| 0b7bb89ebc | |||
| 9274c0adaf | |||
| dfe2a4b241 | |||
| 194c86409f | |||
| 2becf6cb6a | |||
| 24e152b201 | |||
| a83358bbe6 | |||
| 5bb048c534 | |||
| b3469483ca | |||
| 8992bb7a5d | |||
| 246999be01 | |||
| 96a054aa1a | |||
| 36beac98c9 | |||
| 60851450f6 | |||
| 523eab30d5 | |||
| 2fee13d09e | |||
| 84cbf01706 | |||
| fcc3b04725 | |||
| 101369c205 | |||
| e792fe4185 | |||
| 7c18343a95 | |||
| 5508d59652 | |||
| 431792e8d9 | |||
| d5dd3f5e7f | |||
| a235c43f40 | |||
| 9cfd2e4a64 | |||
| ec577e6d76 | |||
| 30f528596c | |||
| 358688db9e | |||
| ffb0d81b6a | |||
| 25681075b4 | |||
| ad81a7c93e | |||
| 518139438e | |||
| 5fa9d4d4f3 | |||
| f4ac1c29db | |||
| 6b1d493f0b | |||
| 9482424ade | |||
| a023e96012 | |||
| 72de2f00f3 |
@@ -89,6 +89,14 @@ if ! npx prisma migrate deploy; then
|
|||||||
fi
|
fi
|
||||||
echo "[entrypoint] DB-Schema aktuell"
|
echo "[entrypoint] DB-Schema aktuell"
|
||||||
|
|
||||||
|
# Pentest 53.3 (2026-06-01): wenn ein veraltetes Image gestartet wird
|
||||||
|
# (kein `docker compose build` nach Schema-Änderung), fehlten neue Felder
|
||||||
|
# wie `areaCode` im generierten Prisma-Client → PUT/POST crash. `prisma
|
||||||
|
# generate` am Start regeneriert den Client gegen das aktuelle Schema
|
||||||
|
# und kostet ~5–10 s – tradeoff für Robustheit.
|
||||||
|
echo "[entrypoint] Prisma-Client regenerieren (falls Image älter als Schema)…"
|
||||||
|
npx prisma generate || echo "[entrypoint] prisma generate fehlgeschlagen – nicht kritisch, Client bleibt aus Image"
|
||||||
|
|
||||||
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
|
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
|
||||||
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
|
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
|
||||||
USER_COUNT=$(node -e "
|
USER_COUNT=$(node -e "
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* mehrfach aufrufbar.
|
* mehrfach aufrufbar.
|
||||||
*/
|
*/
|
||||||
import prisma from '../src/lib/prisma.js';
|
import prisma from '../src/lib/prisma.js';
|
||||||
import { stripHtml } from '../src/utils/sanitize.js';
|
import { stripHtml, isValidDocumentPath as isValidDocumentPathShared } from '../src/utils/sanitize.js';
|
||||||
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
|
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
|
||||||
|
|
||||||
const CUSTOMER_STRING_FIELDS = [
|
const CUSTOMER_STRING_FIELDS = [
|
||||||
@@ -93,18 +93,10 @@ const ALLOWED_CONSENT_SOURCES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
||||||
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
|
// Pentest 27.1 (2026-05-20). Helper jetzt zentral in utils/sanitize – wir
|
||||||
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
|
// re-exportieren hier nur, damit der bestehende Code keinen Schaden nimmt
|
||||||
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
|
// und wir nur EINE Quelle der Wahrheit pflegen müssen.
|
||||||
// raus (Pentest 2026-05-20 LOW 27.1).
|
const isValidDocumentPath = isValidDocumentPathShared;
|
||||||
function isValidDocumentPath(v: string | null | undefined): boolean {
|
|
||||||
if (!v) return true; // null/leer ist OK
|
|
||||||
if (v.includes('..')) return false;
|
|
||||||
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
|
||||||
if (/<[a-z!\/]/i.test(v)) return false; // HTML im Pfad
|
|
||||||
// erlaubt: "uploads/...", "/uploads/..."; keine Kontrollzeichen
|
|
||||||
return /^\/?uploads\/[A-Za-z0-9._\-\/]+$/.test(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupConsents() {
|
async function cleanupConsents() {
|
||||||
// version + documentPath: HTML strippen (waren ohne Validierung).
|
// version + documentPath: HTML strippen (waren ohne Validierung).
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Consent-Hash bekommt eine Ablauffrist (Pentest 57.7 MEDIUM).
|
||||||
|
-- Public-Consent-Links liefen vorher nie ab – DSGVO-Risiko, weil ein
|
||||||
|
-- weitergegebener Link Jahre später noch Einwilligungen erteilen konnte.
|
||||||
|
-- 30 Tage Default; nach Ablauf liefert getCustomerByConsentHash null.
|
||||||
|
-- Bestandsdaten ohne Ablaufzeit bekommen `NOW() + 30 Tage` als Frist,
|
||||||
|
-- damit existierende, frisch versendete Links nicht sofort tot sind.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher.
|
||||||
|
|
||||||
|
ALTER TABLE `Customer`
|
||||||
|
ADD COLUMN IF NOT EXISTS `consentHashExpiresAt` DATETIME(3) NULL;
|
||||||
|
|
||||||
|
UPDATE `Customer`
|
||||||
|
SET `consentHashExpiresAt` = DATE_ADD(NOW(), INTERVAL 30 DAY)
|
||||||
|
WHERE `consentHash` IS NOT NULL
|
||||||
|
AND `consentHashExpiresAt` IS NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- SIM-Karte bekommt ein optionales `isEsim`-Flag – Hardware-Plastikkarte
|
||||||
|
-- vs. eSIM-Profil. UI-Position: zwischen "Hauptkarte" und "Multisim".
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||||
|
-- `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
ALTER TABLE `SimCard`
|
||||||
|
ADD COLUMN IF NOT EXISTS `isEsim` BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Vertrieb-/Provider-Trennung: viele Plattformen vergeben eigene Kunden-/
|
||||||
|
-- Vertragsnummern, die nicht mit denen beim Endanbieter identisch sind.
|
||||||
|
-- Zwei neue optionale Felder unter "Anbieter & Tarif".
|
||||||
|
|
||||||
|
ALTER TABLE `Contract`
|
||||||
|
ADD COLUMN IF NOT EXISTS `customerNumberAtSalesPlatform` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `contractNumberAtSalesPlatform` VARCHAR(191) NULL;
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
-- Zusätzliche Weiterleitungs-E-Mails pro StressfreiEmail-Adresse.
|
||||||
|
-- JSON-Array (z.B. `["info@partner.de","cc@kanzlei.de"]`), wird beim
|
||||||
|
-- Sync zusammen mit customer.email + config.defaultForwardEmail an den
|
||||||
|
-- Provider gepusht (`set:`-Befehl überschreibt die Liste).
|
||||||
|
|
||||||
|
ALTER TABLE `StressfreiEmail`
|
||||||
|
ADD COLUMN IF NOT EXISTS `additionalForwardingEmails` TEXT NULL;
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
-- Zusätzliches optionales Feld unter "Anbieter & Tarif": Auftragsnummer bei
|
||||||
|
-- der Vertriebsplattform (vor der Kundennummer). Plattformen liefern beim
|
||||||
|
-- Abschluss oft eine eigene Auftrags-/Vorgangsnummer, die fürs Reklamations-
|
||||||
|
-- handling gebraucht wird.
|
||||||
|
|
||||||
|
ALTER TABLE `Contract`
|
||||||
|
ADD COLUMN IF NOT EXISTS `orderNumberAtSalesPlatform` VARCHAR(191) NULL;
|
||||||
+13
@@ -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;
|
||||||
@@ -157,7 +157,8 @@ model Customer {
|
|||||||
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
|
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
|
||||||
commercialRegisterNumber String? // Handelsregisternummer (Text)
|
commercialRegisterNumber String? // Handelsregisternummer (Text)
|
||||||
privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden)
|
privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden)
|
||||||
consentHash String? @unique // Permanenter Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
|
consentHash String? @unique // Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
|
||||||
|
consentHashExpiresAt DateTime? // Pentest 57.7: TTL für Public-Consent-Link (30 Tage Default); nach Ablauf ist getCustomerByConsentHash null und der Link muss neu generiert werden.
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
|
|
||||||
// ===== Portal-Zugangsdaten =====
|
// ===== Portal-Zugangsdaten =====
|
||||||
@@ -401,6 +402,12 @@ model StressfreiEmail {
|
|||||||
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
|
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
|
||||||
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
|
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
|
||||||
|
|
||||||
|
// Zusätzliche Weiterleitungsziele (über die Stamm-E-Mail des Kunden
|
||||||
|
// hinaus). Wird beim Sync zusammen mit customer.email +
|
||||||
|
// config.defaultForwardEmail an den Provider geschickt. JSON-Array
|
||||||
|
// von Strings, z.B. `["info@partner.de","cc@kanzlei.de"]`.
|
||||||
|
additionalForwardingEmails String? @db.Text
|
||||||
|
|
||||||
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
|
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
|
||||||
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
|
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -569,6 +576,15 @@ model Provider {
|
|||||||
portalUrl String? // Kundenkontourl (Login-Seite)
|
portalUrl String? // Kundenkontourl (Login-Seite)
|
||||||
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
|
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
|
||||||
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
|
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)
|
isActive Boolean @default(true)
|
||||||
tariffs Tariff[]
|
tariffs Tariff[]
|
||||||
contracts Contract[]
|
contracts Contract[]
|
||||||
@@ -686,6 +702,9 @@ model Contract {
|
|||||||
tariffName String?
|
tariffName String?
|
||||||
customerNumberAtProvider String?
|
customerNumberAtProvider String?
|
||||||
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
||||||
|
orderNumberAtSalesPlatform String? // Auftragsnummer bei der Vertriebsplattform
|
||||||
|
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
|
||||||
|
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
|
||||||
priceFirst12Months String? // Preis erste 12 Monate
|
priceFirst12Months String? // Preis erste 12 Monate
|
||||||
priceFrom13Months String? // Preis ab 13. Monat
|
priceFrom13Months String? // Preis ab 13. Monat
|
||||||
priceAfter24Months String? // Preis nach 24 Monaten
|
priceAfter24Months String? // Preis nach 24 Monaten
|
||||||
@@ -933,6 +952,7 @@ model SimCard {
|
|||||||
puk String? // PUK (verschlüsselt gespeichert)
|
puk String? // PUK (verschlüsselt gespeichert)
|
||||||
isMultisim Boolean @default(false) // Ist dies eine Multisim-Karte?
|
isMultisim Boolean @default(false) // Ist dies eine Multisim-Karte?
|
||||||
isMain Boolean @default(false) // Ist dies die Hauptkarte?
|
isMain Boolean @default(false) // Ist dies die Hauptkarte?
|
||||||
|
isEsim Boolean @default(false) // Ist dies eine eSIM?
|
||||||
// Tatsächlicher Nutzer der SIM-Karte (z.B. Mitarbeiter bei Firmenverträgen,
|
// Tatsächlicher Nutzer der SIM-Karte (z.B. Mitarbeiter bei Firmenverträgen,
|
||||||
// Kind bei Eltern-Vertrag) – kann vom Vertragsinhaber abweichen.
|
// Kind bei Eltern-Vertrag) – kann vom Vertragsinhaber abweichen.
|
||||||
cardUser String?
|
cardUser String?
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
|
|||||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
|
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate, assertSafePdf } from '../utils/sanitize.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||||
import { generateEmailPdf } from '../services/pdfService.js';
|
import { generateEmailPdf } from '../services/pdfService.js';
|
||||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||||
import { DocumentType } from '@prisma/client';
|
import { DocumentType } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -39,12 +41,80 @@ function parseDateParam(v: unknown): Date | undefined {
|
|||||||
return isNaN(d.getTime()) ? undefined : d;
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 91.1 (LOW, 2026-06-21): `accountId=abc` → `parseInt` = `NaN`
|
||||||
|
// → der Ternary gab `NaN` an den Service. `if (NaN)` ist falsy → der
|
||||||
|
// Postfach-Filter fiel weg, und der Vertrag zeigte Mails aus ALLEN
|
||||||
|
// Postfächern.
|
||||||
|
//
|
||||||
|
// Pentest 92 (LOW, 2026-06-21): Bei `accountId=abc` auf Vertrags-
|
||||||
|
// Endpunkten reichte das silent-undefined nicht – die Mailbox-Isolation
|
||||||
|
// brach (man sah Mails aus allen Postfächern statt 0). Strict-400, weil
|
||||||
|
// Verträge per Design IMMER ein bestimmtes Postfach meinen.
|
||||||
|
//
|
||||||
|
// Helper hat zwei Modi:
|
||||||
|
// - default (optional): fehlend/leer → undefined (kein Filter)
|
||||||
|
// invalid → 400
|
||||||
|
// - { required: true }: fehlend/leer → 400
|
||||||
|
// invalid → 400
|
||||||
|
// Bei 400 schreibt der Helper direkt die Response und gibt `null`
|
||||||
|
// zurück; der Caller bricht dann mit `return` ab.
|
||||||
|
function parsePositiveIntQuery(
|
||||||
|
v: unknown,
|
||||||
|
fieldLabel: string,
|
||||||
|
res: Response,
|
||||||
|
options?: { required?: boolean },
|
||||||
|
): number | undefined | null {
|
||||||
|
// Pentest 93.1 (INFO, 2026-06-21): `?accountId=` (explizit-leer) wurde
|
||||||
|
// wie `?accountId` weggelassen behandelt → 200 statt 400 auf optionalen
|
||||||
|
// Endpunkten. Spec sagt aber: leerer String ist KEINE gültige Zahl.
|
||||||
|
// Trennung jetzt strikt:
|
||||||
|
// - Param fehlt komplett (`undefined`) → "absent"
|
||||||
|
// - Param da, aber Wert leer/Whitespace/keine Zahl → invalid → 400
|
||||||
|
if (v === undefined) {
|
||||||
|
if (options?.required) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `${fieldLabel} ist erforderlich (positive Ganzzahl).`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof v !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `${fieldLabel} muss als Zahl übergeben werden.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = v.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `${fieldLabel} darf nicht leer sein – bitte weglassen oder positive Ganzzahl angeben.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const n = parseInt(trimmed, 10);
|
||||||
|
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `${fieldLabel} muss eine positive Ganzzahl sein.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
// E-Mails für einen Kunden abrufen
|
// E-Mails für einen Kunden abrufen
|
||||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
// Customer-Inbox: accountId ist legitim optional (cross-mailbox-Ansicht
|
||||||
|
// ist erwünscht), aber invalide Werte → 400.
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||||
|
if (stressfreiEmailId === null) return;
|
||||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||||
@@ -79,17 +149,26 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mails für einen Vertrag abrufen
|
// E-Mails für einen Vertrag abrufen.
|
||||||
|
// `accountId` (optional) schränkt zusätzlich auf ein bestimmtes Postfach
|
||||||
|
// ein – ohne, sieht man im Vertrags-Tab Mails aus ALLEN Postfächern des
|
||||||
|
// Kunden, die dem Vertrag zugeordnet sind (User-Bug 2026-06-21).
|
||||||
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
|
// Vertrags-Endpunkte sind per Design IMMER pro Postfach – fehlt
|
||||||
|
// accountId, ist die Abfrage semantisch ungültig. Strict-400.
|
||||||
|
// Frontend hat eh ein `enabled: !!selectedAccountId`-Guard.
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
|
||||||
|
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||||
|
|
||||||
const emails = await cachedEmailService.getCachedEmails({
|
const emails = await cachedEmailService.getCachedEmails({
|
||||||
contractId,
|
contractId,
|
||||||
|
stressfreiEmailId,
|
||||||
folder,
|
folder,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
@@ -236,13 +315,17 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
// E-Mail-Anzahl pro Ordner für einen Vertrag (optional pro Postfach)
|
||||||
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
// Wie getEmailsForContract: Postfach ist required (sonst zeigt der
|
||||||
|
// Badge eine andere Zahl als die Liste).
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
|
||||||
|
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
|
||||||
|
|
||||||
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
const counts = await cachedEmailService.getFolderCountsForContract(contractId, stressfreiEmailId);
|
||||||
|
|
||||||
res.json({ success: true, data: counts } as ApiResponse);
|
res.json({ success: true, data: counts } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -309,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
|
||||||
|
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
|
||||||
|
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
|
||||||
|
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
|
||||||
|
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
|
||||||
|
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
|
||||||
|
const MAX_ATTACHMENT_COUNT = 25;
|
||||||
|
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
|
||||||
|
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
|
||||||
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
|
interface AttachmentValidationError {
|
||||||
|
status: 400;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAttachments(
|
||||||
|
attachments: unknown,
|
||||||
|
): { ok: true } | AttachmentValidationError {
|
||||||
|
if (attachments === undefined) return { ok: true };
|
||||||
|
if (!Array.isArray(attachments)) {
|
||||||
|
return { status: 400, error: 'attachments muss ein Array sein.' };
|
||||||
|
}
|
||||||
|
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (let i = 0; i < attachments.length; i++) {
|
||||||
|
const a = attachments[i];
|
||||||
|
const label = `Anhang ${i + 1}`;
|
||||||
|
if (!a || typeof a !== 'object') {
|
||||||
|
return { status: 400, error: `${label} hat das falsche Format.` };
|
||||||
|
}
|
||||||
|
const filename = (a as Record<string, unknown>).filename;
|
||||||
|
const content = (a as Record<string, unknown>).content;
|
||||||
|
const contentType = (a as Record<string, unknown>).contentType;
|
||||||
|
if (typeof filename !== 'string' || filename.trim() === '') {
|
||||||
|
return { status: 400, error: `${label} hat keinen Dateinamen.` };
|
||||||
|
}
|
||||||
|
if (typeof content !== 'string' || content.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!BASE64_RE.test(content)) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (contentType !== undefined && typeof contentType !== 'string') {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
|
||||||
|
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
|
||||||
|
const approxBytes = Math.ceil(content.length * 0.75);
|
||||||
|
if (approxBytes > MAX_PER_FILE_BYTES) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
totalBytes += approxBytes;
|
||||||
|
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden
|
||||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -325,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
|
||||||
|
// (Format, Größe, Anzahl) – sonst leakte der rohe Node.js-Fehler
|
||||||
|
// in die Response und Limits waren nur Frontend-Doku.
|
||||||
|
const attachmentCheck = validateAttachments(attachments);
|
||||||
|
if (!('ok' in attachmentCheck)) {
|
||||||
|
res.status(attachmentCheck.status).json({
|
||||||
|
success: false,
|
||||||
|
error: attachmentCheck.error,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// StressfreiEmail laden
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||||
|
|
||||||
@@ -880,13 +1056,25 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
// ==================== TRASH OPERATIONS ====================
|
// ==================== TRASH OPERATIONS ====================
|
||||||
|
|
||||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
// Papierkorb-E-Mails für einen Kunden abrufen.
|
||||||
|
// Optional `accountId` (Postfach-Filter) und `contractId` (Vertrags-Filter)
|
||||||
|
// – beide aus User-Bug 2026-06-21. Wenn beide leer sind, Verhalten wie
|
||||||
|
// vorher: alle gelöschten E-Mails des Kunden.
|
||||||
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
// Trash auf Kundenebene: Filter sind optional (Cross-Mailbox-Trash-
|
||||||
|
// View ist legitim), invalide Werte → 400.
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||||
|
if (stressfreiEmailId === null) return;
|
||||||
|
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
|
||||||
|
if (contractId === null) return;
|
||||||
|
|
||||||
const emails = await cachedEmailService.getTrashEmails(customerId);
|
const emails = await cachedEmailService.getTrashEmails(customerId, {
|
||||||
|
stressfreiEmailId,
|
||||||
|
contractId,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: emails } as ApiResponse);
|
res.json({ success: true, data: emails } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -898,13 +1086,20 @@ export async function getTrashEmails(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-Anzahl für einen Kunden
|
// Papierkorb-Anzahl für einen Kunden (gleiche Filter wie getTrashEmails)
|
||||||
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
|
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||||
|
if (stressfreiEmailId === null) return;
|
||||||
|
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
|
||||||
|
if (contractId === null) return;
|
||||||
|
|
||||||
const count = await cachedEmailService.getTrashCount(customerId);
|
const count = await cachedEmailService.getTrashCount(customerId, {
|
||||||
|
stressfreiEmailId,
|
||||||
|
contractId,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: { count } } as ApiResponse);
|
res.json({ success: true, data: { count } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1281,6 +1476,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
|||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/${targetDir}/${newFilename}`;
|
const relativePath = `/uploads/${targetDir}/${newFilename}`;
|
||||||
|
|
||||||
|
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen (JS/Launch/Embed).
|
||||||
|
assertSafePdf(attachment.content);
|
||||||
|
|
||||||
// Datei speichern
|
// Datei speichern
|
||||||
fs.writeFileSync(filePath, attachment.content);
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
|
|
||||||
@@ -1431,8 +1629,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentTo error:', error);
|
console.error('saveAttachmentTo error:', error);
|
||||||
// Detailliertere Fehlermeldung für Debugging
|
// Detailliertere Fehlermeldung für Debugging
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
res.status(500).json({
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
|
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
@@ -1826,8 +2025,21 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
|||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
const { documentType, notes } = req.body;
|
const { documentType, notes } = req.body;
|
||||||
|
|
||||||
if (!documentType || typeof documentType !== 'string') {
|
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||||||
res.status(400).json({ success: false, error: 'documentType ist erforderlich' } as ApiResponse);
|
let validatedType: string;
|
||||||
|
try {
|
||||||
|
validatedType = validateContractDocumentType(documentType);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger documentType' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1871,33 +2083,40 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
|||||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
const newFilename = `${safeType}-email-${uniqueSuffix}.pdf`;
|
const newFilename = `${safeType}-email-${uniqueSuffix}.pdf`;
|
||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||||
|
|
||||||
fs.writeFileSync(filePath, pdfBuffer);
|
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
|
||||||
|
// PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
|
||||||
const doc = await prisma.contractDocument.create({
|
// Datei-Müll bei Race-Reject.
|
||||||
data: {
|
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||||||
contractId: contract.id,
|
fs.writeFileSync(filePath, pdfBuffer);
|
||||||
documentType,
|
return prisma.contractDocument.create({
|
||||||
documentPath: relativePath,
|
data: {
|
||||||
originalName: `${email.subject || 'email'}.pdf`,
|
contractId: contract.id,
|
||||||
notes: notes || null,
|
documentType: validatedType,
|
||||||
uploadedBy: (req as any).user?.email || 'email-import',
|
documentPath: relativePath,
|
||||||
},
|
originalName: `${email.subject || 'email'}.pdf`,
|
||||||
|
notes: sanitizeNotes(notes),
|
||||||
|
uploadedBy: (req as any).user?.email || 'email-import',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||||
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveEmailAsContractDocument error:', error);
|
console.error('saveEmailAsContractDocument error:', error);
|
||||||
|
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
||||||
|
// Lock) statt pauschal 500.
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
res.status(500).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2032,6 +2251,9 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
|||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/invoices/${newFilename}`;
|
const relativePath = `/uploads/invoices/${newFilename}`;
|
||||||
|
|
||||||
|
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
|
||||||
|
assertSafePdf(attachment.content);
|
||||||
|
|
||||||
// Datei speichern
|
// Datei speichern
|
||||||
fs.writeFileSync(filePath, attachment.content);
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
|
|
||||||
@@ -2056,8 +2278,9 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsInvoice error:', error);
|
console.error('saveAttachmentAsInvoice error:', error);
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
res.status(500).json({
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
@@ -2075,14 +2298,27 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
const { documentType, notes } = req.body;
|
const { documentType, notes } = req.body;
|
||||||
|
|
||||||
if (!documentType || typeof documentType !== 'string') {
|
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||||||
|
let validatedType: string;
|
||||||
|
try {
|
||||||
|
validatedType = validateContractDocumentType(documentType);
|
||||||
|
} catch (err) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'documentType ist erforderlich',
|
error: err instanceof Error ? err.message : 'Ungültiger documentType',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||||||
@@ -2168,33 +2404,40 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
|
|
||||||
const ext = path.extname(filename) || '.pdf';
|
const ext = path.extname(filename) || '.pdf';
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
|
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
|
||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||||
|
|
||||||
fs.writeFileSync(filePath, attachment.content);
|
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
|
||||||
|
assertSafePdf(attachment.content);
|
||||||
|
|
||||||
const doc = await prisma.contractDocument.create({
|
// Pentest 55.4: Lock vor Schreiben (siehe saveEmailAsContractDocument).
|
||||||
data: {
|
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||||||
contractId: contract.id,
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
documentType,
|
return prisma.contractDocument.create({
|
||||||
documentPath: relativePath,
|
data: {
|
||||||
originalName: filename,
|
contractId: contract.id,
|
||||||
notes: notes || null,
|
documentType: validatedType,
|
||||||
uploadedBy: (req as any).user?.email || 'email-import',
|
documentPath: relativePath,
|
||||||
},
|
originalName: filename,
|
||||||
|
notes: sanitizeNotes(notes),
|
||||||
|
uploadedBy: (req as any).user?.email || 'email-import',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||||
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsContractDocument error:', error);
|
console.error('saveAttachmentAsContractDocument error:', error);
|
||||||
|
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
res.status(500).json({
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
error: `Fehler beim Speichern: ${errorMessage}`,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as contractService from '../services/contract.service.js';
|
import * as contractService from '../services/contract.service.js';
|
||||||
import * as contractCockpitService from '../services/contractCockpit.service.js';
|
import * as contractCockpitService from '../services/contractCockpit.service.js';
|
||||||
@@ -7,9 +8,10 @@ import * as authorizationService from '../services/authorization.service.js';
|
|||||||
import { recordPredecessorFinalReading } from '../services/customer.service.js';
|
import { recordPredecessorFinalReading } from '../services/customer.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml } from '../utils/sanitize.js';
|
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
import { canAccessContract } from '../utils/accessControl.js';
|
||||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
|
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
|
||||||
@@ -34,6 +36,29 @@ function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
|
|||||||
if (body === null || body === undefined) return body;
|
if (body === null || body === undefined) return body;
|
||||||
if (typeof body === 'string') {
|
if (typeof body === 'string') {
|
||||||
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
|
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
|
||||||
|
// Pentest 86.1/86.2 (LOW, 2026-06-19): Längen- + Whitelist-Check auf
|
||||||
|
// Kunden-/Vertrags-/Auftragsnummer-Feldern. validateContractIdentifier
|
||||||
|
// wirft ApiError(400) bei Verstoß → saubere 400-Antwort statt 500.
|
||||||
|
//
|
||||||
|
// Pentest 87.1 (LOW, 2026-06-19): Identifier-Felder MÜSSEN gegen den
|
||||||
|
// Raw-Input geprüft werden, NICHT gegen den stripHtml-Output. Sonst
|
||||||
|
// verschluckt der Sanitizer Tag-Verstöße still: `<b>bold</b>` würde
|
||||||
|
// als `"bold"` mit 200 OK durchgehen, `<script>alert(1)</script>`
|
||||||
|
// sogar zu `null` und damit den vorherigen Wert überschreiben.
|
||||||
|
// Die strikte Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) deckt alle
|
||||||
|
// Bypässe ab, die stripHtml normalerweise auffangen würde
|
||||||
|
// (Tags, Schemes, Zero-Width-Chars, Homoglyphe, Percent-Encoding) –
|
||||||
|
// sie sind alle nicht in der Allowlist und fliegen mit 400 raus.
|
||||||
|
if (parentKey && isContractIdentifierField(parentKey)) {
|
||||||
|
return validateContractIdentifier(body, parentKey);
|
||||||
|
}
|
||||||
|
// Pentest 95.1/95.3/95.4 (LOW–MEDIUM, 2026-06-21): portalUsername
|
||||||
|
// (Manual-Modus) hatte gar keine Validierung – CRLF/Header-Injection,
|
||||||
|
// silent stripHtml-Mutation und VARCHAR-Overflow möglich. Gleiches
|
||||||
|
// Raw-Input-Pattern wie R87.
|
||||||
|
if (parentKey === 'portalUsername') {
|
||||||
|
return validatePortalUsername(body, parentKey);
|
||||||
|
}
|
||||||
return stripHtml(body);
|
return stripHtml(body);
|
||||||
}
|
}
|
||||||
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
|
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
|
||||||
@@ -181,6 +206,11 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth –
|
||||||
|
// canAccessContract explizit aufrufen, statt sich nur auf die
|
||||||
|
// Route-Permission zu verlassen. Portal-User mit kompromittierter
|
||||||
|
// Token-Permission würden sonst beliebige Verträge editieren können.
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
// Vorherigen Stand laden für Audit-Vergleich
|
// Vorherigen Stand laden für Audit-Vergleich
|
||||||
const before = await prisma.contract.findUnique({
|
const before = await prisma.contract.findUnique({
|
||||||
where: { id: contractId },
|
where: { id: contractId },
|
||||||
@@ -196,6 +226,10 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
||||||
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
||||||
|
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
|
||||||
|
orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform',
|
||||||
|
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
|
||||||
|
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
|
||||||
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
||||||
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
||||||
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
||||||
@@ -264,6 +298,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
export async function deleteContract(req: Request, res: Response): Promise<void> {
|
export async function deleteContract(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.3 (latent): Defense-in-Depth – Ownership-Check vor Delete.
|
||||||
|
if (!(await canAccessContract(req as AuthRequest, res, contractId))) return;
|
||||||
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
||||||
await contractService.deleteContract(contractId);
|
await contractService.deleteContract(contractId);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -714,7 +750,7 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const { documentType, notes, deliveryDate } = req.body;
|
const { documentType, notes } = req.body;
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
||||||
@@ -726,33 +762,62 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate validieren (ISO-8601 oder null).
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
||||||
const doc = await prisma.contractDocument.create({
|
// Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
|
||||||
data: {
|
// die Datei schon geschrieben – bei Reject räumen wir sie wieder weg.
|
||||||
contractId,
|
let cleanType: string;
|
||||||
documentType,
|
try {
|
||||||
documentPath,
|
cleanType = validateContractDocumentType(documentType);
|
||||||
originalName: req.file.originalname,
|
} catch (err) {
|
||||||
notes: notes || null,
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
uploadedBy: req.user?.email,
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger Dokumenttyp' } as ApiResponse);
|
||||||
},
|
return;
|
||||||
});
|
}
|
||||||
|
// Pentest 55.4: Race-Schutz – Lock + Recent-Duplicate-Check.
|
||||||
|
const doc = await withContractDocumentLock(contractId, cleanType, () =>
|
||||||
|
prisma.contractDocument.create({
|
||||||
|
data: {
|
||||||
|
contractId,
|
||||||
|
documentType: cleanType,
|
||||||
|
documentPath,
|
||||||
|
originalName: req.file!.originalname,
|
||||||
|
notes: sanitizeNotes(notes),
|
||||||
|
uploadedBy: req.user?.email,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'ContractDocument',
|
req, action: 'CREATE', resourceType: 'ContractDocument',
|
||||||
resourceId: doc.id.toString(),
|
resourceId: doc.id.toString(),
|
||||||
label: `Dokument "${documentType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
|
label: `Dokument "${cleanType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
|
||||||
details: { typ: documentType, datei: req.file.originalname },
|
details: { typ: cleanType, datei: req.file.originalname },
|
||||||
customerId: contract?.customerId,
|
customerId: contract?.customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||||
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
|
await maybeActivateOnDeliveryConfirmation(contractId, cleanType, req, deliveryDate);
|
||||||
|
|
||||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
||||||
|
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
||||||
|
// Schreibfehler.
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||||
|
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
||||||
|
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sanitizeCustomerStrict,
|
sanitizeCustomerStrict,
|
||||||
pickCustomerCreate,
|
pickCustomerCreate,
|
||||||
pickCustomerUpdate,
|
pickCustomerUpdate,
|
||||||
|
sanitizePhoneField,
|
||||||
isValidEmail,
|
isValidEmail,
|
||||||
} from '../utils/sanitize.js';
|
} from '../utils/sanitize.js';
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +91,20 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 60.3: Phone/Mobile auch beim Create gegen Header-Injection sichern.
|
||||||
|
try {
|
||||||
|
if ('phone' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.phone, 'Telefon');
|
||||||
|
data.phone = cleaned ?? null;
|
||||||
|
}
|
||||||
|
if ('mobile' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.mobile, 'Mobil');
|
||||||
|
data.mobile = cleaned ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Convert birthDate string to Date if present
|
// Convert birthDate string to Date if present
|
||||||
if (data.birthDate) {
|
if (data.birthDate) {
|
||||||
data.birthDate = new Date(data.birthDate);
|
data.birthDate = new Date(data.birthDate);
|
||||||
@@ -132,6 +147,23 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
const data: any = pickCustomerUpdate(req.body);
|
const data: any = pickCustomerUpdate(req.body);
|
||||||
|
|
||||||
|
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
|
||||||
|
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
||||||
|
// jetzt zusätzlich durch sanitizePhoneField (Allowlist).
|
||||||
|
try {
|
||||||
|
if ('phone' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.phone, 'Telefon');
|
||||||
|
data.phone = cleaned ?? null;
|
||||||
|
}
|
||||||
|
if ('mobile' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.mobile, 'Mobil');
|
||||||
|
data.mobile = cleaned ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
|
|
||||||
|
|||||||
@@ -87,48 +87,72 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
|||||||
// Stored-XSS gerendert.
|
// Stored-XSS gerendert.
|
||||||
//
|
//
|
||||||
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
||||||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
|
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
|
||||||
// ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
|
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
|
||||||
// Magic eines bekannten safe Typs (PDF, PNG, JPEG, GIF, WebP) zeigen.
|
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
|
||||||
// Bei Mismatch fällt's auf attachment zurück – Stored XSS bleibt
|
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
|
||||||
// weiterhin unmöglich.
|
// attachment zurück – Stored XSS bleibt weiterhin unmöglich.
|
||||||
|
//
|
||||||
|
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
|
||||||
|
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
|
||||||
|
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
|
||||||
|
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
|
||||||
|
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
|
||||||
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||||||
const wantsInline = req.query.disposition === 'inline';
|
const wantsInline = req.query.disposition === 'inline';
|
||||||
let useInline = false;
|
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
|
||||||
let inlineContentType: string | null = null;
|
|
||||||
if (wantsInline) {
|
if (wantsInline && !safeContentType) {
|
||||||
try {
|
console.warn(
|
||||||
const fd = fs.openSync(absolute, 'r');
|
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
|
||||||
const head = Buffer.alloc(12);
|
);
|
||||||
fs.readSync(fd, head, 0, 12, 0);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
if (head.subarray(0, 5).toString('latin1') === '%PDF-') {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'application/pdf';
|
|
||||||
} else if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'image/png';
|
|
||||||
} else if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'image/jpeg';
|
|
||||||
} else if (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
|
||||||
|| head.subarray(0, 6).toString('latin1') === 'GIF89a') {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'image/gif';
|
|
||||||
} else if (head.subarray(0, 4).toString('latin1') === 'RIFF'
|
|
||||||
&& head.subarray(8, 12).toString('latin1') === 'WEBP') {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'image/webp';
|
|
||||||
}
|
|
||||||
} catch { /* ignore – fällt auf attachment zurück */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
if (useInline && inlineContentType) {
|
if (safeContentType) {
|
||||||
res.setHeader('Content-Type', inlineContentType);
|
res.setHeader('Content-Type', safeContentType);
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
} else {
|
} else {
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
}
|
}
|
||||||
res.sendFile(absolute);
|
res.sendFile(absolute);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
|
||||||
|
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
|
||||||
|
* entspricht. Sonst `null` – dann wird die Datei als attachment serviert.
|
||||||
|
*
|
||||||
|
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
|
||||||
|
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
|
||||||
|
*/
|
||||||
|
function detectSafeContentType(absolute: string): string | null {
|
||||||
|
let fd: number | null = null;
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(absolute, 'r');
|
||||||
|
const head = Buffer.alloc(12);
|
||||||
|
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
|
||||||
|
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
|
||||||
|
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
|
||||||
|
if (bytesRead >= 8
|
||||||
|
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||||
|
) return 'image/png';
|
||||||
|
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
|
||||||
|
if (bytesRead >= 6
|
||||||
|
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
||||||
|
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
|
||||||
|
) return 'image/gif';
|
||||||
|
if (bytesRead >= 12
|
||||||
|
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||||
|
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
|
||||||
|
) return 'image/webp';
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) {
|
||||||
|
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as gdprService from '../services/gdpr.service.js';
|
|||||||
import * as consentService from '../services/consent.service.js';
|
import * as consentService from '../services/consent.service.js';
|
||||||
import * as consentPublicService from '../services/consent-public.service.js';
|
import * as consentPublicService from '../services/consent-public.service.js';
|
||||||
import * as appSettingService from '../services/appSetting.service.js';
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
|
import { getPublicUrl } from '../services/auth.service.js';
|
||||||
import { canAccessCustomer } from '../utils/accessControl.js';
|
import { canAccessCustomer } from '../utils/accessControl.js';
|
||||||
import { createAuditLog, logChange } from '../services/audit.service.js';
|
import { createAuditLog, logChange } from '../services/audit.service.js';
|
||||||
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
||||||
@@ -65,7 +66,20 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
// Pentest 56.5 (LOW, 2026-06-01): customerId muss als gültige Zahl
|
||||||
|
// aus dem Body kommen (Route hat kein :id-Segment) – und der Caller
|
||||||
|
// braucht Zugriff auf den Kunden. Ohne den Check konnte jemand mit
|
||||||
|
// gdpr:delete-Permission Löschanfragen für beliebige Kunden stellen
|
||||||
|
// (Insider-Sabotage durch Portal-Vertreter ohne Vollmacht).
|
||||||
|
const bodyCustomerId = req.body?.customerId;
|
||||||
|
const customerId = typeof bodyCustomerId === 'number'
|
||||||
|
? bodyCustomerId
|
||||||
|
: parseInt(bodyCustomerId);
|
||||||
|
if (!Number.isFinite(customerId) || customerId < 1) {
|
||||||
|
res.status(400).json({ success: false, error: 'customerId fehlt oder ungültig' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const { requestSource } = req.body;
|
const { requestSource } = req.body;
|
||||||
|
|
||||||
const request = await gdprService.createDeletionRequest({
|
const request = await gdprService.createDeletionRequest({
|
||||||
@@ -295,6 +309,18 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 24.6 (INFO, 2026-06-02): Portal-User durfte `PENDING`
|
||||||
|
// mitschicken und damit den Consent-Status auf den initialen System-
|
||||||
|
// Status zurücksetzen. PENDING ist nur intern (Default beim
|
||||||
|
// Customer-Anlegen); Portal darf nur GRANTED oder WITHDRAWN setzen.
|
||||||
|
// Verfälschte sonst die DSGVO-Auswertung.
|
||||||
|
if (status !== 'GRANTED' && status !== 'WITHDRAWN') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Portal-Einwilligungen dürfen nur auf GRANTED oder WITHDRAWN gesetzt werden.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const consentLabels: Record<string, string> = {
|
const consentLabels: Record<string, string> = {
|
||||||
DATA_PROCESSING: 'Datenverarbeitung',
|
DATA_PROCESSING: 'Datenverarbeitung',
|
||||||
MARKETING_EMAIL: 'E-Mail-Marketing',
|
MARKETING_EMAIL: 'E-Mail-Marketing',
|
||||||
@@ -559,7 +585,10 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
|
|||||||
|
|
||||||
// ConsentHash sicherstellen
|
// ConsentHash sicherstellen
|
||||||
const hash = await consentPublicService.ensureConsentHash(customerId);
|
const hash = await consentPublicService.ensureConsentHash(customerId);
|
||||||
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
|
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
|
||||||
|
// getPublicUrl – nimmt zuerst die admin-konfigurierte AppSetting
|
||||||
|
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
|
||||||
|
const baseUrl = await getPublicUrl();
|
||||||
const consentUrl = `${baseUrl}/datenschutz/${hash}`;
|
const consentUrl = `${baseUrl}/datenschutz/${hash}`;
|
||||||
|
|
||||||
// Bei E-Mail: tatsächlich senden
|
// Bei E-Mail: tatsächlich senden
|
||||||
@@ -702,7 +731,10 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
|||||||
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
|
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
|
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
|
||||||
|
// getPublicUrl – nimmt zuerst die admin-konfigurierte AppSetting
|
||||||
|
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
|
||||||
|
const baseUrl = await getPublicUrl();
|
||||||
const portalUrl = `${baseUrl}/privacy`;
|
const portalUrl = `${baseUrl}/privacy`;
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* Neue Rechnung hinzufügen
|
* Neue Rechnung hinzufügen
|
||||||
*/
|
*/
|
||||||
|
// Pentest 56.4 (MEDIUM, 2026-06-01): invoiceType wurde an manchen
|
||||||
|
// Endpunkten nicht gegen die Enum-Whitelist validiert; ein beliebiger
|
||||||
|
// String landete als invoiceType in der DB und konnte Frontend-
|
||||||
|
// Filter/Reports verwirren oder XSS in Audit-Labels einschleusen.
|
||||||
|
type ValidInvoiceType = 'INTERIM' | 'FINAL' | 'NOT_AVAILABLE';
|
||||||
|
const VALID_INVOICE_TYPES: Set<ValidInvoiceType> = new Set(['INTERIM', 'FINAL', 'NOT_AVAILABLE']);
|
||||||
|
function assertValidInvoiceType(value: unknown, res: Response): value is ValidInvoiceType {
|
||||||
|
if (typeof value !== 'string' || !VALID_INVOICE_TYPES.has(value as ValidInvoiceType)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Ungültiger Rechnungstyp. Erlaubt: ${[...VALID_INVOICE_TYPES].join(', ')}.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
@@ -66,6 +83,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!assertValidInvoiceType(invoiceType, res)) return;
|
||||||
|
|
||||||
const invoice = await invoiceService.addInvoice(ecdId, {
|
const invoice = await invoiceService.addInvoice(ecdId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
invoiceDate: new Date(invoiceDate),
|
||||||
@@ -99,6 +117,8 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||||
|
// 56.4: invoiceType ist beim Update optional – nur prüfen wenn gesetzt.
|
||||||
|
if (invoiceType !== undefined && !assertValidInvoiceType(invoiceType, res)) return;
|
||||||
|
|
||||||
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
||||||
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
|
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
|
||||||
@@ -168,6 +188,14 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
|
|||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
|
if (!invoiceDate || !invoiceType) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'invoiceDate und invoiceType sind erforderlich',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!assertValidInvoiceType(invoiceType, res)) return;
|
||||||
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
invoiceDate: new Date(invoiceDate),
|
||||||
invoiceType,
|
invoiceType,
|
||||||
|
|||||||
@@ -3,10 +3,33 @@ import * as stressfreiEmailService from '../services/stressfreiEmail.service.js'
|
|||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
|
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
// Pentest 71.3 (INFO): `parseInt(...)` ohne NaN-Check gab bei
|
||||||
|
// `/stressfrei-emails/abc/...` einen generischen 500 zurück.
|
||||||
|
//
|
||||||
|
// Pentest 77.3 (LOW): `Number.isInteger(parseInt(...))` ließ Floats
|
||||||
|
// und Exponential-Notation durch – `4.0`, `4.5`, `4e1` werden alle
|
||||||
|
// zu `4` geparst und treffen die echte ID 4. Fix: erst gegen
|
||||||
|
// `/^\d+$/` validieren, dann erst parsen.
|
||||||
|
function requireIdParam(req: AuthRequest, res: Response, paramName: string): number | null {
|
||||||
|
const raw = req.params[paramName];
|
||||||
|
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
|
||||||
|
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||||
|
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = requireIdParam(req, res, 'customerId');
|
||||||
|
if (customerId === null) return;
|
||||||
// requireCustomerAccess in der Route greift nicht ausreichend:
|
// requireCustomerAccess in der Route greift nicht ausreichend:
|
||||||
// Portal-User haben `customers:read` (für eigene Daten) und werden
|
// Portal-User haben `customers:read` (für eigene Daten) und werden
|
||||||
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
|
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
|
||||||
@@ -26,7 +49,8 @@ export async function getEmailsByCustomer(req: AuthRequest, res: Response): Prom
|
|||||||
|
|
||||||
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const email = await stressfreiEmailService.getEmailById(emailId);
|
const email = await stressfreiEmailService.getEmailById(emailId);
|
||||||
@@ -54,7 +78,8 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function createEmail(req: Request, res: Response): Promise<void> {
|
export async function createEmail(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = requireIdParam(req, res, 'customerId');
|
||||||
|
if (customerId === null) return;
|
||||||
const email = await stressfreiEmailService.createEmail({
|
const email = await stressfreiEmailService.createEmail({
|
||||||
...req.body,
|
...req.body,
|
||||||
customerId,
|
customerId,
|
||||||
@@ -67,7 +92,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: email } as ApiResponse);
|
res.status(201).json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||||
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
@@ -76,7 +102,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -86,7 +113,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||||
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
@@ -95,7 +123,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
await stressfreiEmailService.deleteEmail(emailId);
|
await stressfreiEmailService.deleteEmail(emailId);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -114,7 +143,8 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
|
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
|
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
|
||||||
@@ -151,9 +181,59 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusätzliche Weiterleitungs-E-Mails der StressfreiEmail neu setzen.
|
||||||
|
* Body: `{ emails: string[] }`. Liste ersetzt komplett, Provider wird
|
||||||
|
* unmittelbar nachgezogen.
|
||||||
|
*/
|
||||||
|
export async function updateAdditionalForwards(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
|
const body = req.body ?? {};
|
||||||
|
if (!Array.isArray(body.emails)) {
|
||||||
|
res.status(400).json({ success: false, error: '`emails` muss ein Array sein.' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (body.emails.length > 20) {
|
||||||
|
res.status(400).json({ success: false, error: 'Maximal 20 zusätzliche Weiterleitungen erlaubt.' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await stressfreiEmailService.setAdditionalForwards(emailId, body.emails);
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ success: false, error: result.error } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'StressfreiEmail',
|
||||||
|
resourceId: emailId.toString(),
|
||||||
|
label: `Zusatz-Weiterleitungen aktualisiert (${(result.forwardTargets || []).length} Ziele aktiv)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { forwardTargets: result.forwardTargets },
|
||||||
|
message: 'Weiterleitungen aktualisiert',
|
||||||
|
} as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
|
res.status(status).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
|
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
|
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
@@ -70,6 +70,20 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern.
|
||||||
|
try {
|
||||||
|
if ('whatsappNumber' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer');
|
||||||
|
data.whatsappNumber = cleaned ?? null;
|
||||||
|
}
|
||||||
|
if ('signalNumber' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer');
|
||||||
|
data.signalNumber = cleaned ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const user = await userService.createUser(data);
|
const user = await userService.createUser(data);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
@@ -114,13 +128,27 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
const data = pickUserUpdate(req.body);
|
const data = pickUserUpdate(req.body) as Record<string, unknown>;
|
||||||
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
|
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
|
||||||
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
||||||
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern.
|
||||||
|
try {
|
||||||
|
if ('whatsappNumber' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer');
|
||||||
|
data.whatsappNumber = cleaned ?? null;
|
||||||
|
}
|
||||||
|
if ('signalNumber' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer');
|
||||||
|
data.signalNumber = cleaned ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess /
|
// Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess /
|
||||||
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
|
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
|
||||||
|
|||||||
@@ -253,6 +253,17 @@ app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
|
|||||||
return (downloadFile as any)(req, res, next);
|
return (downloadFile as any)(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pentest 55.3 (HIGH, 2026-06-01): /uploads/contract-documents/*.pdf
|
||||||
|
// kam ungeschützt durch, weil der nginx-Reverse-Proxy die Dateien
|
||||||
|
// direkt aus dem Filesystem auslieferte und der Backend-Auth-Check
|
||||||
|
// nur bei /api/uploads/* griff. Defense-in-Depth: dieselbe Route auch
|
||||||
|
// ohne /api-Präfix freischalten – damit der Backend-Owner-Check immer
|
||||||
|
// läuft, egal wie nginx konfiguriert ist.
|
||||||
|
app.get('/uploads/*', authenticate as any, (req, res, next) => {
|
||||||
|
req.query.path = req.originalUrl.split('?')[0];
|
||||||
|
return (downloadFile as any)(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
|
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
|
||||||
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
|
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
|
||||||
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
|
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { assertSafePdf } from '../utils/sanitize.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strikte Variante: Datei MUSS eine PDF sein. Sonst 415. Für Routen, die
|
||||||
|
* ausschliesslich PDFs zulassen (z.B. Vollmacht-Upload, PDF-Templates).
|
||||||
|
*
|
||||||
|
* Routen, die auch JPG/PNG akzeptieren (z.B. contract.routes
|
||||||
|
* Vertragsdokumente), nutzen `validateUploadedFile` aus
|
||||||
|
* `uploadFileTypeValidator.ts` – das macht Magic-Byte für ALLE Typen +
|
||||||
|
* PDF-Scan in einer Pipeline.
|
||||||
|
*/
|
||||||
|
export function requireSafeUploadedPdf(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const file = (req as Request & { file?: Express.Multer.File }).file;
|
||||||
|
if (!file) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buf = fs.readFileSync(file.path);
|
||||||
|
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||||
|
throw new ApiError(415, 'Datei ist keine gültige PDF.');
|
||||||
|
}
|
||||||
|
assertSafePdf(buf);
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
const status = e instanceof ApiError ? e.statusCode : 415;
|
||||||
|
const message = e instanceof Error ? e.message : 'PDF ungültig';
|
||||||
|
res.status(status).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { assertSafePdf } from '../utils/sanitize.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magic-Byte-Whitelist + canonical Extension Rename + PDF-Active-Content-
|
||||||
|
* Scan in einer Middleware. Greift nach `multer.single(...)`, prüft die
|
||||||
|
* geschriebene Datei auf erlaubten Typ (PDF/JPG/PNG/GIF/WebP) und benennt
|
||||||
|
* sie auf eine kanonische Endung um – damit verschwindet die
|
||||||
|
* `evil.gif.php`-Doppel-Endung und der client-gemeldete mimetype wird
|
||||||
|
* durch den ERKANNTEN ersetzt.
|
||||||
|
*
|
||||||
|
* Historie:
|
||||||
|
* - Pentest 39.3 / 39.4 (2026-05-30): Magic-Byte + canonical Rename.
|
||||||
|
* - Pentest 68.1 (2026-06-03): PDF-Body-Scan auf JS/Launch/Embed/RichMedia.
|
||||||
|
* - Pentest 69.3 (2026-06-03): Wiederverwendung in contract.routes.ts
|
||||||
|
* (Vertragsdokumente) – vorher waren JPG/PNG-Uploads dort ungeprüft,
|
||||||
|
* nur durch Download-Layer kompensiert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PDF_MAGIC = Buffer.from('%PDF-', 'latin1');
|
||||||
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const GIF87 = Buffer.from('GIF87a', 'latin1');
|
||||||
|
const GIF89 = Buffer.from('GIF89a', 'latin1');
|
||||||
|
|
||||||
|
export function detectFileType(buf: Buffer): { mime: string; ext: string } | null {
|
||||||
|
if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' };
|
||||||
|
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' };
|
||||||
|
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' };
|
||||||
|
if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' };
|
||||||
|
if (buf.length >= 12
|
||||||
|
&& buf.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||||
|
&& buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUploadedFile(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const file = (req as Request & { file?: Express.Multer.File }).file;
|
||||||
|
if (!file) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(file.path, 'r');
|
||||||
|
const head = Buffer.alloc(12);
|
||||||
|
fs.readSync(fd, head, 0, 12, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
|
||||||
|
const detected = detectFileType(head);
|
||||||
|
if (!detected) {
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
res.status(415).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detected.mime === 'application/pdf') {
|
||||||
|
try {
|
||||||
|
const fullBuf = fs.readFileSync(file.path);
|
||||||
|
assertSafePdf(fullBuf);
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
const status = e instanceof ApiError ? e.statusCode : 415;
|
||||||
|
const msg = e instanceof Error ? e.message : 'PDF ungültig';
|
||||||
|
res.status(status).json({ success: false, error: msg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.dirname(file.path);
|
||||||
|
const base = path.basename(file.path).replace(/\.[^./]+(\.[^./]+)*$/, '');
|
||||||
|
const newName = base + detected.ext;
|
||||||
|
const newPath = path.join(dir, newName);
|
||||||
|
if (newPath !== file.path) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(file.path, newPath);
|
||||||
|
file.path = newPath;
|
||||||
|
file.filename = newName;
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
console.error('Upload-Rename fehlgeschlagen:', e);
|
||||||
|
res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.mimetype = detected.mime;
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Magic-Byte-Check fehlgeschlagen:', e);
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import fs from 'fs';
|
|||||||
import * as contractController from '../controllers/contract.controller.js';
|
import * as contractController from '../controllers/contract.controller.js';
|
||||||
import * as invoiceController from '../controllers/invoice.controller.js';
|
import * as invoiceController from '../controllers/invoice.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -22,11 +23,15 @@ const docUpload = multer({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const allowed = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
|
// Pentest 70.1: aligned mit validateUploadedFile-Whitelist
|
||||||
|
// (PDF/JPG/PNG/GIF/WebP). Multer-fileFilter ist nur "fast reject"
|
||||||
|
// anhand des client-gemeldeten MIME-Types; der echte Guard ist der
|
||||||
|
// Magic-Byte-Check in validateUploadedFile.
|
||||||
|
const allowed = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
if (allowed.includes(file.mimetype)) cb(null, true);
|
if (allowed.includes(file.mimetype)) cb(null, true);
|
||||||
else cb(new Error('Nur PDF, JPG und PNG Dateien sind erlaubt'));
|
else cb(new Error('Nur PDF, JPG, PNG, GIF oder WebP-Dateien sind erlaubt'));
|
||||||
},
|
},
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: 25 * 1024 * 1024 },
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/', authenticate, requirePermission('contracts:read'), contractController.getContracts);
|
router.get('/', authenticate, requirePermission('contracts:read'), contractController.getContracts);
|
||||||
@@ -54,7 +59,7 @@ router.post('/:id/invoices', authenticate, requirePermission('contracts:update')
|
|||||||
|
|
||||||
// Vertragsdokumente
|
// Vertragsdokumente
|
||||||
router.get('/:id/documents', authenticate, requirePermission('contracts:read'), contractController.getContractDocuments);
|
router.get('/:id/documents', authenticate, requirePermission('contracts:read'), contractController.getContractDocuments);
|
||||||
router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), contractController.uploadContractDocument);
|
router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), validateUploadedFile, contractController.uploadContractDocument);
|
||||||
router.delete('/:id/documents/:documentId', authenticate, requirePermission('contracts:update'), contractController.deleteContractDocument);
|
router.delete('/:id/documents/:documentId', authenticate, requirePermission('contracts:update'), contractController.deleteContractDocument);
|
||||||
|
|
||||||
// Folgezähler
|
// Folgezähler
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { requireSafeUploadedPdf } from '../middleware/pdfUploadSafety.js';
|
||||||
import * as gdprController from '../controllers/gdpr.controller.js';
|
import * as gdprController from '../controllers/gdpr.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -27,7 +28,7 @@ const authUpload = multer({
|
|||||||
cb(new Error('Nur PDF-Dateien sind erlaubt'));
|
cb(new Error('Nur PDF-Dateien sind erlaubt'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: 25 * 1024 * 1024 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Alle Routen erfordern Authentifizierung
|
// Alle Routen erfordern Authentifizierung
|
||||||
@@ -84,7 +85,7 @@ router.get('/customer/:customerId/authorizations', requirePermission('customers:
|
|||||||
router.post('/customer/:customerId/authorizations/:representativeId/send', requirePermission('customers:update'), gdprController.sendAuthorizationRequest);
|
router.post('/customer/:customerId/authorizations/:representativeId/send', requirePermission('customers:update'), gdprController.sendAuthorizationRequest);
|
||||||
router.post('/customer/:customerId/authorizations/:representativeId/grant', requirePermission('customers:update'), gdprController.grantAuthorization);
|
router.post('/customer/:customerId/authorizations/:representativeId/grant', requirePermission('customers:update'), gdprController.grantAuthorization);
|
||||||
router.post('/customer/:customerId/authorizations/:representativeId/withdraw', requirePermission('customers:update'), gdprController.withdrawAuthorization);
|
router.post('/customer/:customerId/authorizations/:representativeId/withdraw', requirePermission('customers:update'), gdprController.withdrawAuthorization);
|
||||||
router.post('/customer/:customerId/authorizations/:representativeId/upload', requirePermission('customers:update'), authUpload.single('document'), gdprController.uploadAuthorizationDocument);
|
router.post('/customer/:customerId/authorizations/:representativeId/upload', requirePermission('customers:update'), authUpload.single('document'), requireSafeUploadedPdf, gdprController.uploadAuthorizationDocument);
|
||||||
router.delete('/customer/:customerId/authorizations/:representativeId/document', requirePermission('customers:update'), gdprController.deleteAuthorizationDocument);
|
router.delete('/customer/:customerId/authorizations/:representativeId/document', requirePermission('customers:update'), gdprController.deleteAuthorizationDocument);
|
||||||
|
|
||||||
// Portal: Vollmachten
|
// Portal: Vollmachten
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { requireSafeUploadedPdf } from '../middleware/pdfUploadSafety.js';
|
||||||
import * as pdfTemplateController from '../controllers/pdfTemplate.controller.js';
|
import * as pdfTemplateController from '../controllers/pdfTemplate.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -34,7 +35,7 @@ router.use(authenticate);
|
|||||||
router.get('/', requirePermission('settings:read'), pdfTemplateController.getTemplates);
|
router.get('/', requirePermission('settings:read'), pdfTemplateController.getTemplates);
|
||||||
router.get('/crm-fields', requirePermission('settings:read'), pdfTemplateController.getCrmFields);
|
router.get('/crm-fields', requirePermission('settings:read'), pdfTemplateController.getCrmFields);
|
||||||
router.get('/:id', requirePermission('settings:read'), pdfTemplateController.getTemplate);
|
router.get('/:id', requirePermission('settings:read'), pdfTemplateController.getTemplate);
|
||||||
router.post('/', requirePermission('settings:update'), upload.single('template'), pdfTemplateController.createTemplate);
|
router.post('/', requirePermission('settings:update'), upload.single('template'), requireSafeUploadedPdf, pdfTemplateController.createTemplate);
|
||||||
router.put('/:id', requirePermission('settings:update'), pdfTemplateController.updateTemplate);
|
router.put('/:id', requirePermission('settings:update'), pdfTemplateController.updateTemplate);
|
||||||
router.delete('/:id', requirePermission('settings:update'), pdfTemplateController.deleteTemplate);
|
router.delete('/:id', requirePermission('settings:update'), pdfTemplateController.deleteTemplate);
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ router.post('/:id/reset-password', authenticate, requirePermission('customers:up
|
|||||||
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
|
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
|
||||||
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
|
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
|
||||||
|
|
||||||
|
// Zusätzliche Weiterleitungs-Ziele setzen (User-pflegbare Liste, zusätzlich
|
||||||
|
// zur Stamm-E-Mail des Kunden und der globalen Default-Forward-Adresse).
|
||||||
|
router.put('/:id/additional-forwards', authenticate, requirePermission('customers:update'), stressfreiEmailController.updateAdditionalForwards);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -6,7 +6,33 @@ import prisma from '../lib/prisma.js';
|
|||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
import {
|
||||||
|
canAccessContract,
|
||||||
|
canAccessCustomer,
|
||||||
|
canAccessBankCard,
|
||||||
|
canAccessIdentityDocument,
|
||||||
|
} from '../utils/accessControl.js';
|
||||||
|
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||||
|
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||||
|
|
||||||
|
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
||||||
|
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
|
||||||
|
// für saubere 404-Antwort + Datei-Cleanup, wenn die Resource nicht
|
||||||
|
// existiert. Anschließend laufen die `canAccess*`-Checks (Portal-User
|
||||||
|
// werden dort auf ihre eigenen Kunden eingeschränkt; Staff bekommen
|
||||||
|
// volle Sicht – konsistent mit der bestehenden Access-Control-Logik).
|
||||||
|
async function resolveInvoiceContractId(invoiceId: number): Promise<number | null> {
|
||||||
|
const invoice = await prisma.invoice.findUnique({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
select: { contractId: true, energyContractDetails: { select: { contractId: true } } },
|
||||||
|
});
|
||||||
|
return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupFile(filePath?: string) {
|
||||||
|
if (!filePath) return;
|
||||||
|
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -57,7 +83,10 @@ const upload = multer({
|
|||||||
storage,
|
storage,
|
||||||
fileFilter,
|
fileFilter,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB max
|
// 25 MB – passt für Ausweis-Scans, Handy-Photos im JpgToPdf-Flow,
|
||||||
|
// mehrseitige PDFs aus dem Modal (bis ~5-7 Seiten je nach Auflösung).
|
||||||
|
// Vorher 10 MB → Multer brach bei zwei Smartphone-Fotos ab.
|
||||||
|
fileSize: 25 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,84 +98,9 @@ function setUploadDir(subDir: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Magic-Byte-Whitelist + canonical Rename + PDF-Scan: siehe
|
||||||
* Post-Upload-Validierung: prüft die Magic-Bytes der gerade geschriebenen
|
// middleware/uploadFileTypeValidator.ts (zentralisiert, damit auch
|
||||||
* Datei und vergleicht mit dem fileFilter-Whitelist. Bei Mismatch
|
// contract.routes.ts denselben Check fahren kann – Pentest 69.3).
|
||||||
* (Pentest 2026-05-30 LOW 39.3: WebP/GIF/JPG/PDF-Spoofing) wird die
|
|
||||||
* Datei sofort gelöscht + 415 zurück.
|
|
||||||
*
|
|
||||||
* Zusätzlich (39.4): die Datei wird auf eine kanonische Endung umbenannt,
|
|
||||||
* die aus dem ERKANNTEN Typ abgeleitet ist – nicht aus dem
|
|
||||||
* client-gemeldeten file.originalname. Damit verschwindet die
|
|
||||||
* `evil.gif.php`-Doppel-Endung; gespeicherter Name ist
|
|
||||||
* `<timestamp-random>.<canonical-ext>` (z.B. `.pdf` / `.png`).
|
|
||||||
*/
|
|
||||||
const PDF_MAGIC = Buffer.from('%PDF-', 'latin1');
|
|
||||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
||||||
const GIF87 = Buffer.from('GIF87a', 'latin1');
|
|
||||||
const GIF89 = Buffer.from('GIF89a', 'latin1');
|
|
||||||
|
|
||||||
function detectType(buf: Buffer): { mime: string; ext: string } | null {
|
|
||||||
if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' };
|
|
||||||
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' };
|
|
||||||
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' };
|
|
||||||
if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' };
|
|
||||||
if (buf.length >= 12
|
|
||||||
&& buf.subarray(0, 4).toString('latin1') === 'RIFF'
|
|
||||||
&& buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateUploadedFile(req: AuthRequest, res: Response, next: Function) {
|
|
||||||
if (!req.file) return next();
|
|
||||||
try {
|
|
||||||
const fd = fs.openSync(req.file.path, 'r');
|
|
||||||
const head = Buffer.alloc(12);
|
|
||||||
fs.readSync(fd, head, 0, 12, 0);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
|
|
||||||
const detected = detectType(head);
|
|
||||||
if (!detected) {
|
|
||||||
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
|
||||||
res.status(415).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filename auf kanonische Extension normalisieren. Multer hat
|
|
||||||
// `<unique>.gif.php` o.ä. geschrieben – wir wollen `<unique>.gif`.
|
|
||||||
const dir = path.dirname(req.file.path);
|
|
||||||
const base = path.basename(req.file.path).replace(/\.[^./]+(\.[^./]+)*$/, '');
|
|
||||||
const newName = base + detected.ext;
|
|
||||||
const newPath = path.join(dir, newName);
|
|
||||||
if (newPath !== req.file.path) {
|
|
||||||
try {
|
|
||||||
fs.renameSync(req.file.path, newPath);
|
|
||||||
req.file.path = newPath;
|
|
||||||
req.file.filename = newName;
|
|
||||||
} catch (e) {
|
|
||||||
// Rename hat seltene Edge-Cases (Cross-Device). Sicherheit
|
|
||||||
// geht vor – sollte das fehlschlagen, werfen wir lieber 500
|
|
||||||
// und putzen die alte Datei.
|
|
||||||
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
|
||||||
console.error('Upload-Rename fehlgeschlagen:', e);
|
|
||||||
res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mimetype mit dem ERKANNTEN überschreiben, damit die Controller
|
|
||||||
// den korrekten Typ persistieren (falls sie ihn weiterreichen).
|
|
||||||
req.file.mimetype = detected.mime;
|
|
||||||
next();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Magic-Byte-Check fehlgeschlagen:', e);
|
|
||||||
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
|
||||||
res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload für Bankkarten-Dokumente
|
// Upload für Bankkarten-Dokumente
|
||||||
router.post(
|
router.post(
|
||||||
@@ -164,9 +118,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bankCardId = parseInt(req.params.id);
|
const bankCardId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||||
|
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
|
||||||
|
if (!card) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await canAccessBankCard(req, res, bankCardId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
|
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
|
||||||
|
|
||||||
// Bankkarte in der DB aktualisieren
|
|
||||||
await prisma.bankCard.update({
|
await prisma.bankCard.update({
|
||||||
where: { id: bankCardId },
|
where: { id: bankCardId },
|
||||||
data: { documentPath: relativePath },
|
data: { documentPath: relativePath },
|
||||||
@@ -183,6 +148,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,9 +170,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const documentId = parseInt(req.params.id);
|
const documentId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||||
|
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
|
||||||
|
if (!doc) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await canAccessIdentityDocument(req, res, documentId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const relativePath = `/uploads/documents/${req.file.filename}`;
|
const relativePath = `/uploads/documents/${req.file.filename}`;
|
||||||
|
|
||||||
// Ausweis in der DB aktualisieren
|
|
||||||
await prisma.identityDocument.update({
|
await prisma.identityDocument.update({
|
||||||
where: { id: documentId },
|
where: { id: documentId },
|
||||||
data: { documentPath: relativePath },
|
data: { documentPath: relativePath },
|
||||||
@@ -223,6 +200,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,6 +224,8 @@ router.delete(
|
|||||||
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessBankCard(req, res, bankCardId))) return;
|
||||||
|
|
||||||
if (!bankCard.documentPath) {
|
if (!bankCard.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
@@ -290,6 +270,8 @@ router.delete(
|
|||||||
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessIdentityDocument(req, res, documentId))) return;
|
||||||
|
|
||||||
if (!document.documentPath) {
|
if (!document.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
@@ -334,6 +316,11 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
|
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -383,6 +370,11 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
|
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -424,6 +416,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -464,6 +458,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -514,6 +510,14 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.2 (HIGH): Ownership-Check VOR Consent-Massen-Update.
|
||||||
|
// Ohne diese Prüfung konnte jeder Caller mit customers:update für
|
||||||
|
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
|
||||||
|
// (DSGVO-Eskalation).
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
|
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -574,6 +578,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -662,11 +668,18 @@ async function handleContractDocumentUpload(
|
|||||||
const dateField = fieldName === 'cancellationConfirmationPath'
|
const dateField = fieldName === 'cancellationConfirmationPath'
|
||||||
? 'cancellationConfirmationDate'
|
? 'cancellationConfirmationDate'
|
||||||
: 'cancellationConfirmationOptionsDate';
|
: 'cancellationConfirmationOptionsDate';
|
||||||
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
|
// Pentest 62.7: confirmationDate gegen ISO-8601 validieren.
|
||||||
|
let provided: string | null;
|
||||||
|
try {
|
||||||
|
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
|
||||||
|
} catch (err) {
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
let target: Date | null = null;
|
let target: Date | null = null;
|
||||||
if (provided) {
|
if (provided) {
|
||||||
const parsed = new Date(provided);
|
target = new Date(provided);
|
||||||
if (!isNaN(parsed.getTime())) target = parsed;
|
|
||||||
}
|
}
|
||||||
if (target) {
|
if (target) {
|
||||||
updateData[dateField] = target;
|
updateData[dateField] = target;
|
||||||
@@ -846,14 +859,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceId = parseInt(req.params.id);
|
const invoiceId = parseInt(req.params.id);
|
||||||
const relativePath = `/uploads/invoices/${req.file.filename}`;
|
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
|
||||||
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
|
||||||
|
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = `/uploads/invoices/${req.file.filename}`;
|
||||||
|
|
||||||
if (invoice.documentPath) {
|
if (invoice.documentPath) {
|
||||||
const oldPath = path.join(process.cwd(), invoice.documentPath);
|
const oldPath = path.join(process.cwd(), invoice.documentPath);
|
||||||
@@ -898,6 +917,9 @@ router.delete(
|
|||||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 56.1: Ownership-Check vor Delete.
|
||||||
|
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
|
||||||
|
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) return;
|
||||||
|
|
||||||
if (!invoice.documentPath) {
|
if (!invoice.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
|
|||||||
@@ -593,7 +593,13 @@ function generateResetToken(): string {
|
|||||||
* Hat Trailing-Slash-Bereinigung, sonst kommen Links wie
|
* Hat Trailing-Slash-Bereinigung, sonst kommen Links wie
|
||||||
* `https://crm.de//portal/login` zustande.
|
* `https://crm.de//portal/login` zustande.
|
||||||
*/
|
*/
|
||||||
async function getPublicUrl(): Promise<string> {
|
// Pentest 59.4 Nebenbefund (2026-06-01): Consent-URL kam mit
|
||||||
|
// `localhost:5173` raus, weil PUBLIC_URL nicht gesetzt war und
|
||||||
|
// req.headers.origin im Hintergrund-Pfad nicht greift. Helper jetzt
|
||||||
|
// EXPORT, damit auch der GDPR-Controller (sendConsentLink etc.)
|
||||||
|
// dieselbe Quelle der Wahrheit nutzt – inklusive admin-konfigurierbarer
|
||||||
|
// portalLoginUrl App-Setting.
|
||||||
|
export async function getPublicUrl(): Promise<string> {
|
||||||
const fromSettings = await appSettingService.getSetting('portalLoginUrl');
|
const fromSettings = await appSettingService.getSetting('portalLoginUrl');
|
||||||
const raw = (fromSettings && fromSettings.trim())
|
const raw = (fromSettings && fromSettings.trim())
|
||||||
|| process.env.PUBLIC_URL
|
|| process.env.PUBLIC_URL
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
|
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
|
||||||
@@ -53,6 +54,8 @@ export async function grantAuthorization(
|
|||||||
representativeId: number,
|
representativeId: number,
|
||||||
data: { source?: string; documentPath?: string; notes?: string }
|
data: { source?: string; documentPath?: string; notes?: string }
|
||||||
) {
|
) {
|
||||||
|
// Pentest 26.7 (Defense-in-Depth): documentPath nur als /uploads/<safe>.
|
||||||
|
assertValidDocumentPath(data.documentPath, 'documentPath');
|
||||||
return prisma.representativeAuthorization.upsert({
|
return prisma.representativeAuthorization.upsert({
|
||||||
where: {
|
where: {
|
||||||
customerId_representativeId: { customerId, representativeId },
|
customerId_representativeId: { customerId, representativeId },
|
||||||
|
|||||||
@@ -1040,20 +1040,41 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
|
|||||||
const finalBackupName = path.basename(finalBackupDir);
|
const finalBackupName = path.basename(finalBackupDir);
|
||||||
|
|
||||||
// ZIP entpacken – mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
|
// ZIP entpacken – mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
|
||||||
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
|
// Pentest 57.8 (2026-06-01): Mehrstufige Verteidigung gegen Path-
|
||||||
|
// Traversal-Varianten, die `path.resolve` allein eventuell durchlässt
|
||||||
|
// (z.B. Backslash-Mischformen auf Cross-OS, Null-Bytes, leere Namen,
|
||||||
|
// explizite `..`-Segmente). Plus Zip-Bomb-Schutz per Entry-Größenlimit.
|
||||||
const absBackupDir = path.resolve(finalBackupDir);
|
const absBackupDir = path.resolve(finalBackupDir);
|
||||||
fs.mkdirSync(absBackupDir, { recursive: true });
|
fs.mkdirSync(absBackupDir, { recursive: true });
|
||||||
|
const MAX_ENTRY_SIZE = 500 * 1024 * 1024; // 500 MB pro Entry
|
||||||
|
let totalUncompressed = 0;
|
||||||
|
const MAX_TOTAL_UNCOMPRESSED = 5 * 1024 * 1024 * 1024; // 5 GB Gesamt
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
// Pfade mit absoluten Pfaden oder Traversal ablehnen
|
|
||||||
const entryName = entry.entryName;
|
const entryName = entry.entryName;
|
||||||
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
|
// Reject: leer, Null-Byte, absoluter Pfad, Backslashes (Cross-OS-
|
||||||
|
// Confusion), expliziter `..`-Segment im Original-Namen,
|
||||||
|
// Home-Dir-Expansion `~/`.
|
||||||
|
if (
|
||||||
|
!entryName
|
||||||
|
|| entryName.includes('\0')
|
||||||
|
|| entryName.includes('\\')
|
||||||
|
|| entryName.startsWith('~')
|
||||||
|
|| path.isAbsolute(entryName)
|
||||||
|
|| entryName.split('/').some((seg) => seg === '..')
|
||||||
|
) {
|
||||||
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
|
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPath = path.resolve(absBackupDir, entryName);
|
const targetPath = path.resolve(absBackupDir, entryName);
|
||||||
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
|
// Zip-Slip-Check: aufgelöster Pfad muss strikt im Backup-Verzeichnis
|
||||||
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
|
// liegen. path.relative gibt "../..." zurück wenn target außerhalb
|
||||||
|
// liegt – das ist robuster als startsWith + Separator-Concat.
|
||||||
|
const rel = path.relative(absBackupDir, targetPath);
|
||||||
|
if (rel === '' && !entry.isDirectory) {
|
||||||
|
return { success: false, error: `Datei-Eintrag zeigt auf das Backup-Wurzelverzeichnis` };
|
||||||
|
}
|
||||||
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
|
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
|
||||||
@@ -1063,6 +1084,16 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
|
|||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
fs.mkdirSync(targetPath, { recursive: true });
|
fs.mkdirSync(targetPath, { recursive: true });
|
||||||
} else {
|
} else {
|
||||||
|
// Zip-Bomb-Schutz: Entry-Größe begrenzen und Gesamt-Tracking
|
||||||
|
if (typeof entry.header?.size === 'number') {
|
||||||
|
if (entry.header.size > MAX_ENTRY_SIZE) {
|
||||||
|
return { success: false, error: `Eintrag "${entryName}" überschreitet das Größenlimit von 500 MB` };
|
||||||
|
}
|
||||||
|
totalUncompressed += entry.header.size;
|
||||||
|
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
|
||||||
|
return { success: false, error: `Backup-ZIP überschreitet das entpackte Gesamtlimit von 5 GB (Zip-Bomb-Schutz)` };
|
||||||
|
}
|
||||||
|
}
|
||||||
// Zielverzeichnis sicherstellen
|
// Zielverzeichnis sicherstellen
|
||||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
// Datei schreiben
|
// Datei schreiben
|
||||||
|
|||||||
@@ -603,33 +603,35 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
|||||||
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails)
|
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
|
||||||
export async function getFolderCountsForContract(contractId: number): Promise<{
|
// Optional auf ein bestimmtes Postfach einschränken. Bug 2026-06-21:
|
||||||
|
// vorher zählten die Badges Mails aus ALLEN Postfächern, während die
|
||||||
|
// Liste (nach Fix) nur die des ausgewählten Postfachs zeigt – Badge
|
||||||
|
// und Liste liefen auseinander. Trash mit reingenommen, weil der
|
||||||
|
// Contract-Trash-Badge sonst wieder auf account-globalen Zähler
|
||||||
|
// zurückfallen müsste.
|
||||||
|
export async function getFolderCountsForContract(
|
||||||
|
contractId: number,
|
||||||
|
stressfreiEmailId?: number,
|
||||||
|
): Promise<{
|
||||||
inbox: number;
|
inbox: number;
|
||||||
inboxUnread: number;
|
inboxUnread: number;
|
||||||
sent: number;
|
sent: number;
|
||||||
sentUnread: number;
|
sentUnread: number;
|
||||||
|
trash: number;
|
||||||
|
trashUnread: number;
|
||||||
}> {
|
}> {
|
||||||
const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([
|
const baseWhere: Prisma.CachedEmailWhereInput = { contractId };
|
||||||
// INBOX total
|
if (stressfreiEmailId) baseWhere.stressfreiEmailId = stressfreiEmailId;
|
||||||
prisma.cachedEmail.count({
|
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
|
||||||
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false },
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false } }),
|
||||||
}),
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false, isRead: false } }),
|
||||||
// INBOX unread
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false } }),
|
||||||
prisma.cachedEmail.count({
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false, isRead: false } }),
|
||||||
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false },
|
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true } }),
|
||||||
}),
|
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true, isRead: false } }),
|
||||||
// SENT total
|
|
||||||
prisma.cachedEmail.count({
|
|
||||||
where: { contractId, folder: EmailFolder.SENT, isDeleted: false },
|
|
||||||
}),
|
|
||||||
// SENT unread
|
|
||||||
prisma.cachedEmail.count({
|
|
||||||
where: { contractId, folder: EmailFolder.SENT, isDeleted: false, isRead: false },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||||
return { inbox, inboxUnread, sent, sentUnread };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle StressfreiEmails eines Kunden mit Mailbox
|
// Alle StressfreiEmails eines Kunden mit Mailbox
|
||||||
@@ -904,14 +906,26 @@ export async function permanentDeleteEmail(id: number): Promise<TrashOperationRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
// Papierkorb-E-Mails für einen Kunden abrufen
|
||||||
export async function getTrashEmails(customerId: number): Promise<CachedEmailWithRelations[]> {
|
// Optional: nach Postfach (stressfreiEmailId) und/oder Vertrag (contractId)
|
||||||
|
// einschränken. Vorher zeigte der Papierkorb immer ALLE gelöschten E-Mails
|
||||||
|
// des Kunden, unabhängig von welchem Postfach man gerade angemeldet ist –
|
||||||
|
// User-Bug 2026-06-21.
|
||||||
|
export async function getTrashEmails(
|
||||||
|
customerId: number,
|
||||||
|
options?: { stressfreiEmailId?: number; contractId?: number },
|
||||||
|
): Promise<CachedEmailWithRelations[]> {
|
||||||
|
const where: Prisma.CachedEmailWhereInput = {
|
||||||
|
isDeleted: true,
|
||||||
|
stressfreiEmail: { customerId },
|
||||||
|
};
|
||||||
|
if (options?.stressfreiEmailId) {
|
||||||
|
where.stressfreiEmailId = options.stressfreiEmailId;
|
||||||
|
}
|
||||||
|
if (options?.contractId) {
|
||||||
|
where.contractId = options.contractId;
|
||||||
|
}
|
||||||
return prisma.cachedEmail.findMany({
|
return prisma.cachedEmail.findMany({
|
||||||
where: {
|
where,
|
||||||
isDeleted: true,
|
|
||||||
stressfreiEmail: {
|
|
||||||
customerId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
stressfreiEmail: {
|
stressfreiEmail: {
|
||||||
select: {
|
select: {
|
||||||
@@ -931,16 +945,22 @@ export async function getTrashEmails(customerId: number): Promise<CachedEmailWit
|
|||||||
}) as Promise<CachedEmailWithRelations[]>;
|
}) as Promise<CachedEmailWithRelations[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-E-Mails zählen
|
// Papierkorb-E-Mails zählen (gleiche Filter wie getTrashEmails)
|
||||||
export async function getTrashCount(customerId: number): Promise<number> {
|
export async function getTrashCount(
|
||||||
return prisma.cachedEmail.count({
|
customerId: number,
|
||||||
where: {
|
options?: { stressfreiEmailId?: number; contractId?: number },
|
||||||
isDeleted: true,
|
): Promise<number> {
|
||||||
stressfreiEmail: {
|
const where: Prisma.CachedEmailWhereInput = {
|
||||||
customerId,
|
isDeleted: true,
|
||||||
},
|
stressfreiEmail: { customerId },
|
||||||
},
|
};
|
||||||
});
|
if (options?.stressfreiEmailId) {
|
||||||
|
where.stressfreiEmailId = options.stressfreiEmailId;
|
||||||
|
}
|
||||||
|
if (options?.contractId) {
|
||||||
|
where.contractId = options.contractId;
|
||||||
|
}
|
||||||
|
return prisma.cachedEmail.count({ where });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
|
// Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
|
||||||
|
|||||||
@@ -5,8 +5,19 @@ import * as consentService from './consent.service.js';
|
|||||||
import * as appSettingService from './appSetting.service.js';
|
import * as appSettingService from './appSetting.service.js';
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
|
|
||||||
|
// Pentest 57.7 (MEDIUM, 2026-06-01): Public-Consent-Hashes hatten keine
|
||||||
|
// Ablauffrist. Ein versehentlich weitergegebener oder geleakter Link
|
||||||
|
// hätte Jahre später noch fremde Einwilligungen erteilen können
|
||||||
|
// (DSGVO-Pflicht zur Zweckbindung). 30 Tage ist der Default-Zeitraum,
|
||||||
|
// in dem ein Kunde realistisch auf den Versandlink klickt; danach muss
|
||||||
|
// ein Mitarbeiter den Link neu generieren (ensureConsentHash() erzeugt
|
||||||
|
// einen neuen Hash + neue Frist).
|
||||||
|
const CONSENT_HASH_TTL_DAYS = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kunden-Lookup per consentHash
|
* Kunden-Lookup per consentHash. Liefert null wenn der Hash unbekannt
|
||||||
|
* oder abgelaufen ist – aus Sicht des Aufrufers identisch, damit der
|
||||||
|
* Public-Endpoint keine Unterscheidung "ungültig vs. abgelaufen" leakt.
|
||||||
*/
|
*/
|
||||||
export async function getCustomerByConsentHash(hash: string) {
|
export async function getCustomerByConsentHash(hash: string) {
|
||||||
const customer = await prisma.customer.findUnique({
|
const customer = await prisma.customer.findUnique({
|
||||||
@@ -18,28 +29,40 @@ export async function getCustomerByConsentHash(hash: string) {
|
|||||||
customerNumber: true,
|
customerNumber: true,
|
||||||
salutation: true,
|
salutation: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
consentHashExpiresAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!customer) return null;
|
if (!customer) return null;
|
||||||
|
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const consents = await consentService.getCustomerConsents(customer.id);
|
const consents = await consentService.getCustomerConsents(customer.id);
|
||||||
|
|
||||||
return { customer, consents };
|
// consentHashExpiresAt nicht an den Client durchreichen
|
||||||
|
const { consentHashExpiresAt: _expires, ...customerWithoutExpiry } = customer;
|
||||||
|
void _expires;
|
||||||
|
return { customer: customerWithoutExpiry, consents };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle 4 Einwilligungen über den öffentlichen Link erteilen
|
* Alle 4 Einwilligungen über den öffentlichen Link erteilen.
|
||||||
|
* Wirft bei abgelaufenem oder unbekanntem Hash mit gleicher Meldung,
|
||||||
|
* damit kein Oracle "existiert vs. abgelaufen" entsteht.
|
||||||
*/
|
*/
|
||||||
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
|
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
|
||||||
const customer = await prisma.customer.findUnique({
|
const customer = await prisma.customer.findUnique({
|
||||||
where: { consentHash: hash },
|
where: { consentHash: hash },
|
||||||
select: { id: true, firstName: true, lastName: true },
|
select: { id: true, firstName: true, lastName: true, consentHashExpiresAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new Error('Ungültiger Link');
|
throw new Error('Ungültiger Link');
|
||||||
}
|
}
|
||||||
|
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
|
||||||
|
throw new Error('Link ist abgelaufen. Bitte einen neuen Link anfordern.');
|
||||||
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const type of Object.values(ConsentType)) {
|
for (const type of Object.values(ConsentType)) {
|
||||||
@@ -56,26 +79,33 @@ export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* consentHash generieren falls nicht vorhanden
|
* consentHash generieren oder erneuern. Liefert einen bestehenden Hash
|
||||||
|
* nur zurück, wenn dessen TTL noch nicht abgelaufen ist – sonst wird ein
|
||||||
|
* neuer Hash + neue Frist gesetzt (Pentest 57.7).
|
||||||
*/
|
*/
|
||||||
export async function ensureConsentHash(customerId: number): Promise<string> {
|
export async function ensureConsentHash(customerId: number): Promise<string> {
|
||||||
const customer = await prisma.customer.findUnique({
|
const customer = await prisma.customer.findUnique({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
select: { consentHash: true },
|
select: { consentHash: true, consentHashExpiresAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new Error('Kunde nicht gefunden');
|
throw new Error('Kunde nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customer.consentHash) {
|
const stillValid = customer.consentHash
|
||||||
return customer.consentHash;
|
&& customer.consentHashExpiresAt
|
||||||
|
&& customer.consentHashExpiresAt.getTime() > Date.now();
|
||||||
|
|
||||||
|
if (stillValid) {
|
||||||
|
return customer.consentHash!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = crypto.randomUUID();
|
const hash = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + CONSENT_HASH_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||||
await prisma.customer.update({
|
await prisma.customer.update({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
data: { consentHash: hash },
|
data: { consentHash: hash, consentHashExpiresAt: expiresAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
return hash;
|
return hash;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ConsentType, ConsentStatus } from '@prisma/client';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||||||
|
|
||||||
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
|
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
|
||||||
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
|
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
|
||||||
@@ -80,6 +81,12 @@ export async function updateConsent(
|
|||||||
throw new Error('Kunde nicht gefunden');
|
throw new Error('Kunde nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 26.7: documentPath darf nur ein gültiger /uploads/<safe>-Pfad
|
||||||
|
// sein. Aktuell hat KEIN Endpoint diesen Wert aus User-Input gemappt
|
||||||
|
// (Portal: nicht aus Body, Admin-Auth-Upload: server-generated). Diese
|
||||||
|
// Service-Side-Validation ist Defense-in-Depth gegen zukünftige Caller.
|
||||||
|
assertValidDocumentPath(data.documentPath, 'documentPath');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updateData = {
|
const updateData = {
|
||||||
status: data.status,
|
status: data.status,
|
||||||
|
|||||||
@@ -2,25 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||||||
import { encrypt, decrypt } from '../utils/encryption.js';
|
import { encrypt, decrypt } from '../utils/encryption.js';
|
||||||
import { sanitizeCustomerStrict } from '../utils/sanitize.js';
|
import { sanitizeCustomerStrict, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
|
|
||||||
// Pentest 51.3 (LOW, 2026-06-01): Telefon-/Vorwahl-Felder dürfen NIE CRLF
|
|
||||||
// oder andere Control-Chars enthalten – sonst könnten sie über Header-
|
|
||||||
// Injection (Mail, HTTP) missbraucht werden, wenn der Wert mal in einen
|
|
||||||
// Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf
|
|
||||||
// literales Space beschränkt, NICHT `\s` – das matched sonst `\r\n\t`
|
|
||||||
// und macht den Schutz wirkungslos. Allowed: Ziffern, Plus, Minus, Slash,
|
|
||||||
// Klammern, Punkt, einfaches Leerzeichen. Bis 40 Zeichen.
|
|
||||||
const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/;
|
|
||||||
function sanitizePhoneField(raw: string | null | undefined, fieldLabel: string): string | undefined {
|
|
||||||
if (raw == null) return undefined;
|
|
||||||
const trimmed = String(raw).trim();
|
|
||||||
if (trimmed === '') return undefined;
|
|
||||||
if (!PHONE_FIELD_ALLOWED.test(trimmed)) {
|
|
||||||
throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`);
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContractFilters {
|
export interface ContractFilters {
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
@@ -221,6 +203,9 @@ interface ContractCreateData {
|
|||||||
providerName?: string;
|
providerName?: string;
|
||||||
tariffName?: string;
|
tariffName?: string;
|
||||||
customerNumberAtProvider?: string;
|
customerNumberAtProvider?: string;
|
||||||
|
orderNumberAtSalesPlatform?: string;
|
||||||
|
customerNumberAtSalesPlatform?: string;
|
||||||
|
contractNumberAtSalesPlatform?: string;
|
||||||
priceFirst12Months?: string;
|
priceFirst12Months?: string;
|
||||||
priceFrom13Months?: string;
|
priceFrom13Months?: string;
|
||||||
priceAfter24Months?: string;
|
priceAfter24Months?: string;
|
||||||
@@ -294,6 +279,7 @@ interface ContractCreateData {
|
|||||||
puk?: string;
|
puk?: string;
|
||||||
isMultisim?: boolean;
|
isMultisim?: boolean;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
|
isEsim?: boolean;
|
||||||
cardUser?: string;
|
cardUser?: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
@@ -399,6 +385,7 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
||||||
isMultisim: sc.isMultisim ?? false,
|
isMultisim: sc.isMultisim ?? false,
|
||||||
isMain: sc.isMain ?? false,
|
isMain: sc.isMain ?? false,
|
||||||
|
isEsim: sc.isEsim ?? false,
|
||||||
cardUser: sc.cardUser,
|
cardUser: sc.cardUser,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -638,6 +625,7 @@ export async function updateContract(
|
|||||||
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
|
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
|
||||||
isMultisim: sc.isMultisim ?? false,
|
isMultisim: sc.isMultisim ?? false,
|
||||||
isMain: sc.isMain ?? false,
|
isMain: sc.isMain ?? false,
|
||||||
|
isEsim: sc.isEsim ?? false,
|
||||||
cardUser: sc.cardUser,
|
cardUser: sc.cardUser,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -657,6 +645,7 @@ export async function updateContract(
|
|||||||
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
||||||
isMultisim: sc.isMultisim ?? false,
|
isMultisim: sc.isMultisim ?? false,
|
||||||
isMain: sc.isMain ?? false,
|
isMain: sc.isMain ?? false,
|
||||||
|
isEsim: sc.isEsim ?? false,
|
||||||
cardUser: sc.cardUser,
|
cardUser: sc.cardUser,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -784,6 +773,7 @@ export async function createFollowUpContract(previousContractId: number) {
|
|||||||
simCardNumber: sc.simCardNumber ?? undefined,
|
simCardNumber: sc.simCardNumber ?? undefined,
|
||||||
isMultisim: sc.isMultisim,
|
isMultisim: sc.isMultisim,
|
||||||
isMain: sc.isMain,
|
isMain: sc.isMain,
|
||||||
|
isEsim: sc.isEsim,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -909,6 +899,10 @@ export async function createRenewalContract(previousContractId: number) {
|
|||||||
providerName: previousContract.providerName,
|
providerName: previousContract.providerName,
|
||||||
tariffName: previousContract.tariffName,
|
tariffName: previousContract.tariffName,
|
||||||
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
||||||
|
contractNumberAtProvider: previousContract.contractNumberAtProvider,
|
||||||
|
orderNumberAtSalesPlatform: previousContract.orderNumberAtSalesPlatform,
|
||||||
|
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
|
||||||
|
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
|
||||||
portalUsername: previousContract.portalUsername,
|
portalUsername: previousContract.portalUsername,
|
||||||
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
||||||
commission: previousContract.commission,
|
commission: previousContract.commission,
|
||||||
@@ -1008,6 +1002,7 @@ export async function createRenewalContract(previousContractId: number) {
|
|||||||
simCardNumber: sc.simCardNumber,
|
simCardNumber: sc.simCardNumber,
|
||||||
isMultisim: sc.isMultisim,
|
isMultisim: sc.isMultisim,
|
||||||
isMain: sc.isMain,
|
isMain: sc.isMain,
|
||||||
|
isEsim: sc.isEsim,
|
||||||
pin: sc.pin,
|
pin: sc.pin,
|
||||||
puk: sc.puk,
|
puk: sc.puk,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { createAuditLog, logChange } from './audit.service.js';
|
import { createAuditLog, logChange } from './audit.service.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
async function runExpireCheck(): Promise<void> {
|
async function runExpireCheck(): Promise<void> {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -84,6 +85,61 @@ export function startContractStatusScheduler(): void {
|
|||||||
|
|
||||||
export { runExpireCheck };
|
export { runExpireCheck };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pentest 55.4 (LOW, 2026-06-01): 5 parallele Lieferbestätigung-Requests
|
||||||
|
* erzeugten 5 ContractDocuments. Application-Lock per (contractId,
|
||||||
|
* documentType) verhindert das in der Praxis (single-instance) und bietet
|
||||||
|
* für Cluster wenigstens eine deutliche Verzögerung gegen Spam-Sprays.
|
||||||
|
*
|
||||||
|
* Plus DB-Check „kürzlich angelegt": rejected, falls innerhalb der
|
||||||
|
* letzten 10 s schon ein Eintrag mit gleichem Typ existiert. Schließt
|
||||||
|
* den größten Teil des Race-Windows und unterscheidet Spam-Attacks von
|
||||||
|
* legitimen Sekunden-später-Updates.
|
||||||
|
*/
|
||||||
|
const docCreateLocks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
export async function assertNoRecentDuplicateDocument(
|
||||||
|
contractId: number,
|
||||||
|
documentType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const recent = await prisma.contractDocument.findFirst({
|
||||||
|
where: {
|
||||||
|
contractId,
|
||||||
|
documentType,
|
||||||
|
createdAt: { gte: new Date(Date.now() - 10_000) },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (recent) {
|
||||||
|
// Pentest 64.1: ApiError(400) statt generischem Error – Caller
|
||||||
|
// mappt das auf 400 Bad Request statt pauschal 500.
|
||||||
|
throw new ApiError(400, 'Ein Dokument dieses Typs wurde vor wenigen Sekunden bereits angelegt – bitte kurz warten und Seite neu laden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withContractDocumentLock<T>(
|
||||||
|
contractId: number,
|
||||||
|
documentType: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const key = `${contractId}|${documentType.trim().toLowerCase()}`;
|
||||||
|
const previous = docCreateLocks.get(key);
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const slot = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
docCreateLocks.set(key, (previous ?? Promise.resolve()).then(() => slot));
|
||||||
|
if (previous) await previous;
|
||||||
|
try {
|
||||||
|
await assertNoRecentDuplicateDocument(contractId, documentType);
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
// Map-Aufräumen: wenn niemand mehr in der Kette wartet
|
||||||
|
if (docCreateLocks.get(key) === (previous ?? Promise.resolve()).then(() => slot)) {
|
||||||
|
docCreateLocks.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
|
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
|
||||||
* Lieferbestätigung ist:
|
* Lieferbestätigung ist:
|
||||||
|
|||||||
@@ -180,17 +180,56 @@ export class PleskEmailProvider implements IEmailProvider {
|
|||||||
|
|
||||||
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
|
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
|
||||||
let hasMailbox: boolean | undefined;
|
let hasMailbox: boolean | undefined;
|
||||||
|
let mailgroupActive: boolean | undefined;
|
||||||
|
let mailgroupMembers: string[] | undefined;
|
||||||
|
let forwardingActive: boolean | undefined;
|
||||||
|
let forwardingTargets: string[] | undefined;
|
||||||
if (exists && result.stdout) {
|
if (exists && result.stdout) {
|
||||||
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
|
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
|
||||||
if (mailboxMatch) {
|
if (mailboxMatch) {
|
||||||
hasMailbox = mailboxMatch[1].toLowerCase() === 'true';
|
hasMailbox = mailboxMatch[1].toLowerCase() === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mailgroup-Status + Mitglieder. Plesk listet sie auf einer
|
||||||
|
// Zeile, Adressen sind durch Whitespace getrennt.
|
||||||
|
const mailgroupMatch = result.stdout.match(/Mailgroup:\s*(true|false)/i);
|
||||||
|
if (mailgroupMatch) {
|
||||||
|
mailgroupActive = mailgroupMatch[1].toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
const groupMembersMatch = result.stdout.match(/Group member\(s\):\s*([^\n]*)/i);
|
||||||
|
if (groupMembersMatch) {
|
||||||
|
mailgroupMembers = groupMembersMatch[1]
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((m) => m.includes('@'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forwarding-Status + Ziele. Plesk druckt "Forward request: <addrs>".
|
||||||
|
// Auf manchen Plesk-Versionen heißt das Feld auch "Forwarding".
|
||||||
|
const forwardActiveMatch = result.stdout.match(/Forwarding:\s*(true|false)/i);
|
||||||
|
if (forwardActiveMatch) {
|
||||||
|
forwardingActive = forwardActiveMatch[1].toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
const forwardTargetsMatch = result.stdout.match(/Forward(?:ing)?(?: request)?:\s*([^\n]*)/i);
|
||||||
|
if (forwardTargetsMatch) {
|
||||||
|
forwardingTargets = forwardTargetsMatch[1]
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((m) => m.includes('@'));
|
||||||
|
if (forwardingActive === undefined) {
|
||||||
|
forwardingActive = (forwardingTargets?.length ?? 0) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exists,
|
exists,
|
||||||
email: exists ? email : undefined,
|
email: exists ? email : undefined,
|
||||||
hasMailbox,
|
hasMailbox,
|
||||||
|
mailgroupActive,
|
||||||
|
mailgroupMembers,
|
||||||
|
forwardingActive,
|
||||||
|
forwardingTargets,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// HTTP-Fehler oder Netzwerkfehler
|
// HTTP-Fehler oder Netzwerkfehler
|
||||||
@@ -458,15 +497,63 @@ export class PleskEmailProvider implements IEmailProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plesk CLI API: Weiterleitungsziele aktualisieren
|
// Plesk-CLI-Eigenheit: `-forwarding-addresses` akzeptiert NUR
|
||||||
// Format für -forwarding-addresses: "set:email1,email2" ersetzt alle Adressen
|
// `add:` und `del:`, KEIN `set:`. Und `-forwarding` ist der
|
||||||
await this.request('POST', '/api/v2/cli/mail/call', {
|
// Mailgroup-Schalter (Plesk nennt das im `--info` "Mailgroup",
|
||||||
params: [
|
// im CLI "forwarding" – derselbe Mechanismus, doppelt benannt).
|
||||||
|
// Es gibt KEINE separaten Mailgroup-Optionen wie `-mailgroup`.
|
||||||
|
//
|
||||||
|
// Wir bauen daher den Diff: alte Member abrufen, dann
|
||||||
|
// del:<entfernt> + add:<neu> in zwei separaten Calls. Idempotent,
|
||||||
|
// weil add: Duplikate ignoriert und del: nicht-vorhandene auch.
|
||||||
|
const currentMembers = exists.mailgroupMembers ?? [];
|
||||||
|
const targetsLower = new Set(targets.map((t) => t.toLowerCase()));
|
||||||
|
const currentLower = new Set(currentMembers.map((m) => m.toLowerCase()));
|
||||||
|
const toRemove = currentMembers.filter((m) => !targetsLower.has(m.toLowerCase()));
|
||||||
|
const toAdd = targets.filter((t) => !currentLower.has(t.toLowerCase()));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Plesk updateForwardTargets] ${email} – aktuell: [${currentMembers.join(', ')}], ` +
|
||||||
|
`soll: [${targets.join(', ')}], entfernen: [${toRemove.join(', ')}], hinzufügen: [${toAdd.join(', ')}]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Entfernen-Schritt
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
const delParams = [
|
||||||
|
'--update', email,
|
||||||
|
'-forwarding-addresses', `del:${toRemove.join(',')}`,
|
||||||
|
];
|
||||||
|
const delResult = await this.request<{ code: number; stdout: string; stderr: string }>(
|
||||||
|
'POST', '/api/v2/cli/mail/call', { params: delParams },
|
||||||
|
);
|
||||||
|
console.log('[Plesk updateForwardTargets] del response:', JSON.stringify(delResult, null, 2));
|
||||||
|
if (delResult.code !== 0 || /error|failed/i.test(delResult.stderr || '')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: delResult.stderr?.trim() || delResult.stdout?.trim() || `Plesk del returned code ${delResult.code}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hinzufügen-Schritt (impliziert -forwarding true, damit Mailgroup
|
||||||
|
// aktiviert bleibt bzw. wird).
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
const addParams = [
|
||||||
'--update', email,
|
'--update', email,
|
||||||
'-forwarding', 'true',
|
'-forwarding', 'true',
|
||||||
'-forwarding-addresses', `set:${targets.join(',')}`,
|
'-forwarding-addresses', `add:${toAdd.join(',')}`,
|
||||||
],
|
];
|
||||||
});
|
const addResult = await this.request<{ code: number; stdout: string; stderr: string }>(
|
||||||
|
'POST', '/api/v2/cli/mail/call', { params: addParams },
|
||||||
|
);
|
||||||
|
console.log('[Plesk updateForwardTargets] add response:', JSON.stringify(addResult, null, 2));
|
||||||
|
if (addResult.code !== 0 || /error|failed/i.test(addResult.stderr || '')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: addResult.stderr?.trim() || addResult.stdout?.trim() || `Plesk add returned code ${addResult.code}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export interface EmailExistsResult {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
hasMailbox?: boolean; // true wenn echte Mailbox vorhanden
|
hasMailbox?: boolean; // true wenn echte Mailbox vorhanden
|
||||||
|
// Plesk hat zwei unabhängige Verteil-Mechanismen, beide können parallel
|
||||||
|
// aktiv sein. Manuelle/Legacy-Anlagen nutzen oft "Mailgroup" statt
|
||||||
|
// "Forwarding" – unser Sync muss alte Mitglieder dort einsammeln,
|
||||||
|
// sonst gehen sie beim Umschalten auf Forwarding verloren.
|
||||||
|
mailgroupActive?: boolean;
|
||||||
|
mailgroupMembers?: string[];
|
||||||
|
forwardingActive?: boolean;
|
||||||
|
forwardingTargets?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailOperationResult {
|
export interface EmailOperationResult {
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export interface ProviderExport {
|
|||||||
portalUrl: string | null;
|
portalUrl: string | null;
|
||||||
usernameFieldName: string | null;
|
usernameFieldName: string | null;
|
||||||
passwordFieldName: 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;
|
isActive: boolean;
|
||||||
tariffs: { name: string; isActive: boolean }[];
|
tariffs: { name: string; isActive: boolean }[];
|
||||||
}
|
}
|
||||||
@@ -90,6 +97,13 @@ export async function collectFactoryDefaults() {
|
|||||||
portalUrl: p.portalUrl,
|
portalUrl: p.portalUrl,
|
||||||
usernameFieldName: p.usernameFieldName,
|
usernameFieldName: p.usernameFieldName,
|
||||||
passwordFieldName: p.passwordFieldName,
|
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,
|
isActive: p.isActive,
|
||||||
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
|
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
|
||||||
})),
|
})),
|
||||||
@@ -284,6 +298,13 @@ export async function importFactoryDefaults(
|
|||||||
portalUrl: p.portalUrl ?? null,
|
portalUrl: p.portalUrl ?? null,
|
||||||
usernameFieldName: p.usernameFieldName ?? null,
|
usernameFieldName: p.usernameFieldName ?? null,
|
||||||
passwordFieldName: p.passwordFieldName ?? 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,
|
isActive: p.isActive ?? true,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
@@ -291,6 +312,13 @@ export async function importFactoryDefaults(
|
|||||||
portalUrl: p.portalUrl ?? null,
|
portalUrl: p.portalUrl ?? null,
|
||||||
usernameFieldName: p.usernameFieldName ?? null,
|
usernameFieldName: p.usernameFieldName ?? null,
|
||||||
passwordFieldName: p.passwordFieldName ?? 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,
|
isActive: p.isActive ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,13 +45,16 @@ export const CRM_FIELDS = [
|
|||||||
{ path: 'owner.phone', label: 'Telefon', group: 'Eigentümer' },
|
{ path: 'owner.phone', label: 'Telefon', group: 'Eigentümer' },
|
||||||
{ path: 'owner.mobile', label: 'Mobil', group: 'Eigentümer' },
|
{ path: 'owner.mobile', label: 'Mobil', group: 'Eigentümer' },
|
||||||
{ path: 'owner.email', label: 'E-Mail', group: 'Eigentümer' },
|
{ path: 'owner.email', label: 'E-Mail', group: 'Eigentümer' },
|
||||||
// Rechnungsadresse
|
// Rechnungsadresse (Fallback auf Lieferadresse, wenn keine separate
|
||||||
|
// Rechnungsadresse gepflegt ist – siehe billingAddress-Resolve im Code)
|
||||||
{ path: 'billingAddress.street', label: 'Straße (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.street', label: 'Straße (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.houseNumber', label: 'Hausnummer (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.houseNumber', label: 'Hausnummer (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.streetFull', label: 'Straße + Nr. (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.streetFull', label: 'Straße + Nr. (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.postalCode', label: 'PLZ (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.postalCode', label: 'PLZ (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.city', label: 'Stadt (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.city', label: 'Stadt (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.postalCodeCity', label: 'PLZ + Stadt (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.postalCodeCity', label: 'PLZ + Stadt (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
|
{ path: 'billingAddress.country', label: 'Land (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
|
{ path: 'billingAddress.full', label: 'Vollständige Adresse (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
// Bankverbindung
|
// Bankverbindung
|
||||||
{ path: 'bankCard.iban', label: 'IBAN', group: 'Bank' },
|
{ path: 'bankCard.iban', label: 'IBAN', group: 'Bank' },
|
||||||
{ path: 'bankCard.bic', label: 'BIC', group: 'Bank' },
|
{ path: 'bankCard.bic', label: 'BIC', group: 'Bank' },
|
||||||
@@ -386,7 +389,12 @@ export async function generateFilledPdf(
|
|||||||
const docTypeLabels: Record<string, string> = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' };
|
const docTypeLabels: Record<string, string> = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' };
|
||||||
|
|
||||||
const addr = contract.address;
|
const addr = contract.address;
|
||||||
const bAddr = contract.billingAddress;
|
// Wenn keine separate Rechnungsadresse hinterlegt ist, fällt der Wert auf
|
||||||
|
// die Lieferadresse zurück – konsistent mit der Kundenakte-Logik
|
||||||
|
// (Contract.billingAddressId NULL = "Wie Lieferadresse"). Damit füllen
|
||||||
|
// Auftragsformulare die Rechnungs-Felder nicht mehr leer aus, wenn der
|
||||||
|
// Anbieter eine identische Adresse erwartet.
|
||||||
|
const bAddr = contract.billingAddress ?? contract.address;
|
||||||
|
|
||||||
const dataContext: Record<string, string> = {
|
const dataContext: Record<string, string> = {
|
||||||
// Kunde
|
// Kunde
|
||||||
@@ -437,6 +445,8 @@ export async function generateFilledPdf(
|
|||||||
'billingAddress.postalCode': bAddr?.postalCode || '',
|
'billingAddress.postalCode': bAddr?.postalCode || '',
|
||||||
'billingAddress.city': bAddr?.city || '',
|
'billingAddress.city': bAddr?.city || '',
|
||||||
'billingAddress.postalCodeCity': bAddr ? `${bAddr.postalCode} ${bAddr.city}` : '',
|
'billingAddress.postalCodeCity': bAddr ? `${bAddr.postalCode} ${bAddr.city}` : '',
|
||||||
|
'billingAddress.country': bAddr?.country || '',
|
||||||
|
'billingAddress.full': bAddr ? `${bAddr.street} ${bAddr.houseNumber}, ${bAddr.postalCode} ${bAddr.city}` : '',
|
||||||
// Bank
|
// Bank
|
||||||
'bankCard.iban': bankCard?.iban || '',
|
'bankCard.iban': bankCard?.iban || '',
|
||||||
'bankCard.bic': bankCard?.bic || '',
|
'bankCard.bic': bankCard?.bic || '',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { stripHtml } from '../utils/sanitize.js';
|
import { stripHtml, isValidEmail, sanitizePhoneField, validateProviderAddress } from '../utils/sanitize.js';
|
||||||
import { validateHttpUrl } from '../utils/url.js';
|
import { validateHttpUrl } from '../utils/url.js';
|
||||||
|
|
||||||
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
|
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
|
||||||
@@ -51,48 +51,89 @@ export async function getProviderById(id: number) {
|
|||||||
// Aktuell escapt React das Textnode, also kein direkter XSS – aber neue
|
// Aktuell escapt React das Textnode, also kein direkter XSS – aber neue
|
||||||
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
|
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
|
||||||
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
|
// 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 };
|
const out: any = { ...data };
|
||||||
if (typeof out.name === 'string') out.name = stripHtml(out.name);
|
for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) {
|
||||||
if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName);
|
if (typeof out[k] === 'string') out[k] = stripHtml(out[k]);
|
||||||
if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName);
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const addressLabels: Record<string, string> = {
|
||||||
|
contactAddress: 'Kontakt-Postadresse',
|
||||||
|
cancellationAddress: 'Kündigungs-Postadresse',
|
||||||
|
};
|
||||||
|
for (const k of ['contactAddress', 'cancellationAddress'] as const) {
|
||||||
|
if (out[k] === undefined) continue;
|
||||||
|
// R89.1/R89.2: validateProviderAddress wirft 400 bei Längen-
|
||||||
|
// Verstoß, HTML, Tabs oder Steuerzeichen. Kein silent truncate,
|
||||||
|
// kein silent null-overwrite mehr.
|
||||||
|
out[k] = validateProviderAddress(out[k], addressLabels[k]);
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProvider(data: {
|
interface ProviderWritable {
|
||||||
name: string;
|
name?: string;
|
||||||
portalUrl?: string;
|
portalUrl?: string;
|
||||||
usernameFieldName?: string;
|
usernameFieldName?: string;
|
||||||
passwordFieldName?: 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 clean = stripProviderStrings(data);
|
||||||
const portalUrl = assertValidPortalUrl(clean.portalUrl);
|
const portalUrl = assertValidPortalUrl(clean.portalUrl);
|
||||||
return prisma.provider.create({
|
return prisma.provider.create({
|
||||||
data: {
|
data: {
|
||||||
...clean,
|
...clean,
|
||||||
|
name: clean.name,
|
||||||
portalUrl,
|
portalUrl,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProvider(
|
export async function updateProvider(id: number, data: ProviderWritable) {
|
||||||
id: number,
|
|
||||||
data: {
|
|
||||||
name?: string;
|
|
||||||
portalUrl?: string;
|
|
||||||
usernameFieldName?: string;
|
|
||||||
passwordFieldName?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
|
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
|
||||||
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
|
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
|
||||||
// damit Prisma nicht den alten Wert hält.
|
// damit Prisma nicht den alten Wert hält.
|
||||||
const updateData: typeof data = stripProviderStrings(data);
|
const updateData: any = stripProviderStrings(data);
|
||||||
if (data.portalUrl !== undefined) {
|
if (data.portalUrl !== undefined) {
|
||||||
const validated = assertValidPortalUrl(data.portalUrl);
|
const validated = assertValidPortalUrl(data.portalUrl);
|
||||||
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
|
updateData.portalUrl = validated ?? null;
|
||||||
}
|
}
|
||||||
return prisma.provider.update({
|
return prisma.provider.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -11,6 +11,75 @@ import {
|
|||||||
getActiveProviderConfig,
|
getActiveProviderConfig,
|
||||||
} from './emailProvider/emailProviderService.js';
|
} from './emailProvider/emailProviderService.js';
|
||||||
import { generateSecurePassword } from '../utils/passwordGenerator.js';
|
import { generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
// Locker, aber strikt genug gegen offensichtlichen Müll (CRLF, Spaces,
|
||||||
|
// Komma). Wirklich validiert wird vom Provider beim Sync.
|
||||||
|
const FORWARD_EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
||||||
|
|
||||||
|
// Pentest 71.1 (MEDIUM, 2026-06-08): RFC-reservierte und private/
|
||||||
|
// On-Prem-TLDs. Eine Weiterleitung an `attacker@plesk.internal` würde
|
||||||
|
// am Provider DNS-Lookups gegen internes Netz auslösen oder bei mDNS-
|
||||||
|
// Setup an einen lokalen Mailserver gehen. Wir blocken sie hart.
|
||||||
|
const BLOCKED_TLDS = new Set([
|
||||||
|
'local', 'internal', 'corp', 'lan', 'home', 'private',
|
||||||
|
'invalid', 'test', 'localhost', 'example',
|
||||||
|
'intranet', 'localdomain', 'arpa',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseAdditionalForwards(raw: string | null | undefined): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((x): x is string => typeof x === 'string' && x.trim() !== '');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAdditionalForwards(list: string[]): string | null {
|
||||||
|
const cleaned = list.map((s) => s.trim()).filter((s) => s !== '');
|
||||||
|
return cleaned.length === 0 ? null : JSON.stringify(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine normalisierte Form der Adresse für den Dedup-Vergleich:
|
||||||
|
* lowercase + Plus-Tag aus dem Local-Part rausgestrippt.
|
||||||
|
* `billing+pentest@test.de` und `billing@test.de` haben so denselben
|
||||||
|
* Schlüssel und treffen sich beim Dedup. Pentest 71.2.
|
||||||
|
*/
|
||||||
|
export function canonicalEmailKey(email: string): string {
|
||||||
|
const trimmed = email.trim().toLowerCase();
|
||||||
|
const at = trimmed.lastIndexOf('@');
|
||||||
|
if (at < 1) return trimmed;
|
||||||
|
const localPart = trimmed.slice(0, at);
|
||||||
|
const domain = trimmed.slice(at + 1);
|
||||||
|
const plus = localPart.indexOf('+');
|
||||||
|
const cleanedLocal = plus === -1 ? localPart : localPart.slice(0, plus);
|
||||||
|
return `${cleanedLocal}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidForwardingEmail(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new ApiError(400, 'Ungültige Weiterleitungs-E-Mail-Adresse');
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.length > 254) {
|
||||||
|
throw new ApiError(400, 'Weiterleitungs-E-Mail-Adresse ist leer oder zu lang');
|
||||||
|
}
|
||||||
|
if (!FORWARD_EMAIL_REGEX.test(trimmed)) {
|
||||||
|
throw new ApiError(400, `Ungültiges E-Mail-Format: ${trimmed}`);
|
||||||
|
}
|
||||||
|
// 71.1: TLD aus dem Domain-Part rausziehen und gegen die Blocklist
|
||||||
|
// halten. Domain liegt nach dem letzten @, TLD nach dem letzten Punkt.
|
||||||
|
const domain = trimmed.slice(trimmed.lastIndexOf('@') + 1).toLowerCase();
|
||||||
|
const tld = domain.slice(domain.lastIndexOf('.') + 1);
|
||||||
|
if (BLOCKED_TLDS.has(tld)) {
|
||||||
|
throw new ApiError(400, `Top-Level-Domain "${tld}" ist nicht erlaubt (reservierte/private TLD).`);
|
||||||
|
}
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
|
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
|
||||||
const where: Record<string, unknown> = { customerId };
|
const where: Record<string, unknown> = { customerId };
|
||||||
@@ -83,6 +152,27 @@ export interface CreateEmailData {
|
|||||||
export async function createEmail(data: CreateEmailData) {
|
export async function createEmail(data: CreateEmailData) {
|
||||||
const { provisionAtProvider, createMailbox, ...emailData } = data;
|
const { provisionAtProvider, createMailbox, ...emailData } = data;
|
||||||
|
|
||||||
|
// Duplikatscheck: pro Kunde darf eine Stressfrei-Adresse nur einmal
|
||||||
|
// existieren. Case-insensitive, weil RFC-localpart-Großschreibung in
|
||||||
|
// der Praxis nie semantischen Unterschied macht und der Provider eh
|
||||||
|
// einheitlich lowercased.
|
||||||
|
const normalized = data.email.trim().toLowerCase();
|
||||||
|
const conflict = await prisma.stressfreiEmail.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId: data.customerId,
|
||||||
|
email: { equals: normalized },
|
||||||
|
},
|
||||||
|
select: { id: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
const hint = conflict.isActive
|
||||||
|
? 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.'
|
||||||
|
: 'Diese E-Mail-Adresse existiert bei diesem Kunden bereits (deaktiviert). Bitte reaktiviere den vorhandenen Eintrag, statt einen neuen anzulegen.';
|
||||||
|
throw new ApiError(409, hint);
|
||||||
|
}
|
||||||
|
// Wert in DB ist eh schon lowercase – wir setzen es einheitlich.
|
||||||
|
emailData.email = normalized;
|
||||||
|
|
||||||
// Falls beim Provider anlegen gewünscht
|
// Falls beim Provider anlegen gewünscht
|
||||||
if (provisionAtProvider) {
|
if (provisionAtProvider) {
|
||||||
// Kunde laden für Weiterleitung
|
// Kunde laden für Weiterleitung
|
||||||
@@ -153,6 +243,34 @@ export async function updateEmail(
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Beim Umbenennen der E-Mail-Adresse erneut Kollisionscheck. Sonst
|
||||||
|
// kann man eine zweite Adresse mit identischer Mail beim selben Kunden
|
||||||
|
// anlegen (Umweg um den Create-Check).
|
||||||
|
if (typeof data.email === 'string' && data.email.trim() !== '') {
|
||||||
|
const normalized = data.email.trim().toLowerCase();
|
||||||
|
const current = await prisma.stressfreiEmail.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { customerId: true, email: true },
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
|
||||||
|
}
|
||||||
|
if (normalized !== current.email.toLowerCase()) {
|
||||||
|
const conflict = await prisma.stressfreiEmail.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId: current.customerId,
|
||||||
|
email: { equals: normalized },
|
||||||
|
NOT: { id },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
throw new ApiError(409, 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.email = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
return prisma.stressfreiEmail.update({
|
return prisma.stressfreiEmail.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -163,6 +281,98 @@ export async function deleteEmail(id: number) {
|
|||||||
return prisma.stressfreiEmail.delete({ where: { id } });
|
return prisma.stressfreiEmail.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Komplette Liste zusätzlicher Weiterleitungs-E-Mails ersetzen und
|
||||||
|
* direkt mit dem Provider synchronisieren. Aufrufer hat eine canonical
|
||||||
|
* Liste – das Sub-Modal arbeitet auf Snapshot-Basis.
|
||||||
|
*
|
||||||
|
* Pentest 71.2: Dedup über `canonicalEmailKey` (Plus-Tags strippen),
|
||||||
|
* damit `billing+tag@x.de` und `billing@x.de` als gleiches Ziel
|
||||||
|
* erkannt werden – auch im Vergleich zur Stamm-E-Mail des Kunden.
|
||||||
|
*
|
||||||
|
* Pentest 71.4: DB-Update wird bei Provider-Sync-Fehler zurückgerollt,
|
||||||
|
* damit Plesk und DB nicht auseinanderlaufen.
|
||||||
|
*
|
||||||
|
* Pentest 81.1: Self-Forward wird hart abgelehnt – würde sonst am
|
||||||
|
* Provider einen Mail-Loop erzeugen (Stressfrei-Adresse leitet auf
|
||||||
|
* sich selbst um → unendliche Weiterleitung).
|
||||||
|
*/
|
||||||
|
export async function setAdditionalForwards(
|
||||||
|
id: number,
|
||||||
|
emails: string[],
|
||||||
|
): Promise<{ success: boolean; forwardTargets?: string[]; error?: string }> {
|
||||||
|
// Kunden-Stamm-Mail + eigene Email holen für Dedup gegen die fest
|
||||||
|
// gesetzten Ziele bzw. die Stressfrei-Adresse selbst.
|
||||||
|
const meta = await prisma.stressfreiEmail.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
additionalForwardingEmails: true,
|
||||||
|
customer: { select: { email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!meta) {
|
||||||
|
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
|
||||||
|
}
|
||||||
|
const previousRaw = meta.additionalForwardingEmails;
|
||||||
|
const customerEmailKey = meta.customer?.email
|
||||||
|
? canonicalEmailKey(meta.customer.email)
|
||||||
|
: null;
|
||||||
|
const selfKey = canonicalEmailKey(meta.email);
|
||||||
|
|
||||||
|
// Input normalisieren + Duplikate raus.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
if (customerEmailKey) seen.add(customerEmailKey);
|
||||||
|
const cleaned: string[] = [];
|
||||||
|
for (const raw of emails) {
|
||||||
|
const ok = assertValidForwardingEmail(raw);
|
||||||
|
const key = canonicalEmailKey(ok);
|
||||||
|
// 81.1: Eintrag, der auf die Adresse selbst zeigt, würde einen
|
||||||
|
// Mail-Loop am Provider erzeugen. Hart ablehnen mit klarer
|
||||||
|
// Fehlermeldung, statt silent zu droppen – der User soll merken,
|
||||||
|
// dass sein Eintrag bewusst nicht akzeptiert wurde.
|
||||||
|
if (key === selfKey) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`"${ok}" zeigt auf die Adresse selbst – Mail-Loop. Bitte eine andere Weiterleitungsadresse wählen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
cleaned.push(ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRaw = serializeAdditionalForwards(cleaned);
|
||||||
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { additionalForwardingEmails: nextRaw },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provider unmittelbar nachziehen, sonst läuft das Plesk-Mail-Konto
|
||||||
|
// mit der alten Liste weiter. autoImport=false, weil unsere DB-Liste
|
||||||
|
// hier die explizite User-Intent ist – kein Plesk-Member-Auto-Pull,
|
||||||
|
// sonst landen gerade entfernte Adressen zurück in der Liste.
|
||||||
|
const syncResult = await syncForwardingForEmail(id, { autoImportPleskMembers: false });
|
||||||
|
|
||||||
|
// 71.4: Rollback wenn Plesk den Sync abgelehnt hat. DB darf nicht
|
||||||
|
// den optimistischen Stand zeigen, wenn der Provider noch auf dem
|
||||||
|
// alten Stand ist.
|
||||||
|
if (!syncResult.success && previousRaw !== nextRaw) {
|
||||||
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { additionalForwardingEmails: previousRaw },
|
||||||
|
}).catch((rollbackErr) => {
|
||||||
|
console.error(
|
||||||
|
'[setAdditionalForwards] Rollback nach Provider-Fail fehlgeschlagen:',
|
||||||
|
rollbackErr,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncResult;
|
||||||
|
}
|
||||||
|
|
||||||
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
|
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
|
||||||
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
|
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||||||
@@ -298,6 +508,7 @@ export async function getDecryptedPassword(id: number): Promise<string | null> {
|
|||||||
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
|
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
|
||||||
export async function syncForwardingForEmail(
|
export async function syncForwardingForEmail(
|
||||||
id: number,
|
id: number,
|
||||||
|
options: { autoImportPleskMembers?: boolean } = {},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
forwardTargets?: string[];
|
forwardTargets?: string[];
|
||||||
@@ -305,6 +516,14 @@ export async function syncForwardingForEmail(
|
|||||||
passwordReset?: boolean;
|
passwordReset?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
|
// Auto-Import übernimmt unbekannte Plesk-Members in unsere DB. Macht
|
||||||
|
// beim Sync-Button Sinn (Bestands-Migration), aber NICHT beim
|
||||||
|
// User-getriggerten Add/Remove – dort ist die DB-Liste die Wahrheit.
|
||||||
|
// Sonst kreisen entfernte Adressen zurück in die Liste:
|
||||||
|
// 1. User entfernt c → DB=[a,b], Plesk=[a,b,c]
|
||||||
|
// 2. Auto-Import: "c ist in Plesk aber nicht in DB → in DB schreiben"
|
||||||
|
// 3. → DB=[a,b,c], Diff sagt nichts zu löschen, Plesk bleibt [a,b,c].
|
||||||
|
const autoImport = options.autoImportPleskMembers ?? true;
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: {
|
select: {
|
||||||
@@ -313,6 +532,7 @@ export async function syncForwardingForEmail(
|
|||||||
isProvisioned: true,
|
isProvisioned: true,
|
||||||
hasMailbox: true,
|
hasMailbox: true,
|
||||||
emailPasswordEncrypted: true,
|
emailPasswordEncrypted: true,
|
||||||
|
additionalForwardingEmails: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,10 +553,90 @@ export async function syncForwardingForEmail(
|
|||||||
if (config?.defaultForwardEmail) {
|
if (config?.defaultForwardEmail) {
|
||||||
forwardTargets.push(config.defaultForwardEmail);
|
forwardTargets.push(config.defaultForwardEmail);
|
||||||
}
|
}
|
||||||
|
// Zusätzliche Weiterleitungsziele (vom User im Modal gepflegt). Duplikate
|
||||||
|
// gegen die Stamm-Mail oder den Default werden hier weggefiltert, damit
|
||||||
|
// Plesk nicht mit Wiederholungen die Liste aufbläht. Pentest 71.2:
|
||||||
|
// Vergleich über `canonicalEmailKey`, damit Plus-Tags nicht doppelt
|
||||||
|
// zustellen.
|
||||||
|
const seenKeys = new Set(forwardTargets.map(canonicalEmailKey));
|
||||||
|
for (const extra of parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails)) {
|
||||||
|
const key = canonicalEmailKey(extra);
|
||||||
|
if (!seenKeys.has(key)) {
|
||||||
|
seenKeys.add(key);
|
||||||
|
forwardTargets.push(extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const localPart = stressfreiEmail.email.split('@')[0];
|
const localPart = stressfreiEmail.email.split('@')[0];
|
||||||
|
|
||||||
// 1) Forwards neu setzen.
|
// 0) Auto-Migration: Plesk hat zwei Verteil-Mechanismen (Mailgroup +
|
||||||
|
// Forwarding). Alt-Anlagen liefen oft via Mailgroup – unser Sync
|
||||||
|
// schreibt aber nur in die Forwarding-Liste, daher landeten neue
|
||||||
|
// Adressen nirgendwo. Hier holen wir die aktuellen Mailgroup-Members
|
||||||
|
// ab und ziehen alle, die wir nicht schon kennen, in unsere
|
||||||
|
// additionalForwardingEmails-Liste rein. Der nachfolgende Plesk-Call
|
||||||
|
// deaktiviert dann die Mailgroup und schreibt die volle Liste als
|
||||||
|
// Forwarding. Verlustfrei – kein Empfänger fällt raus.
|
||||||
|
// Pentest 83.2: Self-Forward auch beim Import blocken. Die
|
||||||
|
// Stressfrei-Adresse selbst darf nicht aus Plesk in unsere DB
|
||||||
|
// landen – sonst läuft sie nach dem Mailgroup→Forwarding-Umschalten
|
||||||
|
// als Forwarding-Target auf sich selbst (Mail-Loop).
|
||||||
|
seenKeys.add(canonicalEmailKey(stressfreiEmail.email));
|
||||||
|
|
||||||
|
if (autoImport) {
|
||||||
|
try {
|
||||||
|
const pleskState = await checkEmailExists(localPart);
|
||||||
|
const existingMembers = [
|
||||||
|
...(pleskState.mailgroupMembers ?? []),
|
||||||
|
...(pleskState.forwardingTargets ?? []),
|
||||||
|
];
|
||||||
|
const newImports: string[] = [];
|
||||||
|
for (const member of existingMembers) {
|
||||||
|
// Pentest 83.1: importierte Adressen aus Plesk müssen denselben
|
||||||
|
// Filter passieren wie User-Eingaben (TLD-Blocklist, Format).
|
||||||
|
// Sonst rutschen reservierte TLDs wie `.internal` ohne Check
|
||||||
|
// in unsere DB, falls ein Plesk-Admin sie dort manuell gepflegt
|
||||||
|
// hat. Ungültige werden silent gedroppt – Log informiert.
|
||||||
|
let validated: string;
|
||||||
|
try {
|
||||||
|
validated = assertValidForwardingEmail(member);
|
||||||
|
} catch (validationErr) {
|
||||||
|
const reason = validationErr instanceof Error ? validationErr.message : 'unbekannt';
|
||||||
|
console.debug(
|
||||||
|
`[syncForwardingForEmail] Plesk-Member "${member}" verworfen: ${reason}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = canonicalEmailKey(validated);
|
||||||
|
if (!seenKeys.has(key)) {
|
||||||
|
seenKeys.add(key);
|
||||||
|
forwardTargets.push(validated);
|
||||||
|
newImports.push(validated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newImports.length > 0) {
|
||||||
|
const mergedAdditional = [
|
||||||
|
...parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails),
|
||||||
|
...newImports,
|
||||||
|
];
|
||||||
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { additionalForwardingEmails: serializeAdditionalForwards(mergedAdditional) },
|
||||||
|
});
|
||||||
|
// Pentest 83.3: PII-Logs auf debug-Level statt log-Level.
|
||||||
|
console.debug(
|
||||||
|
`[syncForwardingForEmail] Importiert aus Plesk-Mailgroup für ${stressfreiEmail.email}:`,
|
||||||
|
newImports,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (importErr) {
|
||||||
|
// Nicht hart fehlschlagen – im schlimmsten Fall fehlen ein paar
|
||||||
|
// alte Empfänger, aber der eigentliche Sync soll trotzdem laufen.
|
||||||
|
console.error('[syncForwardingForEmail] Mailgroup-Import fehlgeschlagen:', importErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Forwards neu setzen (deaktiviert intern Mailgroup).
|
||||||
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
|
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
|
||||||
if (!forwardResult.success) {
|
if (!forwardResult.success) {
|
||||||
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Erlaubt Service-/Helper-Funktionen, einen Fehler mit explizitem HTTP-
|
||||||
|
* Status nach oben zu reichen. Controller können in ihrem `catch` per
|
||||||
|
* `instanceof ApiError` den Status auslesen statt pauschal 500 zu liefern.
|
||||||
|
*
|
||||||
|
* Pentest 64.1 (LOW, 2026-06-02): Race-Lock (assertNoRecentDuplicate-
|
||||||
|
* Document) warf einen generischen Error → catch hat 500 zurückgegeben,
|
||||||
|
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
|
||||||
|
* angelegt" eindeutig eine 400-Class-Situation ist.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly statusCode: number;
|
||||||
|
constructor(statusCode: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
|
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ApiError } from './apiError.js';
|
||||||
|
|
||||||
// Felder die NIE in einer API-Response an den Client gehen dürfen
|
// Felder die NIE in einer API-Response an den Client gehen dürfen
|
||||||
const SENSITIVE_CUSTOMER_FIELDS = [
|
const SENSITIVE_CUSTOMER_FIELDS = [
|
||||||
'portalPasswordHash',
|
'portalPasswordHash',
|
||||||
@@ -15,6 +17,10 @@ const SENSITIVE_CUSTOMER_FIELDS = [
|
|||||||
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
|
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
|
||||||
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
|
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
|
||||||
'consentHash',
|
'consentHash',
|
||||||
|
// Pentest 57.7 (2026-06-01): TTL des Public-Consent-Links – kein Leak
|
||||||
|
// an die Standard-Customer-Response, da Existenz/Ablaufzeit Info über
|
||||||
|
// den Workflow gibt.
|
||||||
|
'consentHashExpiresAt',
|
||||||
// Session-/OTP-State – Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
|
// Session-/OTP-State – Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
|
||||||
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
|
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
|
||||||
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
|
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
|
||||||
@@ -69,6 +75,9 @@ const CONTRACT_DISPLAY_STRING_FIELDS = [
|
|||||||
'tariffName',
|
'tariffName',
|
||||||
'customerNumberAtProvider',
|
'customerNumberAtProvider',
|
||||||
'contractNumberAtProvider',
|
'contractNumberAtProvider',
|
||||||
|
'orderNumberAtSalesPlatform',
|
||||||
|
'customerNumberAtSalesPlatform',
|
||||||
|
'contractNumberAtSalesPlatform',
|
||||||
'portalUsername',
|
'portalUsername',
|
||||||
'previousProviderName',
|
'previousProviderName',
|
||||||
'previousCustomerNumber',
|
'previousCustomerNumber',
|
||||||
@@ -166,6 +175,341 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
|
|||||||
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
||||||
* Audit-Log abrufbar) und sanitisiert das embedded customer.
|
* Audit-Log abrufbar) und sanitisiert das embedded customer.
|
||||||
*/
|
*/
|
||||||
|
// Sanitisierung für freitextliche User-Notizen (ContractDocument.notes,
|
||||||
|
// Invoice.notes, MeterReading.notes etc.). Pentest 55.2 (MEDIUM,
|
||||||
|
// 2026-06-01): 50 000-Zeichen-Inputs mit XSS-Payload und CRLF gingen
|
||||||
|
// roh in die DB. Selbst wenn React escapt, sind sie ein Header-Injection-
|
||||||
|
// und Speicher-Risiko, wenn die Notiz mal in Mail/PDF/CSV-Export fließt.
|
||||||
|
// - Tags + gefährliche Schemata via stripHtml
|
||||||
|
// - CRLF auf Newline normalisieren, keine Carriage-Returns persistieren
|
||||||
|
// - Length-Cap default 2000 Zeichen (genug für sinnvolle Anmerkungen)
|
||||||
|
// Pentest 58.1 (MEDIUM, 2026-06-01): documentType wurde nur durch
|
||||||
|
// stripHtml geschickt, aber NICHT gegen eine Whitelist geprüft. Damit
|
||||||
|
// landeten beliebige Strings (`NICHT_ERLAUBT`, `DROP TABLE …`,
|
||||||
|
// Tippfehler-Werte aus alten UI-Versionen) als documentType in der
|
||||||
|
// ContractDocument-Tabelle und brachen Frontend-Filter, Auto-Activation
|
||||||
|
// (Lieferbestätigung-Trigger) und Reports.
|
||||||
|
//
|
||||||
|
// Whitelist spiegelt die Konstante CONTRACT_DOCUMENT_TYPES aus
|
||||||
|
// SaveAttachmentModal / SaveEmailAsPdfModal im Frontend. Beide
|
||||||
|
// Listen MÜSSEN synchron gehalten werden – idealerweise später
|
||||||
|
// in eine geteilte Konfiguration gehoben.
|
||||||
|
export const ALLOWED_CONTRACT_DOCUMENT_TYPES = [
|
||||||
|
'Auftragsformular',
|
||||||
|
'Auftragsbestätigung',
|
||||||
|
'Lieferbestätigung',
|
||||||
|
'Vertragsunterlagen',
|
||||||
|
'Vollmacht',
|
||||||
|
'Widerrufsbelehrung',
|
||||||
|
'Preisblatt',
|
||||||
|
'Sonstiges',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTRACT_DOCUMENT_TYPE_SET: Set<string> = new Set(ALLOWED_CONTRACT_DOCUMENT_TYPES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert + normalisiert einen documentType-Wert. Wirft einen Fehler
|
||||||
|
* mit klarer Liste, wenn der Wert nicht in der Whitelist steht (der
|
||||||
|
* aufrufende Controller mappt das auf 400). Trimmt Whitespace und macht
|
||||||
|
* den Vergleich case-insensitive – damit `"lieferbestätigung"` aus
|
||||||
|
* Drittsystemen sauber matched, aber `"Lieferbestätigung_DROP"` rausfliegt.
|
||||||
|
*/
|
||||||
|
export function validateContractDocumentType(raw: unknown): string {
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
const cleaned = stripHtml(raw) as string;
|
||||||
|
const trimmed = cleaned.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
const canonical = ALLOWED_CONTRACT_DOCUMENT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase());
|
||||||
|
if (!canonical) {
|
||||||
|
throw new Error(`Ungültiger documentType '${trimmed}'. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 26.7 LOW (defense-in-depth, 2026-06-02): documentPath wird
|
||||||
|
// (außer beim Upload-Endpoint) NIE direkt aus User-Input übernommen.
|
||||||
|
// Falls doch jemand auf die Idee kommt, das Feld irgendwo zu mappen,
|
||||||
|
// fangen wir hier Path-Traversal / Javascript-URIs / HTML ab.
|
||||||
|
// Spiegelt isValidDocumentPath aus prisma/cleanup-xss-and-mass-assignment.ts
|
||||||
|
// 1:1 – Single Source of Truth für Lese- UND Schreibpfad.
|
||||||
|
export function isValidDocumentPath(v: string | null | undefined): boolean {
|
||||||
|
if (!v) return true; // null/leer ist OK – Feld bleibt einfach unbesetzt
|
||||||
|
if (typeof v !== 'string') return false;
|
||||||
|
if (v.includes('..')) return false;
|
||||||
|
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
||||||
|
if (/<[a-z!/]/i.test(v)) return false;
|
||||||
|
return /^\/?uploads\/[A-Za-z0-9._\-/]+$/.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidDocumentPath(v: string | null | undefined, fieldLabel = 'documentPath'): void {
|
||||||
|
if (!isValidDocumentPath(v)) {
|
||||||
|
throw new Error(`${fieldLabel} ist kein gültiger Upload-Pfad (erlaubt: /uploads/<safe>).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 68.1 (LOW, 2026-06-03): PDFs mit JavaScript, /Launch (externes
|
||||||
|
// Programm), /EmbeddedFile (eingebettete Executables) oder /RichMedia
|
||||||
|
// (Flash) kamen durch den reinen Magic-Byte-Check (%PDF-) und wurden
|
||||||
|
// inline ausgeliefert. Browser-PDF-Viewer (PDFium/PDF.js) führen kein JS
|
||||||
|
// aus, aber sobald jemand die PDF in Adobe Acrobat öffnet, läuft sie.
|
||||||
|
// → Wir blocken das schon beim Upload.
|
||||||
|
//
|
||||||
|
// PDF-Name-Objekte sind laut PDF 32000-1:2008 §7.3.5 case-sensitive, also
|
||||||
|
// kein /i auf den Patterns. Whitespace nach `/` ist im Standard zwar
|
||||||
|
// erlaubt, in real-world Exploits aber praktisch nie zu sehen – wir
|
||||||
|
// bleiben hier pragmatisch.
|
||||||
|
//
|
||||||
|
// Hinweis: erkannt wird nur, was im Klartext im PDF-Body steht.
|
||||||
|
// Komprimierte oder verschlüsselte Streams entgehen dem String-Scan.
|
||||||
|
// Für unser Bedrohungsmodell (kompromittierter Staff-Account, LOW) reicht
|
||||||
|
// das – ein vollständiger PDF-Parser wäre Overkill.
|
||||||
|
const PDF_DANGER_PATTERNS: { pattern: RegExp; label: string }[] = [
|
||||||
|
{ pattern: /\/JavaScript\b/, label: 'JavaScript-Action' },
|
||||||
|
{ pattern: /\/JS\b/, label: 'JavaScript-Action' },
|
||||||
|
{ pattern: /\/Launch\b/, label: 'Launch-Action (externes Programm)' },
|
||||||
|
{ pattern: /\/EmbeddedFile\b/, label: 'eingebettete Datei' },
|
||||||
|
{ pattern: /\/RichMedia\b/, label: 'RichMedia-Inhalt (Flash)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function assertSafePdf(buf: Buffer): void {
|
||||||
|
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||||
|
return; // keine PDF → andere Validatoren zuständig
|
||||||
|
}
|
||||||
|
// Stream-Inhalte (Bilder/Fonts/Komprimiertes) aus dem Scan rausnehmen.
|
||||||
|
// Jpeg-Bytes können zufällig "/JavaScript" enthalten → false-positive
|
||||||
|
// bei jsPDF-generierten PDFs mit eingebetteten Fotos (stage-Bug
|
||||||
|
// 2026-06-03). Echte aktive PDF-Inhalte stehen IMMER im PDF-
|
||||||
|
// Object-Stream (außerhalb von `stream..endstream`-Blöcken).
|
||||||
|
const scanTarget = buf.toString('latin1').replace(/stream\s[\s\S]*?endstream/g, '');
|
||||||
|
for (const { pattern, label } of PDF_DANGER_PATTERNS) {
|
||||||
|
if (pattern.test(scanTarget)) {
|
||||||
|
throw new ApiError(
|
||||||
|
415,
|
||||||
|
`PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 51.3 + 60.3 (MEDIUM, 2026-06-01): Telefon-/Vorwahl-Felder
|
||||||
|
// dürfen NIE CRLF oder andere Control-Chars enthalten – sonst sind sie
|
||||||
|
// ein Header-Injection-Vektor (Mail, HTTP), wenn der Wert mal in einen
|
||||||
|
// Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf
|
||||||
|
// literales Space beschränkt, NICHT `\s` – das matched sonst `\r\n\t`.
|
||||||
|
// Allowed: Ziffern, Plus, Minus, Slash, Klammern, Punkt, Space. Bis 40 Zeichen.
|
||||||
|
//
|
||||||
|
// 51.3 deckte nur Contract-Phone-Felder ab; 60.3: `Customer.phone` /
|
||||||
|
// `Customer.mobile` waren immer noch offen, weil pickCustomerUpdate nur
|
||||||
|
// stripHtml laufen ließ – das filtert keine Control-Chars.
|
||||||
|
const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/;
|
||||||
|
export function sanitizePhoneField(raw: unknown, fieldLabel: string): string | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
if (trimmed === '') return undefined;
|
||||||
|
if (!PHONE_FIELD_ALLOWED.test(trimmed)) {
|
||||||
|
throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7 (LOW, 2026-06-02): deliveryDate/confirmationDate-Felder
|
||||||
|
// liefen ungeprüft in maybeActivateOnDeliveryConfirmation. XSS-Payloads
|
||||||
|
// gingen mit 200 durch, weil das ungültige Datum nur silent als null
|
||||||
|
// behandelt wurde. Impact gering, aber API-Hygiene: ungültige Eingabe
|
||||||
|
// soll 400 zurückgeben, nicht 200.
|
||||||
|
//
|
||||||
|
// Akzeptiert: ISO-8601-Datum (YYYY-MM-DD) und Datum+Zeit (mit oder ohne
|
||||||
|
// Zeitzone). Whitespace wird getrimmt. null / leerer String / undefined
|
||||||
|
// sind OK – der Aufrufer behandelt das als "Datum nicht gesetzt".
|
||||||
|
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
||||||
|
export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new Error(`${fieldLabel} muss ein Datums-String (YYYY-MM-DD) sein.`);
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
if (!ISO_DATE_REGEX.test(trimmed)) {
|
||||||
|
throw new Error(`${fieldLabel} muss ISO-8601-Format haben (YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS).`);
|
||||||
|
}
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new Error(`${fieldLabel} ist kein gültiges Datum.`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 86.1 + 86.2 (LOW, 2026-06-19): Kunden-/Vertrags-/Auftrags-
|
||||||
|
// Nummern bei Anbieter und Vertriebsplattform hatten keine Längen- oder
|
||||||
|
// Zeichen-Validierung. >1000 Zeichen-Strings warfen einen generischen
|
||||||
|
// 500er (DB-Overflow VARCHAR(191)) statt eines 400ers. Außerdem
|
||||||
|
// überlebten Attribut-Injection-Payloads wie `foo" onerror="alert(1)`
|
||||||
|
// die stripHtml-Defense (kein umschließender Tag → kein Match), die
|
||||||
|
// in PDF-/Mail-Export potentiell aktiv werden könnten.
|
||||||
|
//
|
||||||
|
// Whitelist orientiert sich am Vorschlag des Pentesters
|
||||||
|
// `^[\w\-\/\s\.]*$` – Whitespace ist hier bewusst NUR ein literales
|
||||||
|
// Space, NICHT `\s` (kein CRLF/Tab → kein Header-Injection-Vektor
|
||||||
|
// in CSV-/Mail-Exporten). Max 100 Zeichen reicht für jede reale
|
||||||
|
// Kunden-/Vertrags-Nummer und bleibt deutlich unter dem VARCHAR(191)-
|
||||||
|
// Limit der DB-Spalte.
|
||||||
|
const CONTRACT_IDENTIFIER_FIELDS: ReadonlySet<string> = new Set([
|
||||||
|
'customerNumberAtProvider',
|
||||||
|
'contractNumberAtProvider',
|
||||||
|
'orderNumberAtSalesPlatform',
|
||||||
|
'customerNumberAtSalesPlatform',
|
||||||
|
'contractNumberAtSalesPlatform',
|
||||||
|
]);
|
||||||
|
const CONTRACT_IDENTIFIER_ALLOWED = /^[A-Za-z0-9_\-/. ]{0,100}$/;
|
||||||
|
const CONTRACT_IDENTIFIER_MAX_LEN = 100;
|
||||||
|
|
||||||
|
export function isContractIdentifierField(key: string): boolean {
|
||||||
|
return CONTRACT_IDENTIFIER_FIELDS.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateContractIdentifier(
|
||||||
|
raw: unknown,
|
||||||
|
fieldLabel: string,
|
||||||
|
): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
if (trimmed.length > CONTRACT_IDENTIFIER_MAX_LEN) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} darf maximal ${CONTRACT_IDENTIFIER_MAX_LEN} Zeichen lang sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!CONTRACT_IDENTIFIER_ALLOWED.test(trimmed)) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, Leerzeichen).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 95.1/95.3/95.4 (MEDIUM/LOW, 2026-06-21): Manuelles
|
||||||
|
// `portalUsername` am Vertrag hatte gar keine Validierung. Drei
|
||||||
|
// nachweisbare Effekte:
|
||||||
|
// - `foo\r\nBcc:evil@x.de` (CRLF) verbatim gespeichert →
|
||||||
|
// Header-Injection-Vektor sobald der Wert in Mail-Templates
|
||||||
|
// oder PDF-Footers landet.
|
||||||
|
// - `<script>alert(1)</script>@x.de` lief durch stripHtml →
|
||||||
|
// stille Mutation (R87.1/R89.2-Pattern auf neuem Feld).
|
||||||
|
// - >190 Zeichen → VARCHAR-Overflow → generischer 500 statt 400.
|
||||||
|
//
|
||||||
|
// Bewusst NICHT übernommen wurde R95.2 (Email-Format-Pflicht):
|
||||||
|
// `portalUsername` ist im Manual-Modus nicht zwingend eine
|
||||||
|
// E-Mail. Vodafone, 1&1, EWE und etliche Stadtwerke nutzen
|
||||||
|
// Kundennummern, Pseudonyme oder Customer-IDs als Portal-Login.
|
||||||
|
// Eine `email().regex()`-Pflicht würde legitime Logins ablehnen.
|
||||||
|
// Der Stressfrei-Modus hängt eh an einer schon validierten
|
||||||
|
// Email-Stammdate (assertValidForwardingEmail).
|
||||||
|
//
|
||||||
|
// Allowed: Alphanumerisch + `_`, `-`, `.`, `/`, `@`, `+`, Space.
|
||||||
|
// Damit sind Vodafone-Kunden-IDs (`12345678`), Pseudonyme
|
||||||
|
// (`max.mustermann`), Plus-Tag-Emails (`m+tag@example.com`)
|
||||||
|
// und gemischte Formen abgedeckt. Strukturell sind CRLF, Tab,
|
||||||
|
// alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne
|
||||||
|
// extra Check. Max 100 Zeichen << VARCHAR(191) → R95.4.
|
||||||
|
const PORTAL_USERNAME_ALLOWED = /^[A-Za-z0-9_\-/.@+ ]{0,100}$/;
|
||||||
|
const PORTAL_USERNAME_MAX_LEN = 100;
|
||||||
|
|
||||||
|
export function validatePortalUsername(
|
||||||
|
raw: unknown,
|
||||||
|
fieldLabel = 'portalUsername',
|
||||||
|
): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
if (trimmed.length > PORTAL_USERNAME_MAX_LEN) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} darf maximal ${PORTAL_USERNAME_MAX_LEN} Zeichen lang sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!PORTAL_USERNAME_ALLOWED.test(trimmed)) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, @, +, Leerzeichen).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 89.1 + 89.2 (MEDIUM/LOW, 2026-06-21): Postadressen am
|
||||||
|
// Provider (`contactAddress`, `cancellationAddress`). sanitizeNotes
|
||||||
|
// hat das Length-Cap silent durchgeschoben (slice statt Error) und
|
||||||
|
// stripHtml lief vor dem Length-Check – derselbe Fehler wie R87:
|
||||||
|
// `<script>…</script>` reduziert auf leeren String → null in der
|
||||||
|
// DB → vorheriger Wert ohne Fehlermeldung überschrieben.
|
||||||
|
//
|
||||||
|
// Lösung wie R87: Raw-Input validieren, harte 400 statt silent-Mutation.
|
||||||
|
// - max 500 Zeichen (mehrzeilige Postadresse ≈ 4 Zeilen × 80)
|
||||||
|
// - `<` oder `>` direkt 400 (Postadressen brauchen kein HTML)
|
||||||
|
// - Steuerzeichen außer `\n` direkt 400 (kein CR/Tab/Null/etc)
|
||||||
|
// - leerer/getrimmt-leerer Input → null (Feld zurücksetzen)
|
||||||
|
// - CRLF → LF normalisieren
|
||||||
|
const PROVIDER_ADDRESS_MAX_LEN = 500;
|
||||||
|
// Erlaubt: nur LF (`\x0A`) als Newline. Alles andere – inkl. Tab (`\x09`) –
|
||||||
|
// fliegt raus. Tab in Postadressen ist Header-Injection-Vektor für CSV/Mail
|
||||||
|
// und nichts, was ein Mensch je tippt.
|
||||||
|
const PROVIDER_ADDRESS_BAD_CHARS = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]/;
|
||||||
|
|
||||||
|
export function validateProviderAddress(
|
||||||
|
raw: unknown,
|
||||||
|
fieldLabel: string,
|
||||||
|
): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||||||
|
}
|
||||||
|
// CRLF → LF NORMALISIEREN bevor wir auf Länge prüfen – ein Editor der
|
||||||
|
// immer `\r\n` schickt würde sonst bei jedem Zeilenumbruch zwei
|
||||||
|
// Zeichen gegen das 500er-Cap zählen.
|
||||||
|
const normalized = raw.replace(/\r\n?/g, '\n');
|
||||||
|
if (PROVIDER_ADDRESS_BAD_CHARS.test(normalized)) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} enthält unzulässige Zeichen (HTML, Tabs oder Steuerzeichen sind in Postadressen nicht erlaubt).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (normalized.length > PROVIDER_ADDRESS_MAX_LEN) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} darf maximal ${PROVIDER_ADDRESS_MAX_LEN} Zeichen lang sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const trimmed = normalized.trim();
|
||||||
|
return trimmed === '' ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTES_DEFAULT_MAX = 2000;
|
||||||
|
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const stripped = stripHtml(raw) as string;
|
||||||
|
// CR allein → entfernen (CRLF → LF); restliche Steuerzeichen außer \n
|
||||||
|
// herausfiltern. Null/Form-Feed/Tabs raus.
|
||||||
|
const normalized = stripped
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||||
|
const trimmed = normalized.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
return trimmed.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
|
// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
|
||||||
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
|
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
|
||||||
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
|
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
|
||||||
|
|||||||
@@ -84,6 +84,57 @@ const BLOCKED_HOSTNAMES = new Set([
|
|||||||
'[::1]',
|
'[::1]',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pentest 59.4 (HIGH, 2026-06-01): Node's URL-Parser normalisiert
|
||||||
|
* IPv4-mapped IPv6 zur **Hex-Form**:
|
||||||
|
* `::ffff:127.0.0.1` → `::ffff:7f00:1`
|
||||||
|
* `::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS Metadata!)
|
||||||
|
* `::ffff:10.0.0.1` → `::ffff:a00:1`
|
||||||
|
*
|
||||||
|
* Die alten Patterns (`::ffff:127\.`, `::ffff:10\.` etc.) griffen nur
|
||||||
|
* auf die Dotted-Form – Angreifer konnten via URL-Brackets die Hex-Form
|
||||||
|
* an der Blocklist vorbeischleusen, weil `new URL()` umnormalisiert.
|
||||||
|
*
|
||||||
|
* Lösung: aus IPv4-mapped IPv6 extrahieren wir den IPv4-Anteil und
|
||||||
|
* lassen ihn durch die IPv4-Patterns laufen. Das deckt beide Schreib-
|
||||||
|
* weisen + ausgeschriebene Long-Form ab.
|
||||||
|
*/
|
||||||
|
function extractMappedIPv4(addr: string): string | null {
|
||||||
|
// Compact dotted: ::ffff:127.0.0.1
|
||||||
|
let m = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr);
|
||||||
|
if (m) return m[1];
|
||||||
|
// Compact hex: ::ffff:7f00:1
|
||||||
|
m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr);
|
||||||
|
if (m) {
|
||||||
|
const h1 = parseInt(m[1], 16);
|
||||||
|
const h2 = parseInt(m[2], 16);
|
||||||
|
if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null;
|
||||||
|
return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`;
|
||||||
|
}
|
||||||
|
// Expanded dotted: 0:0:0:0:0:ffff:127.0.0.1
|
||||||
|
m = /^0:0:0:0:0:ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr);
|
||||||
|
if (m) return m[1];
|
||||||
|
// Expanded hex: 0:0:0:0:0:ffff:7f00:1
|
||||||
|
m = /^0:0:0:0:0:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr);
|
||||||
|
if (m) {
|
||||||
|
const h1 = parseInt(m[1], 16);
|
||||||
|
const h2 = parseInt(m[2], 16);
|
||||||
|
if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null;
|
||||||
|
return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIPv4(ipv4: string, includePrivate: boolean): boolean {
|
||||||
|
if (BLOCKED_HOSTNAMES.has(ipv4)) return true;
|
||||||
|
for (const pattern of BLOCKED_PATTERNS) if (pattern.test(ipv4)) return true;
|
||||||
|
if (includePrivate) {
|
||||||
|
if (PRIVATE_HOSTNAMES.has(ipv4)) return true;
|
||||||
|
for (const pattern of PRIVATE_IP_PATTERNS) if (pattern.test(ipv4)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
||||||
if (!host) return false;
|
if (!host) return false;
|
||||||
const h = host.trim().toLowerCase();
|
const h = host.trim().toLowerCase();
|
||||||
@@ -92,6 +143,10 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
|||||||
for (const pattern of BLOCKED_PATTERNS) {
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
if (pattern.test(h)) return true;
|
if (pattern.test(h)) return true;
|
||||||
}
|
}
|
||||||
|
// 59.4: IPv4-mapped IPv6 entpacken und die IPv4 separat prüfen –
|
||||||
|
// egal ob Hex- oder Dotted-Form, egal ob compact oder expanded.
|
||||||
|
const mappedV4 = extractMappedIPv4(h);
|
||||||
|
if (mappedV4 && checkIPv4(mappedV4, BLOCK_PRIVATE_IPS)) return true;
|
||||||
if (BLOCK_PRIVATE_IPS) {
|
if (BLOCK_PRIVATE_IPS) {
|
||||||
if (PRIVATE_HOSTNAMES.has(h)) return true;
|
if (PRIVATE_HOSTNAMES.has(h)) return true;
|
||||||
for (const pattern of PRIVATE_IP_PATTERNS) {
|
for (const pattern of PRIVATE_IP_PATTERNS) {
|
||||||
@@ -123,6 +178,11 @@ export function isPrivateOrBlockedHost(host: string | null | undefined): boolean
|
|||||||
for (const pattern of PRIVATE_IP_PATTERNS) {
|
for (const pattern of PRIVATE_IP_PATTERNS) {
|
||||||
if (pattern.test(h)) return true;
|
if (pattern.test(h)) return true;
|
||||||
}
|
}
|
||||||
|
// 59.4: IPv4-mapped IPv6 strikt prüfen (Hex- + Dotted-Form,
|
||||||
|
// compact + expanded). Pentester konnte ::ffff:7f00:1 statt
|
||||||
|
// ::ffff:127.0.0.1 nutzen, weil URL-Parser umnormalisiert.
|
||||||
|
const mappedV4 = extractMappedIPv4(h);
|
||||||
|
if (mappedV4 && checkIPv4(mappedV4, true)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,187 @@ Vor jedem Launch mit echten Tokens probieren.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📝 Bewusste Akzeptanz – URL-encoded Route-Parameter
|
||||||
|
|
||||||
|
**Finding aus Pentest-Runde 85 (INFO, kein Security-Impact):**
|
||||||
|
|
||||||
|
`/api/stressfrei-emails/%31/...` (URL-encoded `1`) liefert dieselbe
|
||||||
|
Antwort wie `/api/stressfrei-emails/1/...` – die `requireIdParam`-
|
||||||
|
Validierung mit `/^\d+$/` sieht die schon dekodierte Form, weil
|
||||||
|
Express URL-Parameter **vor** dem Routing dekodiert.
|
||||||
|
|
||||||
|
**Wir akzeptieren das als by-design:**
|
||||||
|
|
||||||
|
1. RFC 3986 fordert genau dieses Verhalten: prozentual-codierte und
|
||||||
|
roh-Form derselben Zeichen müssen semantisch identisch behandelt
|
||||||
|
werden. `/1` und `/%31` ist HTTP-konform äquivalent.
|
||||||
|
2. Ein nachträglicher Strict-Check auf `req.url` (roh) würde
|
||||||
|
legitime Clients brechen – Browser-Bookmarks mit URL-Encoding,
|
||||||
|
curl-Calls mit `--data-urlencode`, Proxy-Tools die URL-encoden,
|
||||||
|
Mobile-Clients mit kanonischer URL-Normalisierung.
|
||||||
|
3. Der Pentester selbst bestätigt: **kein Security-Impact** – die ID
|
||||||
|
landet nach Dekodierung als gültige Zahl bei der gleichen
|
||||||
|
Auth-/IDOR-Logik, die auch für die rohe Form greift. Insbesondere
|
||||||
|
`canAccessStressfreiEmail` läuft identisch.
|
||||||
|
4. Validierungs- und Access-Control-Kette ist nach der Dekodierung
|
||||||
|
identisch dicht – Auth, Ownership, Rate-Limit, alles greift gleich.
|
||||||
|
|
||||||
|
**Code-Notiz:** kein Patch nötig. Diese Markdown-Sektion ist die
|
||||||
|
einzige Dokumentation, damit das in zukünftigen Pentest-Runden nicht
|
||||||
|
erneut als „offenes Finding" auftaucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Runde 86 – Vertrags-Identifier-Validierung
|
||||||
|
|
||||||
|
**Findings (beide LOW):**
|
||||||
|
|
||||||
|
- **R86.1**: Strings >999 Zeichen in `orderNumberAtSalesPlatform` / den
|
||||||
|
vier verwandten Sales-/Provider-Nummern-Feldern endeten mit
|
||||||
|
generischem 500 (DB-Overflow `VARCHAR(191)`) statt sauberem 400.
|
||||||
|
- **R86.2**: Attribut-Injection-Payload `foo" onerror="alert(1)`
|
||||||
|
(kein umschließender Tag) überlebte `stripHtml`. React escaped
|
||||||
|
Attribute, aber sobald der Wert in PDF-/Mail-/CSV-Export fließt,
|
||||||
|
ist es potentiell aktiv.
|
||||||
|
|
||||||
|
**Fix:** `validateContractIdentifier(raw, fieldLabel)` in
|
||||||
|
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
|
||||||
|
|
||||||
|
- Max-Länge 100 Zeichen (deutlich unter VARCHAR(191)).
|
||||||
|
- Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$` – Buchstaben, Ziffern,
|
||||||
|
Punkt, Bindestrich, Schrägstrich, Unterstrich und literales
|
||||||
|
Leerzeichen. Bewusst NICHT `\s` (kein CRLF/Tab → kein
|
||||||
|
Header-Injection-Vektor in CSV-/Mail-Exporten).
|
||||||
|
- Bei Verstoß: `ApiError(400, …)` mit konkreter Fehlermeldung
|
||||||
|
statt 500.
|
||||||
|
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
|
||||||
|
fünf Identifier-Felder (`customerNumberAtProvider`,
|
||||||
|
`contractNumberAtProvider`, `orderNumberAtSalesPlatform`,
|
||||||
|
`customerNumberAtSalesPlatform`, `contractNumberAtSalesPlatform`)
|
||||||
|
bei jedem Create/Update.
|
||||||
|
- Frontend: `maxLength={100}` als zusätzliche UX-Schicht im
|
||||||
|
ContractForm – Server-seitige Validierung bleibt die einzige
|
||||||
|
Wahrheit, das HTML-Attribut spart nur den unnötigen Round-Trip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Runde 87 – Whitelist vor Sanitizer (silent-mutation-Schutz)
|
||||||
|
|
||||||
|
**Finding (LOW): Sanitizer-Order maskiert Tag-Verstöße**
|
||||||
|
|
||||||
|
Im ursprünglichen R86-Fix lief `stripHtml(body)` **vor**
|
||||||
|
`validateContractIdentifier`. Das hatte einen subtilen Bypass:
|
||||||
|
|
||||||
|
| Payload | Status | Tatsächlich gespeichert |
|
||||||
|
|--------------------------------------|------------|-------------------------|
|
||||||
|
| `<b>bold</b>` | 200 OK | `"bold"` (silent strip) |
|
||||||
|
| `EVN<b>2024</b>` | 200 OK | `"EVN2024"` |
|
||||||
|
| `<script>alert(1)</script>` | **200 OK** | `null` – **vorherigen Wert überschrieben** |
|
||||||
|
| `foo<bar>baz` | 200 OK | `"foobarbaz"` |
|
||||||
|
|
||||||
|
Kein direkter XSS-Vektor (React + DB-Whitelist greifen weiterhin),
|
||||||
|
aber zwei reale UX-/Datenintegritäts-Risiken:
|
||||||
|
|
||||||
|
1. Admin tippt `VG<2024>001`, bekommt 200 zurück, gespeichert ist
|
||||||
|
`VG2024001` ohne Hinweis auf die Mutation.
|
||||||
|
2. Werte die komplett aus Tags bestehen (`<script>…</script>`)
|
||||||
|
werden vom Sanitizer auf den leeren String reduziert →
|
||||||
|
`null` in der DB → **vorheriger Wert wird stillschweigend
|
||||||
|
gelöscht**.
|
||||||
|
|
||||||
|
**Fix:** Validierungs-Reihenfolge für die fünf Identifier-Felder
|
||||||
|
umgedreht – `validateContractIdentifier` läuft jetzt **direkt
|
||||||
|
gegen den Raw-Input**, ohne dass `stripHtml` ihn vorher
|
||||||
|
glättet. Die strikte Whitelist
|
||||||
|
`^[A-Za-z0-9_\-/. ]{0,100}$` lehnt sowieso alles ab, was
|
||||||
|
`stripHtml` normalerweise abgefangen hätte (Tags, Schemes,
|
||||||
|
Zero-Width-Chars, Homoglyphe, Percent-Encoding) – Defense-in-
|
||||||
|
Depth bleibt unverändert, nur jetzt ehrlich (400 statt silent-200).
|
||||||
|
|
||||||
|
Single-Line-Patch in [`backend/src/controllers/contract.controller.ts`](../backend/src/controllers/contract.controller.ts)
|
||||||
|
`sanitizeContractBody`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Runde 89 – Provider-Adressfelder härten
|
||||||
|
|
||||||
|
**Findings (R89.1 MEDIUM + R89.2 LOW):**
|
||||||
|
|
||||||
|
Beim neuen Provider-Modal (`Kontakt + Kündigung`) wurden
|
||||||
|
`contactAddress` und `cancellationAddress` über `sanitizeNotes(…, 500)`
|
||||||
|
geleitet. Zwei Probleme:
|
||||||
|
|
||||||
|
- **R89.1**: `sanitizeNotes` macht `slice(0, 500)` statt 400 – 501+ Zeichen
|
||||||
|
wurden silent auf 500 abgeschnitten und mit 200 OK gespeichert.
|
||||||
|
- **R89.2**: stripHtml lief vor dem Length-Check – derselbe Bug wie R87.
|
||||||
|
`<script>…</script>` → leerer String → `null` in der DB → vorheriger
|
||||||
|
Wert ohne Fehlermeldung überschrieben.
|
||||||
|
|
||||||
|
**Fix:** Eigener `validateProviderAddress(raw, fieldLabel)` in
|
||||||
|
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
|
||||||
|
|
||||||
|
- Validiert den Raw-Input direkt – kein stripHtml davor.
|
||||||
|
- Max 500 Zeichen → `ApiError(400, …)` mit klarer Meldung.
|
||||||
|
- Zeichen-Blacklist `[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]` – erlaubt ist
|
||||||
|
nur LF (`\n`). HTML-Klammern (`<`, `>`), Tab, NUL, CR-allein, alle
|
||||||
|
anderen Control-Chars → 400. Tab raus weil Header-Injection-Vektor
|
||||||
|
für CSV-/Mail-Exporte und in einer Postadresse nie legitim.
|
||||||
|
- CRLF → LF normalisiert **vor** dem Length-Check, damit ein Editor
|
||||||
|
mit `\r\n`-Zeilenenden nicht jedes Newline doppelt zählt.
|
||||||
|
- Leerer / nur-Whitespace Input → `null` (Feld zurücksetzen).
|
||||||
|
|
||||||
|
Eingehängt in `stripProviderStrings` für die zwei Adressfelder. Die
|
||||||
|
übrigen fünf Kontakt-Felder (Email/Telefon/Fax) gehen weiter durch
|
||||||
|
`isValidEmail` / `sanitizePhoneField` – die hat der Pentester explizit
|
||||||
|
als sauber bestätigt (7/7 + 6/6 Angriffsvektoren geblockt).
|
||||||
|
|
||||||
|
**Bewusst nicht gefixt:** R89.3 (Anführungszeichen) und R89.4 (`\n`).
|
||||||
|
Der Pentester selbst sagt "kein unmittelbares Risiko, React escaped
|
||||||
|
korrekt". Quotes in `Anbieter "GmbH"` sind legitim, `\n` ist Teil
|
||||||
|
einer mehrzeiligen Postadresse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Runde 95 – Portal-Username-Validierung
|
||||||
|
|
||||||
|
**Findings (R95.1 MEDIUM + R95.3 LOW + R95.4 LOW):**
|
||||||
|
|
||||||
|
`portalUsername` (Manual-Input-Modus am Vertrag) hatte gar keine
|
||||||
|
Validierung. Drei nachweisbare Effekte:
|
||||||
|
|
||||||
|
- **R95.1**: `foo\r\nBcc:evil@x.de` (CRLF) wurde verbatim
|
||||||
|
gespeichert → Header-Injection-Vektor sobald der Wert in
|
||||||
|
Mail-Templates oder PDF-Footers landet.
|
||||||
|
- **R95.3**: `<script>alert(1)</script>@x.de` lief durch
|
||||||
|
`stripHtml` → stille Mutation zu `@x.de` (R87.1/R89.2-Pattern
|
||||||
|
auf neuem Feld).
|
||||||
|
- **R95.4**: >190 Zeichen → VARCHAR-Overflow → generischer 500
|
||||||
|
statt sauberem 400.
|
||||||
|
|
||||||
|
**Fix:** `validatePortalUsername(raw, fieldLabel)` in
|
||||||
|
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
|
||||||
|
|
||||||
|
- Whitelist `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind
|
||||||
|
CRLF, Tab, alle Control-Chars, Tags (`<`, `>`) und Quotes raus
|
||||||
|
→ R95.1 und R95.3 ohne extra Check.
|
||||||
|
- Max 100 Zeichen → `ApiError(400, …)` → R95.4 mit klarer Meldung.
|
||||||
|
- Raw-Input direkt validiert (kein `stripHtml` davor) – gleicher
|
||||||
|
R87-Pattern wie bei Contract-Identifier und Provider-Address.
|
||||||
|
- Eingehängt in `sanitizeContractBody` als eigener Branch.
|
||||||
|
|
||||||
|
**Bewusst NICHT übernommen: R95.2 (Email-Format-Pflicht).**
|
||||||
|
|
||||||
|
Der Pentester schlägt `z.string().email()` vor, weil der „Kunde
|
||||||
|
sich sonst nicht einloggen kann". Falsche Annahme: `portalUsername`
|
||||||
|
ist im Manual-Modus **nicht zwingend eine E-Mail**. Vodafone, 1&1,
|
||||||
|
EWE und etliche Stadtwerke nutzen reine Kundennummern (`12345678`),
|
||||||
|
Pseudonyme (`max.mustermann`) oder Customer-IDs als Portal-Login.
|
||||||
|
Eine Email-Pflicht würde legitime Logins ablehnen. Der Stressfrei-
|
||||||
|
Modus hängt sowieso an einer schon validierten Email-Stammdate
|
||||||
|
(`assertValidForwardingEmail`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🧭 Wann ist „dicht" dicht?
|
## 🧭 Wann ist „dicht" dicht?
|
||||||
|
|
||||||
100 % gibt es nicht. Erreicht ist:
|
100 % gibt es nicht. Erreicht ist:
|
||||||
|
|||||||
+586
@@ -97,6 +97,592 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log**
|
||||||
|
- Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
|
||||||
|
bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
|
||||||
|
`fileDownload.controller` ist eigentlich korrekt – sauberer Magic-
|
||||||
|
Byte-Check für PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest
|
||||||
|
gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
|
||||||
|
aber nicht reproduzieren.
|
||||||
|
- Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
|
||||||
|
extrahiert, finally-Block schließt File-Descriptor garantiert,
|
||||||
|
Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
|
||||||
|
- Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
|
||||||
|
es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
|
||||||
|
R30.13).
|
||||||
|
- Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
|
||||||
|
Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
|
||||||
|
der Fall im Prod-Log auf, falls er nochmal auftritt – bisher
|
||||||
|
war's silent.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler**
|
||||||
|
- R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
|
||||||
|
erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
|
||||||
|
Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
|
||||||
|
durchgehen.
|
||||||
|
- R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) – die im
|
||||||
|
Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
|
||||||
|
bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
|
||||||
|
- Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
|
||||||
|
läuft **vor** dem `sendEmail`-Aufruf:
|
||||||
|
- `attachments` muss Array oder undefined sein
|
||||||
|
- max 25 Anhänge
|
||||||
|
- jeder: `filename` non-empty String, `content` non-empty Base64-
|
||||||
|
String (Regex), optional `contentType` String
|
||||||
|
- max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
|
||||||
|
kein Buffer.from-Aufruf während der Validierung)
|
||||||
|
- Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
|
||||||
|
grün inkl. aller R97.1-Pentest-Payloads.
|
||||||
|
|
||||||
|
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
|
||||||
|
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
|
||||||
|
"Datei anhängen":
|
||||||
|
- **Vertragsdokumente**: listet alle am Vertrag gespeicherten Dokumente
|
||||||
|
gruppiert nach `documentType`. Auswahl → Server-Download via
|
||||||
|
`fileUrl` (Token-Auth, Per-File-Ownership-Check greift) → base64 →
|
||||||
|
direkt in die Anhang-Liste. Respektiert das 25-MB-Gesamtlimit.
|
||||||
|
- **Kundendaten einfügen**: zeigt nur Sections die tatsächlich Daten
|
||||||
|
haben (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag,
|
||||||
|
Bank, Ausweis). Pro Section Checkbox + Preview. Bei Bank +
|
||||||
|
Ausweis zusätzlich Sub-Checkbox "als PDF anhängen", wenn ein
|
||||||
|
`documentPath` vorhanden ist.
|
||||||
|
- Beim Bestätigen werden die Text-Blöcke an das Body-Ende gehängt
|
||||||
|
(mit `\n\n`-Separator), Anhänge per `serverFileToAttachment` aus
|
||||||
|
`composeAttachmentHelpers.ts` gezogen. Anhang-Limit (25 MB gesamt)
|
||||||
|
wird beidseitig geprüft, drüberlaufende Dateien werden mit Toast
|
||||||
|
übersprungen statt silent weggeschluckt.
|
||||||
|
- Helpers (`composeAttachmentHelpers.ts`):
|
||||||
|
- `serverFileToAttachment(path, filename)` – fetch via Token-URL
|
||||||
|
→ Blob → base64 → `EmailAttachment`.
|
||||||
|
- `totalAttachmentBytes` – Größen-Check unter Berücksichtigung der
|
||||||
|
~33 % base64-Overhead.
|
||||||
|
- `bankCardAttachmentName` / `identityDocAttachmentName` –
|
||||||
|
sprechende Dateinamen für den Empfänger.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R95 – Portal-Username (Manual-Modus) härten**
|
||||||
|
- R95.1 (MEDIUM): `foo\r\nBcc:evil@x.de` → Header-Injection-Vektor
|
||||||
|
sobald der Wert in Mail-Templates / PDF-Footer landet.
|
||||||
|
- R95.3 (LOW): `<script>…</script>@x.de` → silent stripHtml-Mutation
|
||||||
|
(R87.1-Pattern, dritter Treffer auf demselben Bug).
|
||||||
|
- R95.4 (LOW): >190 Zeichen → VARCHAR-Overflow → 500 statt 400.
|
||||||
|
- Fix: `validatePortalUsername()` in `sanitize.ts` mit Whitelist
|
||||||
|
`^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind CRLF, Tab, alle
|
||||||
|
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
|
||||||
|
Check. Max 100 → `ApiError(400)` → R95.4 sauber. Raw-Input direkt
|
||||||
|
validiert (R87-Pattern). Eingehängt in `sanitizeContractBody`.
|
||||||
|
- Frontend: `maxLength={100}` am Input.
|
||||||
|
- **R95.2 bewusst nicht übernommen** (Email-Format-Pflicht): das
|
||||||
|
Feld ist im Manual-Modus nicht zwingend eine E-Mail – Vodafone,
|
||||||
|
1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als
|
||||||
|
Portal-Login. Doku in `SECURITY-HARDENING.md § Runde 95`.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R93 – Leerer String != fehlender Param**
|
||||||
|
- R93.1 (INFO): `?accountId=` (explizit-leer) wurde wie `?accountId`
|
||||||
|
weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.
|
||||||
|
Pentester-Spec: leerer String ist KEINE gültige Zahl.
|
||||||
|
- Fix im `parsePositiveIntQuery()`-Helper: striktere Absent-Logik
|
||||||
|
(`v === undefined` ist absent; `''`, `' '`, alles andere muss
|
||||||
|
parsen). Required + optional Modes unverändert.
|
||||||
|
- Float-Grenzfall (`accountId=5.5` → 5 via `parseInt`) bleibt als
|
||||||
|
by-design akzeptiert (Pentester-Bestätigung, kein Security-Impact).
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R92 – Strict-400 für accountId auf Vertrags-Endpunkten**
|
||||||
|
- R91-Fix war silent-undefined bei invaliden Werten: `accountId=abc`
|
||||||
|
auf `GET /contracts/:id/emails` ergab "kein Filter" → Mailbox-
|
||||||
|
Isolation brach (alle Postfächer sichtbar). Pentester R92: per
|
||||||
|
Design sind Vertrags-Endpunkte immer pro Postfach, also strict-400.
|
||||||
|
- Fix: `parsePositiveIntQuery(v, label, res, { required? })`
|
||||||
|
ersetzt den alten silent-Helper. Modes:
|
||||||
|
- default (optional): fehlend/leer → `undefined` (kein Filter),
|
||||||
|
invalid → 400
|
||||||
|
- `{ required: true }`: fehlend/leer **oder** invalid → 400
|
||||||
|
- Verteilung:
|
||||||
|
- Contract-Emails, Contract-Folder-Counts: `{ required: true }`
|
||||||
|
- Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox-
|
||||||
|
View ist legitim), invalid → 400
|
||||||
|
- Frontend hat schon ein `enabled: !!selectedAccountId`-Guard auf
|
||||||
|
den Vertrags-Queries – kein UX-Bruch.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R91 – NaN-Bypass auf accountId-Query-Param**
|
||||||
|
- R91.1 (LOW): `accountId=abc` → `parseInt('abc')` = `NaN` → der
|
||||||
|
Ternary im Controller gab `NaN` an den Service, `if (NaN)` ist
|
||||||
|
falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit
|
||||||
|
ungültigem `accountId` sah alle Mailbox-Mails für seinen Vertrag
|
||||||
|
statt nur die aus dem gewählten Postfach (kein Cross-Customer-
|
||||||
|
Leak — `canAccessContract` greift weiter).
|
||||||
|
- Fix: zentraler `parsePositiveIntParam()` im `cachedEmail.controller.ts`,
|
||||||
|
der nur positive Ganzzahlen aus dem Query-String akzeptiert und
|
||||||
|
alles andere zu `undefined` macht. Eingesetzt in allen 5
|
||||||
|
Endpunkten, die `accountId`/`contractId` aus Query nehmen
|
||||||
|
(Contract-Emails, Contract-Folder-Counts, Customer-Emails,
|
||||||
|
Trash, Trash-Count) – auch da, wo der Pentester nicht getestet
|
||||||
|
hat, weil derselbe Pattern überall stand.
|
||||||
|
|
||||||
|
- [x] **🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht**
|
||||||
|
- Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im
|
||||||
|
Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN Postfächern
|
||||||
|
des Kunden angezeigt, egal welches Postfach im Selector aktiv war.
|
||||||
|
Im Vertrag fehlte zusätzlich der Vertrags-Filter für den Papierkorb.
|
||||||
|
- Backend:
|
||||||
|
- `getEmailsForContract` controller akzeptiert jetzt `accountId`-
|
||||||
|
Query-Param und reicht ihn als `stressfreiEmailId` an
|
||||||
|
`getCachedEmails` weiter (der hat den Filter eh schon implementiert,
|
||||||
|
nur niemand hat ihn aufgerufen).
|
||||||
|
- `getTrashEmails` (controller + service) akzeptiert `accountId` und
|
||||||
|
`contractId` als optionale Filter. Default-Verhalten unverändert,
|
||||||
|
wenn keiner gesetzt ist.
|
||||||
|
- `getFolderCountsForContract` akzeptiert optional `stressfreiEmailId`,
|
||||||
|
bekommt zusätzlich `trash` + `trashUnread` ins Result – sonst läge
|
||||||
|
der Trash-Badge im Vertrag wieder account-global, während die Liste
|
||||||
|
contract-scoped ist.
|
||||||
|
- Frontend:
|
||||||
|
- `cachedEmailApi.getForContract` / `getTrash` / `getContractFolderCounts`
|
||||||
|
nehmen den Filter entgegen.
|
||||||
|
- `ContractEmailsSection` reicht `selectedAccountId` in alle drei
|
||||||
|
Queries durch und nimmt es in den queryKey mit auf – sonst greift
|
||||||
|
der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge
|
||||||
|
kommt jetzt aus den contract-scoped Counts, damit Badge und Liste
|
||||||
|
synchron laufen.
|
||||||
|
- `EmailClientTab` reicht `selectedAccountId` in die Trash-Query
|
||||||
|
durch (Inbox/Sent waren schon korrekt).
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R89 – Provider-Adressfelder härten**
|
||||||
|
- R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)`
|
||||||
|
statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit
|
||||||
|
200 OK gespeichert.
|
||||||
|
- R89.2 (LOW): `stripHtml` lief vor dem Length-Check – `<script>…</script>`
|
||||||
|
reduzierte auf leeren String → `null` in der DB → vorheriger Wert
|
||||||
|
silent überschrieben (R87.1-Pattern auf Adress-Feldern).
|
||||||
|
- Fix: eigener `validateProviderAddress()` in `sanitize.ts`. Raw-Input,
|
||||||
|
max 500 → `ApiError(400)`, Blacklist `<`, `>`, Tab, alle Control-
|
||||||
|
Chars außer `\n`. CRLF → LF normalisiert vor Length-Check.
|
||||||
|
Eingehängt in `stripProviderStrings`.
|
||||||
|
- R89.3 (Quotes) + R89.4 (`\n`): bewusst nicht gefixt – Pentester
|
||||||
|
bestätigt "kein unmittelbares Risiko", React escaped korrekt,
|
||||||
|
sind legitime Bestandteile mehrzeiliger Postadressen.
|
||||||
|
- Doku in `SECURITY-HARDENING.md § Runde 89`.
|
||||||
|
|
||||||
|
- [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,
|
||||||
|
`<script>…</script>` reduzierte auf leeren String → `null` in
|
||||||
|
der DB → vorheriger Wert ohne Fehlermeldung überschrieben.
|
||||||
|
- Fix: Validierungs-Reihenfolge für die fünf Identifier-Felder
|
||||||
|
umgedreht – `validateContractIdentifier` läuft jetzt direkt
|
||||||
|
gegen den Raw-Input. Die strikte Whitelist lehnt eh alles
|
||||||
|
ab, was stripHtml normalerweise auffangen würde (Tags,
|
||||||
|
Schemes, Zero-Width, Homoglyphe, Percent-Encoding) – Defense-
|
||||||
|
in-Depth bleibt, nur ehrlich (400 statt silent-200).
|
||||||
|
- Single-Line-Patch in `contract.controller.ts`, Doku in
|
||||||
|
`SECURITY-HARDENING.md § Runde 87`.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R86 – Vertrags-Identifier härten**
|
||||||
|
- R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/
|
||||||
|
Auftragsnummer warfen 500 (DB-Overflow `VARCHAR(191)`) statt 400.
|
||||||
|
- R86.2 (LOW/INFO): Attribut-Injection ohne umschließenden Tag
|
||||||
|
(`foo" onerror=…`) überlebte `stripHtml` – kein Risiko in der React-
|
||||||
|
UI, aber relevant für PDF/Mail/CSV-Export.
|
||||||
|
- Fix: zentraler `validateContractIdentifier()` in `sanitize.ts`
|
||||||
|
mit Max-100 und Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$`. Bewusst
|
||||||
|
literales Space statt `\s`, damit kein CRLF/Tab passiert (Header-
|
||||||
|
Injection). Wirft `ApiError(400, …)` mit klarer Meldung.
|
||||||
|
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
|
||||||
|
fünf Identifier-Felder bei Create/Update. ContractForm bekommt
|
||||||
|
`maxLength={100}` als UX-Schicht. Doku in
|
||||||
|
`docs/SECURITY-HARDENING.md` § Runde 86.
|
||||||
|
|
||||||
|
- [x] **🆕 Vertrag: Auftragsnummer bei Vertriebsplattform**
|
||||||
|
- Neues optionales Feld `Contract.orderNumberAtSalesPlatform`
|
||||||
|
(`VARCHAR(191) NULL`), Migration
|
||||||
|
`20260619100000_contract_order_number_at_sales_platform` mit
|
||||||
|
`IF NOT EXISTS`.
|
||||||
|
- Im ContractForm direkt **vor** der Kundennummer der
|
||||||
|
Vertriebsplattform angeordnet (Wunsch des Users).
|
||||||
|
ContractDetail zeigt sie als eigene Zeile mit Copy-Button vor
|
||||||
|
den anderen beiden Sales-Platform-Feldern.
|
||||||
|
- Audit-Log-Mapping, Renewal-Copy (VVL-Folgevertrag) und
|
||||||
|
XSS-Strip-Allowlist (`CONTRACT_DISPLAY_STRING_FIELDS`)
|
||||||
|
mitgezogen, damit das neue Feld die gleichen Garantien wie
|
||||||
|
Kunden-/Vertragsnummer bekommt.
|
||||||
|
|
||||||
|
- [x] **🐞 Entfernte Weiterleitungen kamen via Auto-Import zurück**
|
||||||
|
- Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kürzer →
|
||||||
|
Plesk-Sync läuft → Auto-Import (`Pentest 83.x`) sieht „c ist in
|
||||||
|
Plesk aber nicht in DB" → schreibt `c` zurück in
|
||||||
|
`additionalForwardingEmails` → Diff sagt nichts zu entfernen.
|
||||||
|
- Ursache: Auto-Import war für **alle** Sync-Aufrufe aktiv. Beim
|
||||||
|
Sync-Button-Klick will der User Plesk-Bestand übernehmen (Import
|
||||||
|
sinnvoll), beim Add/Remove im Modal ist die DB-Liste die
|
||||||
|
explizite Intent (Import schädlich).
|
||||||
|
- Fix: `syncForwardingForEmail(id, { autoImportPleskMembers? })`
|
||||||
|
mit Default `true`. `setAdditionalForwards` ruft mit
|
||||||
|
`false` auf → entfernte Adressen verschwinden jetzt sauber bei
|
||||||
|
Plesk. Sync-Button-Pfad bleibt unverändert (importiert weiterhin
|
||||||
|
alte Bestands-Members).
|
||||||
|
|
||||||
|
- [x] **🐞 Plesk-Sync: `-forwarding-addresses set:` existiert gar nicht**
|
||||||
|
- Folge-Bug nach `a83358b`/`24e152b`: Sync verändert Plesk weiterhin
|
||||||
|
nicht. `plesk bin mail --help` zeigt: `-forwarding-addresses`
|
||||||
|
akzeptiert ausschließlich `add:` und `del:` – unser `set:` wurde
|
||||||
|
von Plesk silent verworfen. Außerdem gibt es keine separate
|
||||||
|
`-mailgroup`-Option; was Plesk im `--info` als `Mailgroup: true`
|
||||||
|
zeigt, ist genau das, was `-forwarding true` in der CLI setzt
|
||||||
|
(doppelt benannt). Mein vorheriges `-mailgroup false` lief auf
|
||||||
|
den Phantom-Parameter und triggerte `Unrecognized option`.
|
||||||
|
- `updateForwardTargets` baut jetzt den Diff: aktuelle Mailgroup-
|
||||||
|
Members (aus `emailExists`) gegen Soll-Liste; `del:<entfernt>` +
|
||||||
|
`add:<neu>` in zwei separaten CLI-Calls. Idempotent.
|
||||||
|
Case-insensitive – `Bruns.Gerhard` ≡ `bruns.gerhard`.
|
||||||
|
- Phantom-`-mailgroup`-Parameter entfernt.
|
||||||
|
- Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
|
||||||
|
nichts entfernt, nur `bzirks@gmx.de` hinzugefügt.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 83.1-83.3: Auto-Import-Pfad härten**
|
||||||
|
- **83.1 MEDIUM:** Auto-Import in `syncForwardingForEmail` umging
|
||||||
|
`assertValidForwardingEmail`. Plesk-Member wie `attacker@plesk.internal`
|
||||||
|
oder `evil@x.local` wären ohne TLD-Block-Check (71.1) in unsere
|
||||||
|
DB gewandert. Fix: jeder importierte Member läuft durch
|
||||||
|
`assertValidForwardingEmail`; ungültige werden silent gedroppt
|
||||||
|
und auf `console.debug`-Level geloggt.
|
||||||
|
- **83.2 LOW:** Self-Forward-Schutz (81.1) lief nur im Add-Pfad.
|
||||||
|
Wenn Plesk die eigene Adresse als Mailgroup-Member führte, wäre
|
||||||
|
sie beim Auto-Import in die DB-Liste gerutscht → nach dem
|
||||||
|
Umschalten auf Forwarding Mail-Loop. Fix:
|
||||||
|
`seenKeys.add(canonicalEmailKey(stressfreiEmail.email))` vor
|
||||||
|
der Import-Schleife.
|
||||||
|
- **83.3 INFO:** PII-Log auf `console.debug` umgestellt (statt
|
||||||
|
`console.log` auf Default-Level).
|
||||||
|
- Smoke-Test mit gemischter Plesk-Liste: `karibik61@web.de` (legit)
|
||||||
|
importiert, `attacker@plesk.internal` + `evil@x.local` per 83.1
|
||||||
|
abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2
|
||||||
|
abgelehnt, Customer-Stamm-Mail + Default deduped.
|
||||||
|
|
||||||
|
- [x] **🐞 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht**
|
||||||
|
- Prod-Bug: User trägt zusätzliche Weiterleitung ein, Toast meldet
|
||||||
|
Erfolg, aber Plesk übernimmt nichts. Ursache: Plesk hat zwei
|
||||||
|
Verteil-Mechanismen, **Mailgroup** (alte CLI-Anlagen,
|
||||||
|
`Group member(s):`) und **Forwarding** (`Forward request:`). Unser
|
||||||
|
Sync schrieb nur in Forwarding, die Adresse lief aber via Mailgroup
|
||||||
|
→ unsere `set:`-Befehle landeten in einer ungenutzten Tabelle.
|
||||||
|
Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt
|
||||||
|
wurden (Forwarding-Modus von Anfang an).
|
||||||
|
- `EmailExistsResult` um `mailgroupActive` + `mailgroupMembers` +
|
||||||
|
`forwardingActive` + `forwardingTargets` erweitert.
|
||||||
|
- `pleskProvider.emailExists` parst alle vier Felder aus dem
|
||||||
|
`--info`-stdout (`Mailgroup: true|false`, `Group member(s): ...`,
|
||||||
|
`Forward request: ...`).
|
||||||
|
- `pleskProvider.updateForwardTargets` setzt jetzt zusätzlich
|
||||||
|
`-mailgroup false`, damit der Legacy-Mechanismus deaktiviert wird
|
||||||
|
und nur noch Forwarding aktiv ist.
|
||||||
|
- `syncForwardingForEmail`: vor dem Plesk-Update werden bestehende
|
||||||
|
Mailgroup-Members + Forwarding-Targets abgeholt und in unsere
|
||||||
|
`additionalForwardingEmails`-Liste **importiert** (canonical-Key-
|
||||||
|
Dedup). Verlustfrei – kein bestehender Empfänger fällt beim
|
||||||
|
Umschalten auf Forwarding raus. Import-Fehler werden geloggt,
|
||||||
|
aber der eigentliche Sync läuft trotzdem.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider**
|
||||||
|
- Bug: User konnte die Stressfrei-Adresse selbst (z.B.
|
||||||
|
`max.mustermann@stressfrei-wechseln.net`) als zusätzliches
|
||||||
|
Weiterleitungsziel eintragen – auch Plus-Varianten davon. Plesk
|
||||||
|
leitet auf sich selbst um → Mail-Loop.
|
||||||
|
- Backend (`setAdditionalForwards`): zieht jetzt zusätzlich
|
||||||
|
`meta.email` aus der DB und vergleicht `canonicalEmailKey(eintrag)`
|
||||||
|
gegen `canonicalEmailKey(meta.email)`. Bei Treffer hartes
|
||||||
|
`ApiError(400)` mit klarer Self-Forward-Meldung statt silent dedup
|
||||||
|
– der User soll merken, dass sein Eintrag bewusst abgelehnt wurde.
|
||||||
|
- Frontend (`AdditionalForwardsModal`): zusätzlich proaktive
|
||||||
|
Validierung im Sub-Modal mit identischem `canonicalize`-Helper
|
||||||
|
(Plus-Tag strippen, lowercase). Neuer Prop `selfEmail`, damit
|
||||||
|
auch der Create-Modus (vor dem Persistieren) den Check fahren
|
||||||
|
kann. Spart einen Roundtrip + zeigt sofort eine sprechende
|
||||||
|
Meldung „… zeigt auf die Adresse selbst – Mail-Loop".
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 77.3 (LOW): `requireIdParam` ließ Float-IDs durch**
|
||||||
|
- `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
|
||||||
|
den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
|
||||||
|
auf die echte ID 4 statt 400 zurückzuliefern. Gleiches gilt für
|
||||||
|
`4.0` und Exp-Notation `4e1`.
|
||||||
|
- Fix: vorm Parsen Regex `/^\d+$/` auf die rohe `req.params.<name>`-
|
||||||
|
Eingabe. Nur reine Ziffern erlaubt – keine Floats, Exp, Vorzeichen,
|
||||||
|
Whitespace, Hex.
|
||||||
|
- Smoke-Test (17 Cases): `4.0`, `4.5`, `4e1`, `4E2`, `0`, `-4`, `+4`,
|
||||||
|
`0x10`, `1.0e0`, leading/trailing Space alle abgelehnt; `1`, `4`,
|
||||||
|
`100`, `9999999` durchgewunken.
|
||||||
|
|
||||||
|
- [x] **🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt**
|
||||||
|
- Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
|
||||||
|
anlegen (siehe Screenshot mit 2× `max.mustermann@...`). `createEmail`
|
||||||
|
hatte keinen Duplikatscheck, `updateEmail` ebenfalls nicht.
|
||||||
|
- Service: Vor `prisma.create` jetzt `findFirst` auf
|
||||||
|
`(customerId, email)` (case-insensitive). Bei Treffer → `ApiError(409)`.
|
||||||
|
Unterschiedliche Meldung für aktive vs. inaktive Duplikate
|
||||||
|
(Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
|
||||||
|
- `updateEmail`: gleicher Check beim Umbenennen, mit `NOT id`-Exclude.
|
||||||
|
- Controller: `catch`-Blöcke honorieren jetzt den `ApiError.statusCode`
|
||||||
|
(vorher pauschal 400) → 409 kommt sauber durch.
|
||||||
|
- Frontend: `updateMutation` bekam ein `onError`, damit der 409 nicht
|
||||||
|
nur ins Leere lief.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 71.1–71.4: Härtung der Zusatz-Weiterleitungen**
|
||||||
|
- **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`,
|
||||||
|
`corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`,
|
||||||
|
`example`, `intranet`, `localdomain`, `arpa`) werden in
|
||||||
|
`assertValidForwardingEmail` jetzt hart abgelehnt. Verhindert
|
||||||
|
Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups.
|
||||||
|
- **71.2 LOW:** Neuer Helper `canonicalEmailKey` normalisiert Mail-
|
||||||
|
Adressen für den Dedup-Vergleich (Plus-Tag wegstrippen,
|
||||||
|
lowercase). `billing+pentest@x.de` und `billing@x.de` werden als
|
||||||
|
dasselbe Ziel erkannt – auch im Vergleich zur Kunden-Stamm-Mail
|
||||||
|
und im sync-Pfad gegen `config.defaultForwardEmail`.
|
||||||
|
- **71.3 INFO:** Neuer `requireIdParam(req, res, paramName)`-Helper
|
||||||
|
fängt nicht-numerische Route-Parameter und liefert 400 statt 500.
|
||||||
|
Alle acht parseInt-Stellen in `stressfreiEmail.controller.ts`
|
||||||
|
umgestellt (auch über das gemeldete Finding hinaus).
|
||||||
|
- **71.4 INFO:** `setAdditionalForwards` rollt den DB-Stand bei
|
||||||
|
Provider-Sync-Fehler zurück, damit DB und Plesk nicht
|
||||||
|
auseinanderlaufen. Vorheriger `additionalForwardingEmails`-Wert
|
||||||
|
wird vor dem Update gemerkt und bei Fail wieder eingespielt.
|
||||||
|
- Smoke-Tests bestätigen: 11 reservierte TLDs abgelehnt, 4 echte
|
||||||
|
TLDs (`de`, `com`, `co.uk`, `museum`) durchgewinkt, Plus-Tag-
|
||||||
|
Strip funktioniert (auch mit Multi-Plus + Casing).
|
||||||
|
|
||||||
|
- [x] **🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen**
|
||||||
|
- Im „Adresse hinzufügen"-Modal erscheint der „Weitere
|
||||||
|
Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
|
||||||
|
anlegen" angehakt ist. Liste wird lokal gepflegt, Provider-Sync
|
||||||
|
läuft direkt nach `createEmail` mit der vollen Liste.
|
||||||
|
- Sub-Modal generalisiert: `value`/`onChange`-Pattern (controlled).
|
||||||
|
Mit `email`-Prop → API-Persist pro Änderung (Edit). Ohne `email`
|
||||||
|
→ lokaler State (Create). Counter-Badge am Button zeigt die
|
||||||
|
Anzahl Adressen.
|
||||||
|
|
||||||
|
- [x] **🆕 Stressfrei-Wechseln-Adressen: zusätzliche Weiterleitungsziele**
|
||||||
|
- Neues Feld `StressfreiEmail.additionalForwardingEmails` (Text/
|
||||||
|
JSON-Array), Migration `20260608100000_stressfrei_email_additional_forwards`
|
||||||
|
mit `IF NOT EXISTS`.
|
||||||
|
- `syncForwardingForEmail` zieht die zusätzlichen Adressen mit
|
||||||
|
in die Plesk-`set:`-Liste ein (case-insensitive Dedup gegen
|
||||||
|
`customer.email` und `config.defaultForwardEmail`).
|
||||||
|
- Neuer Endpoint `PUT /api/stressfrei-emails/:id/additional-forwards`
|
||||||
|
mit Body `{ emails: string[] }` – ersetzt die Liste und syncht
|
||||||
|
direkt mit dem Provider. Hard-Cap 20 Adressen, Format-Check per
|
||||||
|
Regex, Audit-Log.
|
||||||
|
- Im StressfreiEmailModal neuer „Weitere Weiterleitungen"-Button
|
||||||
|
(Edit-Modus + `providerStatus === exists`) öffnet ein Sub-Modal
|
||||||
|
mit Liste + Add/Remove. Jede Änderung geht sofort live.
|
||||||
|
|
||||||
|
- [x] **🐞 Modal-Felder ließen sich nicht editieren (Zähler/Bankkarte/Ausweis/Zählerstand)**
|
||||||
|
- Vier identische Vorkommen desselben Anti-Patterns wie beim
|
||||||
|
AddressModal-Fix von 2026-06-03: `setFormData(getInitialFormData())`
|
||||||
|
im Render-Body, getriggert durch `formData.X !== prop.X`. Jeder
|
||||||
|
Tastendruck setzte den State zurück.
|
||||||
|
- Fix in allen vier Modals (MeterModal, BankCardModal,
|
||||||
|
IdentityDocumentModal, MeterReadingModal): nach `useEffect` mit
|
||||||
|
`[<entity>?.id]`-Dependency umgezogen.
|
||||||
|
|
||||||
|
- [x] **🐞 JpgToPdfModal: PDF blieb trotz vorherigem Fix bei 20+ MB**
|
||||||
|
- Stage-Test: 2 Handy-JPGs → 23 MB PDF. Ursache: Smartphone-Fotos
|
||||||
|
haben 4000-6000 px Kante (24 MP), das vergrößert die JPEG-Datei
|
||||||
|
auch ohne Re-Encode auf 5-10 MB pro Bild.
|
||||||
|
- Fix: Bilder **beim Hinzufügen** auf max. 2400 px lange Kante
|
||||||
|
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
|
||||||
|
Quality 0.92 (Lightroom-Default, kein wahrnehmbarer Unterschied)
|
||||||
|
persistiert. Vorschau-Thumbnail, Rotation/Flip und finaler
|
||||||
|
PDF-Embed laufen alle auf dem skalierten Bild.
|
||||||
|
- Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF (statt 23 MB).
|
||||||
|
|
||||||
|
- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
|
||||||
|
- `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
|
||||||
|
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
|
||||||
|
`ExternalLink`-Icon. CustomerDetail übergibt den Builder mit
|
||||||
|
`?tab=<id>`-Query-Param, der eh schon vom URL-Sync genutzt wird.
|
||||||
|
- `target="_blank"` + `rel="noopener noreferrer"` + Klick-stopPropagation,
|
||||||
|
damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.
|
||||||
|
|
||||||
|
- [x] **🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform**
|
||||||
|
- Zwei neue optionale Felder
|
||||||
|
`Contract.customerNumberAtSalesPlatform` +
|
||||||
|
`contractNumberAtSalesPlatform`, Migration
|
||||||
|
`20260603150000_contract_sales_platform_numbers` mit
|
||||||
|
`IF NOT EXISTS`.
|
||||||
|
- Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim
|
||||||
|
Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen
|
||||||
|
mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip
|
||||||
|
(CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
|
||||||
|
- Bonus: das fehlende `contractNumberAtProvider` im Renewal-Copy
|
||||||
|
und Audit-Label-Mapping ist gleich mit drin – wurde bisher
|
||||||
|
nicht in VVL-Folgeverträge kopiert.
|
||||||
|
|
||||||
|
- [x] **🆕 Email-Links öffnen im neuen Tab**
|
||||||
|
- In `EmailDetail` nach der DOMPurify-Sanitize jedes `<a>`-Element
|
||||||
|
auf `target="_blank"` + `rel="noopener noreferrer"` gesetzt. Letzteres
|
||||||
|
verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen
|
||||||
|
in einem `useMemo`, das nur bei Wechsel der Email neu rechnet.
|
||||||
|
|
||||||
|
- [x] **🐞 assertSafePdf: jspdf-PDFs mit JPEGs fälschlich als „JavaScript" blockiert**
|
||||||
|
- Stage-Bug: User lädt Ausweis als „JPGs → PDF" hoch → 415 mit
|
||||||
|
Meldung „PDF enthält JavaScript-Action". Backend hat den jspdf-
|
||||||
|
Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream
|
||||||
|
enthielten zufällig die Byte-Folge „/JavaScript" → Pattern-Match.
|
||||||
|
- Fix: vor dem Pattern-Scan `stream..endstream`-Blöcke aus dem
|
||||||
|
PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb
|
||||||
|
von Streams (in PDF-Object-Dictionaries) – Binär-Streams enthalten
|
||||||
|
Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
|
||||||
|
- Smoke-Test: jspdf-Style-PDF mit `/JavaScript`-Bytes im Stream
|
||||||
|
durchgewinkt, echte `/OpenAction /S /JavaScript` weiterhin
|
||||||
|
blockiert, clean PDF weiterhin OK.
|
||||||
|
|
||||||
|
- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
|
||||||
|
- `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
|
||||||
|
`formData.street !== address.street`. Jeder Tastendruck löste neu
|
||||||
|
aus → Reset auf DB-Wert → Cursor sprang zurück → keine Eingabe
|
||||||
|
möglich.
|
||||||
|
- Fix: in `useEffect` mit `[address?.id]`-Dependency umgezogen.
|
||||||
|
Re-Init nur beim Wechsel/Open, nicht bei jedem Render.
|
||||||
|
|
||||||
|
- [x] **🐞 Upload-Limit: Multer 10 MB → 25 MB (Ausweis-Scans, JPGs→PDF)**
|
||||||
|
- Zwei Smartphone-Fotos zu PDF kombiniert kratzten am 10-MB-Limit
|
||||||
|
(auch mit Original-Bytes-Optimierung aus 431792e). Limits in
|
||||||
|
`upload.routes.ts`, `gdpr.routes.ts`, `contract.routes.ts` auf
|
||||||
|
25 MB hochgezogen. `pdfTemplate.routes.ts` war eh schon bei 20 MB.
|
||||||
|
- Frontend: `FileUpload`-Hinweis „max. 10 MB" → „max. 25 MB".
|
||||||
|
|
||||||
|
- [x] **🆕 SIM-Karten: Checkbox „eSIM" zwischen „Hauptkarte" und „Multisim"**
|
||||||
|
- Hardware-Plastikkarte vs. eSIM-Profil ist eine eigene Eigenschaft –
|
||||||
|
eSIM kann sowohl Hauptkarte als auch Multisim sein, also zusätzlich
|
||||||
|
statt entweder/oder.
|
||||||
|
- Schema: `SimCard.isEsim Boolean @default(false)`, Migration
|
||||||
|
`20260603100000_sim_card_esim` mit `IF NOT EXISTS`.
|
||||||
|
- Backend: alle vier SimCard-Schreibpfade in `contract.service.ts`
|
||||||
|
(Create + Update + Follow-Up + Renewal) plus FE-Type-Definition.
|
||||||
|
- UI: dritte Checkbox in `ContractForm` zwischen Hauptkarte und
|
||||||
|
Multisim. ContractDetail zeigt blauen `eSIM`-Badge neben Hauptkarte.
|
||||||
|
|
||||||
|
- [x] **🆕 JpgToPdfModal: PDF-Größe drastisch reduziert (Original-Bytes + Quality 0.95)**
|
||||||
|
- Stage-Bug: 2 Handy-JPGs à 2 MB → PDF >10 MB → Multer 413. Ursache:
|
||||||
|
Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB
|
||||||
|
auf (Quality 100 % heißt nicht „identisch zum Original", sondern
|
||||||
|
„möglichst viele Bits pro Pixel" – ein schon JPEG-komprimiertes
|
||||||
|
Smartphone-Foto wird so künstlich 4-8× größer).
|
||||||
|
- **Fix 1:** Wenn Rotation/Flip unverändert (Standardfall), wird die
|
||||||
|
Original-DataURL 1:1 in die PDF eingebettet – kein Canvas-Roundtrip,
|
||||||
|
keine Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Funktioniert für
|
||||||
|
JPEG und PNG (Format-Detection per `data:image/png`-Prefix).
|
||||||
|
- **Fix 2:** Bei Transformation: `toDataURL('image/jpeg', 0.95)` statt
|
||||||
|
`1.0`. Visuell identisch für Foto-Inhalte (Adobe-Lightroom-Default),
|
||||||
|
aber 50-70 % kleiner.
|
||||||
|
- Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
|
||||||
|
16-30 MB), 2 gedrehte ≈ 5-8 MB.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 70.2 (LOW): falscher 500 statt 415 bei verbotenem MIME-Type**
|
||||||
|
- Globaler Error-Handler in `index.ts:461` matcht
|
||||||
|
`/sind erlaubt|nicht erlaubt/i` und mappt auf 415. Meine 70.1-
|
||||||
|
Message „… WebP erlaubt" (ohne „sind") rutschte durch und landete
|
||||||
|
bei 500 + Error-Log-Spam.
|
||||||
|
- Fix: 1 Zeile in `contract.routes.ts` – `… WebP-Dateien sind
|
||||||
|
erlaubt` macht den Regex glücklich. Andere Routes
|
||||||
|
(`upload.routes.ts`, `gdpr.routes.ts`, `pdfTemplate.routes.ts`,
|
||||||
|
`factoryDefaults.routes.ts`, `appSetting.routes.ts`) nutzen alle
|
||||||
|
schon „sind erlaubt".
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 70.1 (INFO): GIF/WebP-Inkonsistenz in contract.routes Multer-Filter**
|
||||||
|
- `contract.routes.ts` Vertragsdokumente: Multer-fileFilter blockte
|
||||||
|
`image/gif` und `image/webp`, obwohl `validateUploadedFile` beide
|
||||||
|
Typen zulässt. Folge: GIF mit korrektem MIME `image/gif` → 415, GIF
|
||||||
|
mit gespooftem MIME `image/jpeg` → akzeptiert (vom Magic-Byte als
|
||||||
|
`.gif` erkannt). Kein Sicherheitsproblem (Magic-Byte ist der echte
|
||||||
|
Guard), aber inkonsistent.
|
||||||
|
- Fix: Whitelist um `image/gif` + `image/webp` ergänzt → konsistent
|
||||||
|
zum zentralen `validateUploadedFile` und zu `upload.routes.ts`.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 69.3 (INFO → Defense-in-Depth): Magic-Byte-Check auf Vertragsdokumente erweitert**
|
||||||
|
- `contract.routes.ts` Vertragsdokumente-Upload hatte bisher nur den
|
||||||
|
PDF-Inhalts-Scan (`scanUploadedPdfIfPresent` aus 68.1). JPG/PNG-
|
||||||
|
Uploads waren ungeprüft – kompensiert durch Download-Layer
|
||||||
|
(`fileDownload.controller.ts` liefert nur bei Magic-Byte-Match
|
||||||
|
inline aus, sonst attachment). Pentester selbst: "ohne Exploit-
|
||||||
|
Pfad", aber inkonsistent zu `upload.routes.ts`.
|
||||||
|
- **Refactor:** `detectType` + `validateUploadedFile` aus
|
||||||
|
`upload.routes.ts` in neue Middleware
|
||||||
|
`middleware/uploadFileTypeValidator.ts` ausgelagert (Single Source
|
||||||
|
of Truth). Beide Routes nutzen jetzt denselben Helper.
|
||||||
|
- **contract.routes.ts:** `validateUploadedFile` ersetzt das
|
||||||
|
schlankere `scanUploadedPdfIfPresent` – jetzt greift Magic-Byte +
|
||||||
|
canonical Rename + PDF-Scan für Vertragsdokumente analog zu allen
|
||||||
|
anderen Upload-Pfaden.
|
||||||
|
- **pdfUploadSafety.ts:** `scanUploadedPdfIfPresent` entfernt (tot,
|
||||||
|
da nur in contract.routes verwendet wurde). `requireSafeUploadedPdf`
|
||||||
|
bleibt für gdpr.routes Vollmacht + pdfTemplate.routes.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Inhalts-Validierung + Modal-Limit**
|
||||||
|
- **68.1 PDF-Active-Content-Filter:** Magic-Byte-Check prüfte bisher
|
||||||
|
nur `%PDF-`. PDFs mit `/JavaScript`, `/JS`, `/Launch` (externes
|
||||||
|
Programm), `/EmbeddedFile`, `/RichMedia` (Flash) wurden inline an
|
||||||
|
den Viewer ausgeliefert – Browser-PDF-Viewer (Chrome/Firefox)
|
||||||
|
ignorieren JS, Adobe Acrobat aber nicht.
|
||||||
|
- Neuer Helper `assertSafePdf(buf)` in `utils/sanitize.ts`:
|
||||||
|
String-Scan auf die fünf Action-Pattern (case-sensitive nach
|
||||||
|
PDF 32000-1:2008 §7.3.5). Wirft `ApiError(415, ...)` bei Treffer.
|
||||||
|
- Neue Middleware `pdfUploadSafety.ts` mit zwei Varianten:
|
||||||
|
- `requireSafeUploadedPdf` – Datei MUSS PDF sein, sonst 415.
|
||||||
|
- `scanUploadedPdfIfPresent` – durchwinkt JPG/PNG, scannt nur PDFs.
|
||||||
|
- Eingehängt:
|
||||||
|
- `upload.routes.ts` (Magic-Byte-Validator erweitert)
|
||||||
|
- `gdpr.routes.ts` Vollmacht-Upload
|
||||||
|
- `pdfTemplate.routes.ts` Template-Upload
|
||||||
|
- `contract.routes.ts` Vertragsdokumente
|
||||||
|
- `cachedEmail.controller.ts` Email-Anhang-Pfade (3 Stellen:
|
||||||
|
saveAttachmentTo, saveAttachmentAsInvoice,
|
||||||
|
saveAttachmentAsContractDocument)
|
||||||
|
- **Inline-Vorschau bleibt erhalten** – das war die explizite
|
||||||
|
Anforderung (Augen-Button öffnet PDF im neuen Tab). Pentester-
|
||||||
|
Empfehlung „disposition=inline abschalten" wurde bewusst NICHT
|
||||||
|
umgesetzt, weil sie das eigentliche Acrobat-Risiko nicht löst
|
||||||
|
(PDF auf Disk + Doppelklick → Acrobat → JS läuft trotzdem).
|
||||||
|
- Edge-Case-Test bestätigt: `/JSXForm` und `/JavaScriptFooter` werden
|
||||||
|
NICHT als JavaScript-Action erkannt (word-boundary `\b` greift).
|
||||||
|
- **68.2 Modal-Limit:** `JpgToPdfModal` hatte kein Bild-/Größen-Limit.
|
||||||
|
Jetzt `MAX_IMAGES = 50` + `MAX_IMAGE_BYTES = 25 MB` pro Bild.
|
||||||
|
UX-Schutz, kein Security-Bug (Self-DoS only).
|
||||||
|
|
||||||
|
- [x] **🆕 JPGs → PDF: Button überall bei PDF-Upload**
|
||||||
|
- Neue Komponente `JpgToPdfModal` (lokal im Browser via `jspdf`,
|
||||||
|
keine Backend-Round-Trip nötig). Mehrere Bilder hinzufügen per
|
||||||
|
Klick, Drag&Drop oder `Strg+V` (Clipboard-Image), Reihenfolge
|
||||||
|
per Drag&Drop sortierbar, pro Bild 90°/180°-Drehung +
|
||||||
|
Horizontal/Vertikal-Spiegelung. Quality 100%, 1 Bild = 1 Seite,
|
||||||
|
A4 mit automatischer Hoch-/Querformat-Wahl je Bild.
|
||||||
|
- `FileUpload`-Komponente (11 Stellen: Datenschutz-PDF,
|
||||||
|
Vollmacht, Bankkarten-Dokumente, Ausweise, Gewerbeanmeldung,
|
||||||
|
Handelsregister, Kündigungsschreiben + -Bestätigung +
|
||||||
|
deren Optionen) bekommt automatisch einen sekundären
|
||||||
|
"JPGs → PDF"-Button, wenn `accept` PDF einschließt.
|
||||||
|
- Direkt-Inputs ebenfalls erweitert: Vertragsdokumente
|
||||||
|
(ContractDetail), Vollmacht-Dokumente (CustomerDetail Tab),
|
||||||
|
Rechnungen (InvoicesSection).
|
||||||
|
- PdfTemplates **bewusst ausgenommen** – braucht echte
|
||||||
|
AcroForm-PDFs mit Formularfeldern, Bild-PDFs wären unbrauchbar.
|
||||||
|
|
||||||
- [x] **🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"**
|
- [x] **🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"**
|
||||||
- `customerEmailLabel` existierte im Backend (Schema +
|
- `customerEmailLabel` existierte im Backend (Schema +
|
||||||
Update-Logik + Public-Endpoint), war im UI aber nicht
|
Update-Logik + Public-Endpoint), war im UI aber nicht
|
||||||
|
|||||||
Generated
+190
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-frontend",
|
"name": "opencrm-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "opencrm-frontend",
|
"name": "opencrm-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -264,6 +265,14 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -1632,11 +1641,22 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pako": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/raf": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
@@ -1767,6 +1787,15 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.16",
|
"version": "2.9.16",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz",
|
||||||
@@ -1874,6 +1903,25 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/canvg": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/raf": "^3.4.0",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"raf": "^3.4.1",
|
||||||
|
"regenerator-runtime": "^0.13.7",
|
||||||
|
"rgbcolor": "^1.0.1",
|
||||||
|
"stackblur-canvas": "^2.0.0",
|
||||||
|
"svg-pathdata": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -1936,11 +1984,31 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crelt": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -2169,6 +2237,16 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-png": {
|
||||||
|
"version": "6.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||||
|
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/pako": "^2.0.3",
|
||||||
|
"iobuffer": "^5.3.2",
|
||||||
|
"pako": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -2178,6 +2256,11 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -2370,6 +2453,24 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iobuffer": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -2465,6 +2566,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jspdf": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"fast-png": "^6.2.0",
|
||||||
|
"fflate": "^0.8.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"canvg": "^3.0.11",
|
||||||
|
"core-js": "^3.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
|
"html2canvas": "^1.0.0-rc.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -2667,12 +2784,23 @@
|
|||||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/performance-now": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3075,6 +3203,15 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/raf": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"performance-now": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -3189,6 +3326,12 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -3219,6 +3362,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rgbcolor": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.55.2",
|
"version": "4.55.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
||||||
@@ -3317,6 +3469,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackblur-canvas": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||||
@@ -3351,6 +3512,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-pathdata": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -3388,6 +3558,15 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
@@ -3534,6 +3713,15 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye } from 'lucide-react';
|
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye, Images } from 'lucide-react';
|
||||||
|
import JpgToPdfModal from '../ui/JpgToPdfModal';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
@@ -218,6 +219,7 @@ function InvoiceModal({
|
|||||||
});
|
});
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
|
||||||
|
|
||||||
const addInvoiceFn = async (data: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
const addInvoiceFn = async (data: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
||||||
if (ecdId) {
|
if (ecdId) {
|
||||||
@@ -386,15 +388,31 @@ function InvoiceModal({
|
|||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<Button
|
<div className="flex gap-2 flex-wrap">
|
||||||
type="button"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
variant="secondary"
|
||||||
>
|
onClick={() => fileInputRef.current?.click()}
|
||||||
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
|
>
|
||||||
</Button>
|
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsJpgModalOpen(true)}
|
||||||
|
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||||
|
>
|
||||||
|
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<JpgToPdfModal
|
||||||
|
isOpen={isJpgModalOpen}
|
||||||
|
onClose={() => setIsJpgModalOpen(false)}
|
||||||
|
onComplete={(file) => setSelectedFile(file)}
|
||||||
|
fileNameHint="rechnung"
|
||||||
|
/>
|
||||||
|
|
||||||
{formData.invoiceType === 'NOT_AVAILABLE' && (
|
{formData.invoiceType === 'NOT_AVAILABLE' && (
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { FileText, Loader2, ExternalLink } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { contractApi, EmailAttachment } from '../../services/api';
|
||||||
|
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
|
||||||
|
import { viewUrl } from '../../utils/fileUrl';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contractId: number;
|
||||||
|
currentAttachments: EmailAttachment[];
|
||||||
|
onAttach: (added: EmailAttachment[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // identisch zur Compose-Modal
|
||||||
|
|
||||||
|
export default function AttachContractDocumentsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
contractId,
|
||||||
|
currentAttachments,
|
||||||
|
onAttach,
|
||||||
|
}: Props) {
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['contract-documents', contractId],
|
||||||
|
queryFn: () => contractApi.getDocuments(contractId),
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const documents = data?.data || [];
|
||||||
|
|
||||||
|
const toggle = (id: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (busy) return; // Kein Abbruch während Download läuft
|
||||||
|
setSelected(new Set());
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttach = async () => {
|
||||||
|
if (selected.size === 0) {
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const docsToFetch = documents.filter((d) => selected.has(d.id));
|
||||||
|
const newAttachments: EmailAttachment[] = [];
|
||||||
|
let runningSize = totalAttachmentBytes(currentAttachments);
|
||||||
|
try {
|
||||||
|
for (const doc of docsToFetch) {
|
||||||
|
try {
|
||||||
|
const att = await serverFileToAttachment(doc.documentPath, doc.originalName);
|
||||||
|
const approxBytes = Math.ceil(att.content.length * 0.75);
|
||||||
|
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
|
||||||
|
toast.error(
|
||||||
|
`Maximale Gesamtgröße erreicht (25 MB). "${doc.originalName}" und folgende übersprungen.`,
|
||||||
|
{ duration: 6000 },
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
newAttachments.push(att);
|
||||||
|
runningSize += approxBytes;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || `Fehler beim Anhängen von "${doc.originalName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newAttachments.length > 0) {
|
||||||
|
onAttach(newAttachments);
|
||||||
|
toast.success(
|
||||||
|
newAttachments.length === 1
|
||||||
|
? '1 Dokument angehängt'
|
||||||
|
: `${newAttachments.length} Dokumente angehängt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSelected(new Set());
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nach documentType gruppieren für übersichtliche Darstellung
|
||||||
|
const grouped = documents.reduce<Record<string, typeof documents>>((acc, doc) => {
|
||||||
|
const key = doc.documentType || 'Sonstiges';
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(doc);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Vertragsdokumente anhängen"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-gray-500">
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Dokumente werden geladen…
|
||||||
|
</div>
|
||||||
|
) : documents.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<FileText className="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">Keine Dokumente am Vertrag hinterlegt</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||||
|
{Object.entries(grouped).map(([type, docs]) => (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
{type}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<label className="flex items-start gap-2 flex-1 min-w-0 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(doc.id)}
|
||||||
|
onChange={() => toggle(doc.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="mt-0.5 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="truncate">{doc.originalName}</span>
|
||||||
|
</div>
|
||||||
|
{doc.notes && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5 ml-6 truncate">
|
||||||
|
{doc.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href={viewUrl(doc.documentPath)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
|
||||||
|
title="Dokument in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
<span>Vorschau</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{selected.size > 0 ? `${selected.size} ausgewählt` : 'Keine Auswahl'}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAttach}
|
||||||
|
disabled={busy || selected.size === 0}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Anhängen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Anhängen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Send, Paperclip, X, FileText } from 'lucide-react';
|
import { Send, Paperclip, X, FileText, FilePlus, UserPlus } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import AttachContractDocumentsModal from './AttachContractDocumentsModal';
|
||||||
|
import InsertCustomerDataModal from './InsertCustomerDataModal';
|
||||||
|
|
||||||
interface ComposeEmailModalProps {
|
interface ComposeEmailModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -31,6 +33,8 @@ export default function ComposeEmailModal({
|
|||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showAttachDocsModal, setShowAttachDocsModal] = useState(false);
|
||||||
|
const [showInsertDataModal, setShowInsertDataModal] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Formular bei Modal-Öffnung initialisieren
|
// Formular bei Modal-Öffnung initialisieren
|
||||||
@@ -308,15 +312,39 @@ export default function ComposeEmailModal({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Anhang hinzufügen Button */}
|
{/* Anhang-/Daten-Buttons */}
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
type="button"
|
||||||
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
<Paperclip className="w-4 h-4 mr-2" />
|
>
|
||||||
Datei anhängen
|
<Paperclip className="w-4 h-4 mr-2" />
|
||||||
</button>
|
Datei anhängen
|
||||||
|
</button>
|
||||||
|
{contractId && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAttachDocsModal(true)}
|
||||||
|
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
title="Bereits am Vertrag hinterlegte Dokumente direkt anhängen"
|
||||||
|
>
|
||||||
|
<FilePlus className="w-4 h-4 mr-2" />
|
||||||
|
Vertragsdokumente
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowInsertDataModal(true)}
|
||||||
|
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
title="Kunden- und Vertragsdaten in die Nachricht einfügen, optional Ausweis/Bankkarte als PDF anhängen"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Kundendaten einfügen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Anhang-Liste */}
|
{/* Anhang-Liste */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
@@ -374,6 +402,35 @@ export default function ComposeEmailModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-Modal: Vertragsdokumente anhängen */}
|
||||||
|
{contractId && (
|
||||||
|
<AttachContractDocumentsModal
|
||||||
|
isOpen={showAttachDocsModal}
|
||||||
|
onClose={() => setShowAttachDocsModal(false)}
|
||||||
|
contractId={contractId}
|
||||||
|
currentAttachments={attachments}
|
||||||
|
onAttach={(added) => setAttachments((prev) => [...prev, ...added])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sub-Modal: Kundendaten einfügen */}
|
||||||
|
{contractId && (
|
||||||
|
<InsertCustomerDataModal
|
||||||
|
isOpen={showInsertDataModal}
|
||||||
|
onClose={() => setShowInsertDataModal(false)}
|
||||||
|
contractId={contractId}
|
||||||
|
senderEmail={account.email}
|
||||||
|
currentBody={body}
|
||||||
|
currentAttachments={attachments}
|
||||||
|
onResult={(newBody, addedAtt) => {
|
||||||
|
setBody(newBody);
|
||||||
|
if (addedAtt.length > 0) {
|
||||||
|
setAttachments((prev) => [...prev, ...addedAtt]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/
|
|||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
|
import CopyButton from '../ui/CopyButton';
|
||||||
import EmailDetail from './EmailDetail';
|
import EmailDetail from './EmailDetail';
|
||||||
import ComposeEmailModal from './ComposeEmailModal';
|
import ComposeEmailModal from './ComposeEmailModal';
|
||||||
import TrashEmailList from './TrashEmailList';
|
import TrashEmailList from './TrashEmailList';
|
||||||
@@ -49,28 +50,44 @@ export default function ContractEmailsSection({
|
|||||||
|
|
||||||
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
|
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
|
||||||
|
|
||||||
// E-Mails für den Vertrag laden (nach Ordner gefiltert, nicht für TRASH)
|
// E-Mails für den Vertrag laden (nach Ordner UND Postfach gefiltert).
|
||||||
|
// Bug 2026-06-21: vorher gingen Mails aus allen Postfächern in den
|
||||||
|
// gewählten Vertrags-Ordner – obwohl der User ein bestimmtes Postfach
|
||||||
|
// ausgewählt hatte. selectedAccountId muss in queryKey + queryFn.
|
||||||
const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
|
const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
|
||||||
queryKey: ['emails', 'contract', contractId, selectedFolder],
|
queryKey: ['emails', 'contract', contractId, selectedAccountId, selectedFolder],
|
||||||
queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder as 'INBOX' | 'SENT' }),
|
queryFn: () => cachedEmailApi.getForContract(contractId, {
|
||||||
enabled: selectedFolder !== 'TRASH',
|
folder: selectedFolder as 'INBOX' | 'SENT',
|
||||||
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder !== 'TRASH' && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emails = emailsData?.data || [];
|
const emails = emailsData?.data || [];
|
||||||
|
|
||||||
// Papierkorb-E-Mails laden (für den ganzen Kunden, da Trash nicht vertragsgebunden)
|
// Papierkorb-E-Mails laden – jetzt strikt: nur das aktuell ausgewählte
|
||||||
|
// Postfach UND nur dem Vertrag zugeordnete Mails. Wenn man also den
|
||||||
|
// Vertrags-Papierkorb öffnet, sieht man nicht mehr alle gelöschten
|
||||||
|
// E-Mails des Kunden, sondern wirklich nur die, die diesem Vertrag
|
||||||
|
// aus diesem Postfach zugeordnet waren.
|
||||||
const { data: trashData, isLoading: trashLoading } = useQuery({
|
const { data: trashData, isLoading: trashLoading } = useQuery({
|
||||||
queryKey: ['emails', 'trash', customerId],
|
queryKey: ['emails', 'trash', customerId, selectedAccountId, contractId],
|
||||||
queryFn: () => cachedEmailApi.getTrash(customerId),
|
queryFn: () => cachedEmailApi.getTrash(customerId, {
|
||||||
enabled: selectedFolder === 'TRASH' && canAccessTrash,
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
contractId,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trashEmails = trashData?.data || [];
|
const trashEmails = trashData?.data || [];
|
||||||
|
|
||||||
// Ordner-Anzahlen für Badges (Vertrag)
|
// Ordner-Anzahlen für Badges (Vertrag + Postfach). Badge und Liste
|
||||||
|
// müssen mit derselben Filter-Kombination laufen, sonst zeigt der
|
||||||
|
// Badge eine andere Zahl als die sichtbare Liste.
|
||||||
const { data: folderCountsData } = useQuery({
|
const { data: folderCountsData } = useQuery({
|
||||||
queryKey: ['contract-folder-counts', contractId],
|
queryKey: ['contract-folder-counts', contractId, selectedAccountId],
|
||||||
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId),
|
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId, selectedAccountId ?? undefined),
|
||||||
|
enabled: !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const folderCounts = folderCountsData?.data || {
|
const folderCounts = folderCountsData?.data || {
|
||||||
@@ -78,16 +95,6 @@ export default function ContractEmailsSection({
|
|||||||
inboxUnread: 0,
|
inboxUnread: 0,
|
||||||
sent: 0,
|
sent: 0,
|
||||||
sentUnread: 0,
|
sentUnread: 0,
|
||||||
};
|
|
||||||
|
|
||||||
// Ordner-Anzahlen für das Konto (für Trash-Badge)
|
|
||||||
const { data: accountFolderCountsData } = useQuery({
|
|
||||||
queryKey: ['folder-counts', selectedAccountId],
|
|
||||||
queryFn: () => stressfreiEmailApi.getFolderCounts(selectedAccountId!),
|
|
||||||
enabled: !!selectedAccountId && canAccessTrash,
|
|
||||||
});
|
|
||||||
|
|
||||||
const accountFolderCounts = accountFolderCountsData?.data || {
|
|
||||||
trash: 0,
|
trash: 0,
|
||||||
trashUnread: 0,
|
trashUnread: 0,
|
||||||
};
|
};
|
||||||
@@ -358,11 +365,23 @@ export default function ContractEmailsSection({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
<Inbox className="w-4 h-4 text-gray-500" />
|
<Inbox className="w-4 h-4 text-gray-500" />
|
||||||
<span>{selectedAccount?.email}</span>
|
<span>{selectedAccount?.email}</span>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -429,18 +448,18 @@ export default function ContractEmailsSection({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Papierkorb
|
Papierkorb
|
||||||
{accountFolderCounts.trash > 0 && (
|
{folderCounts.trash > 0 && (
|
||||||
<span
|
<span
|
||||||
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
|
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
|
||||||
accountFolderCounts.trashUnread > 0
|
folderCounts.trashUnread > 0
|
||||||
? 'bg-red-100 text-red-600 font-medium'
|
? 'bg-red-100 text-red-600 font-medium'
|
||||||
: 'bg-gray-100 text-gray-500'
|
: 'bg-gray-100 text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
title={`${accountFolderCounts.trashUnread} ungelesen / ${accountFolderCounts.trash} gesamt`}
|
title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`}
|
||||||
>
|
>
|
||||||
{accountFolderCounts.trashUnread > 0
|
{folderCounts.trashUnread > 0
|
||||||
? `${accountFolderCounts.trashUnread}/${accountFolderCounts.trash}`
|
? `${folderCounts.trashUnread}/${folderCounts.trash}`
|
||||||
: accountFolderCounts.trash}
|
: folderCounts.trash}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -470,9 +489,13 @@ export default function ContractEmailsSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
|
<div
|
||||||
{/* Email List */}
|
className="flex -mx-6 -mb-6"
|
||||||
<div className="w-1/3 border-r border-gray-200 overflow-auto">
|
style={{ height: '600px' }}
|
||||||
|
>
|
||||||
|
{/* Email List – scrollt intern, damit die Vertrags-Seite nicht
|
||||||
|
elendig lang wird. */}
|
||||||
|
<div className="w-1/3 border-r border-gray-200 overflow-y-auto">
|
||||||
{selectedFolder === 'TRASH' ? (
|
{selectedFolder === 'TRASH' ? (
|
||||||
<TrashEmailList
|
<TrashEmailList
|
||||||
emails={trashEmails}
|
emails={trashEmails}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } fr
|
|||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import CopyButton from '../ui/CopyButton';
|
||||||
import EmailList from './EmailList';
|
import EmailList from './EmailList';
|
||||||
import EmailDetail from './EmailDetail';
|
import EmailDetail from './EmailDetail';
|
||||||
import ComposeEmailModal from './ComposeEmailModal';
|
import ComposeEmailModal from './ComposeEmailModal';
|
||||||
@@ -124,11 +125,17 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
|
|
||||||
const emails = emailsData?.data || [];
|
const emails = emailsData?.data || [];
|
||||||
|
|
||||||
// Papierkorb-E-Mails laden
|
// Papierkorb-E-Mails laden – jetzt strikt pro Postfach.
|
||||||
|
// Bug 2026-06-21: vorher kamen alle gelöschten E-Mails des Kunden
|
||||||
|
// raus, egal welches Postfach selektiert war. selectedAccountId muss
|
||||||
|
// in queryKey + queryFn, sonst greift React-Query-Cache bei Wechsel
|
||||||
|
// nicht und der Folder-Count aus folderCountsData liefe auseinander.
|
||||||
const { data: trashData, isLoading: trashLoading } = useQuery({
|
const { data: trashData, isLoading: trashLoading } = useQuery({
|
||||||
queryKey: ['emails', 'trash', customerId],
|
queryKey: ['emails', 'trash', customerId, selectedAccountId],
|
||||||
queryFn: () => cachedEmailApi.getTrash(customerId),
|
queryFn: () => cachedEmailApi.getTrash(customerId, {
|
||||||
enabled: selectedFolder === 'TRASH' && canAccessTrash,
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trashEmails = trashData?.data || [];
|
const trashEmails = trashData?.data || [];
|
||||||
@@ -288,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ minHeight: '600px' }}>
|
// Bounded auf Viewport-Höhe – sonst ignoriert h-full ohnehin den
|
||||||
|
// Tab-Container und der Postfach-Inhalt wächst beliebig, sodass die
|
||||||
|
// ganze Seite scrollt statt nur die E-Mail-Liste.
|
||||||
|
<div
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
||||||
{/* Account Selector */}
|
{/* Account Selector */}
|
||||||
@@ -309,11 +322,25 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
size="md"
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||||
<Inbox className="w-5 h-5 text-gray-500" />
|
<Inbox className="w-5 h-5 text-gray-500" />
|
||||||
<span>{selectedAccount?.email}</span>
|
<span>{selectedAccount?.email}</span>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
size="md"
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
||||||
@@ -52,6 +52,24 @@ export default function EmailDetail({
|
|||||||
setLocalStarred(email.isStarred);
|
setLocalStarred(email.isStarred);
|
||||||
}, [email.id, email.isStarred]);
|
}, [email.id, email.isStarred]);
|
||||||
|
|
||||||
|
// Email-Body sanitizen + alle <a>-Links auf neuen Tab umstellen.
|
||||||
|
// rel="noopener noreferrer" verhindert window.opener-Tab-Hijacking.
|
||||||
|
const safeHtmlBody = useMemo(() => {
|
||||||
|
if (!email.htmlBody) return '';
|
||||||
|
const sanitized = DOMPurify.sanitize(email.htmlBody, {
|
||||||
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
||||||
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
});
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = sanitized;
|
||||||
|
wrapper.querySelectorAll('a').forEach((a) => {
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
return wrapper.innerHTML;
|
||||||
|
}, [email.htmlBody]);
|
||||||
|
|
||||||
const toggleStarMutation = useMutation({
|
const toggleStarMutation = useMutation({
|
||||||
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
@@ -312,6 +330,16 @@ export default function EmailDetail({
|
|||||||
{email.contract.contractNumber}
|
{email.contract.contractNumber}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/contracts/${email.contract.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 text-green-600 hover:text-green-800"
|
||||||
|
title="Vertrag in neuem Tab öffnen"
|
||||||
|
aria-label="Vertrag in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
|
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
|
||||||
{!email.isAutoAssigned && (
|
{!email.isAutoAssigned && (
|
||||||
<button
|
<button
|
||||||
@@ -411,16 +439,7 @@ export default function EmailDetail({
|
|||||||
{showHtml && email.htmlBody ? (
|
{showHtml && email.htmlBody ? (
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none"
|
className="prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{ __html: safeHtmlBody }}
|
||||||
__html: DOMPurify.sanitize(email.htmlBody, {
|
|
||||||
// Scripte, Inline-Handler, Form-Elemente, externe Referenzen verbieten.
|
|
||||||
// Bilder + Links mit target=_blank bleiben zugelassen.
|
|
||||||
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
|
||||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
|
||||||
// Links in neuen Tabs öffnen (verhindert window.opener-Angriffe)
|
|
||||||
ADD_ATTR: ['target'],
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { contractApi, EmailAttachment } from '../../services/api';
|
||||||
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
|
import {
|
||||||
|
bankCardAttachmentName,
|
||||||
|
identityDocAttachmentName,
|
||||||
|
serverFileToAttachment,
|
||||||
|
totalAttachmentBytes,
|
||||||
|
} from './composeAttachmentHelpers';
|
||||||
|
import type { Contract, Address, BankCard, IdentityDocument } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contractId: number;
|
||||||
|
/**
|
||||||
|
* E-Mail-Adresse des Postfachs, von dem die Mail abgeschickt wird.
|
||||||
|
* Wird in der "Anrede & Name"-Section als Alternative zur Stammdaten-
|
||||||
|
* E-Mail angeboten – User-Wunsch 2026-06-21: bei Kundendaten wählen,
|
||||||
|
* ob die Customer-Email oder die Stressfrei-Wechseln-Absender-Adresse
|
||||||
|
* eingefügt wird.
|
||||||
|
*/
|
||||||
|
senderEmail: string;
|
||||||
|
currentBody: string;
|
||||||
|
currentAttachments: EmailAttachment[];
|
||||||
|
onResult: (newBody: string, addedAttachments: EmailAttachment[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailChoice = 'master' | 'sender' | 'none';
|
||||||
|
|
||||||
|
const MAX_TOTAL_SIZE = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
type SectionKey =
|
||||||
|
| 'customer'
|
||||||
|
| 'deliveryAddress'
|
||||||
|
| 'billingAddress'
|
||||||
|
| 'contract';
|
||||||
|
|
||||||
|
export default function InsertCustomerDataModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
contractId,
|
||||||
|
senderEmail,
|
||||||
|
currentBody,
|
||||||
|
currentAttachments,
|
||||||
|
onResult,
|
||||||
|
}: Props) {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['contract', contractId, 'for-insert-data'],
|
||||||
|
queryFn: () => contractApi.getById(contractId),
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contract = data?.data;
|
||||||
|
const customer = contract?.customer;
|
||||||
|
const deliveryAddress = contract?.address;
|
||||||
|
// Rechnungsadresse nur eigenständig zeigen, wenn sie sich tatsächlich
|
||||||
|
// von der Lieferadresse unterscheidet – sonst doppelt im Text.
|
||||||
|
const billingAddress = useMemo(() => {
|
||||||
|
if (!contract?.billingAddress) return undefined;
|
||||||
|
if (!deliveryAddress) return contract.billingAddress;
|
||||||
|
return contract.billingAddress.id !== deliveryAddress.id ? contract.billingAddress : undefined;
|
||||||
|
}, [contract?.billingAddress, deliveryAddress]);
|
||||||
|
|
||||||
|
const bankCard = contract?.bankCard;
|
||||||
|
const identityDocument = contract?.identityDocument;
|
||||||
|
|
||||||
|
// Sections die default-an sind: Anrede + Vertragsdaten. Anhang-/Text-
|
||||||
|
// Schalter für Bank + Ausweis bleiben default-aus (User-Intent: bewusst
|
||||||
|
// entscheiden, was vertraulich verschickt wird).
|
||||||
|
const [checked, setChecked] = useState<Record<SectionKey, boolean>>({
|
||||||
|
customer: true,
|
||||||
|
deliveryAddress: true,
|
||||||
|
billingAddress: false,
|
||||||
|
contract: true,
|
||||||
|
});
|
||||||
|
// Bank: zwei unabhängige Schalter. Text fügt nur die letzten 4 IBAN-
|
||||||
|
// Stellen ein (kein vollständiger IBAN-Versand per Mail = Default-Hygiene).
|
||||||
|
const [insertBankText, setInsertBankText] = useState(false);
|
||||||
|
const [attachBankPdf, setAttachBankPdf] = useState(false);
|
||||||
|
// Ausweis: Text-Schalter fügt nur die Ausweisnummer ein, kein Geburtsdatum
|
||||||
|
// / keine Ausstellungsdaten – falls der Empfänger nur die Nummer braucht.
|
||||||
|
const [insertIdentityText, setInsertIdentityText] = useState(false);
|
||||||
|
const [attachIdentityPdf, setAttachIdentityPdf] = useState(false);
|
||||||
|
// Welche E-Mail-Adresse in der Customer-Section steht:
|
||||||
|
// - 'master' = Stammdaten-E-Mail (customer.email)
|
||||||
|
// - 'sender' = Postfach-Adresse, von der die Mail abgeht (Stressfrei)
|
||||||
|
// - 'none' = E-Mail-Zeile weglassen
|
||||||
|
const [emailChoice, setEmailChoice] = useState<EmailChoice>('master');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Bei jedem Öffnen sinnvoll vorbelegen (sonst bleiben "checked" stale
|
||||||
|
// wenn das Modal mal mit anderen Daten wieder aufgeht).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && contract) {
|
||||||
|
setChecked({
|
||||||
|
customer: !!customer,
|
||||||
|
deliveryAddress: !!deliveryAddress,
|
||||||
|
billingAddress: false, // nur wenn vorhanden, aber default aus
|
||||||
|
contract: true,
|
||||||
|
});
|
||||||
|
setInsertBankText(false);
|
||||||
|
setAttachBankPdf(false);
|
||||||
|
setInsertIdentityText(false);
|
||||||
|
setAttachIdentityPdf(false);
|
||||||
|
// Default: Stammdaten-E-Mail wenn vorhanden, sonst Absender-Adresse.
|
||||||
|
setEmailChoice(customer?.email ? 'master' : 'sender');
|
||||||
|
}
|
||||||
|
}, [isOpen, contract, customer, deliveryAddress]);
|
||||||
|
|
||||||
|
const toggle = (key: SectionKey) => {
|
||||||
|
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (busy) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!contract) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
if (checked.customer && customer) {
|
||||||
|
const chosenEmail =
|
||||||
|
emailChoice === 'master'
|
||||||
|
? customer.email || ''
|
||||||
|
: emailChoice === 'sender'
|
||||||
|
? senderEmail
|
||||||
|
: '';
|
||||||
|
blocks.push(formatCustomerBlock(customer, contract, chosenEmail));
|
||||||
|
}
|
||||||
|
if (checked.deliveryAddress && deliveryAddress) {
|
||||||
|
blocks.push(formatAddressBlock('Lieferadresse', deliveryAddress));
|
||||||
|
}
|
||||||
|
if (checked.billingAddress && billingAddress) {
|
||||||
|
blocks.push(formatAddressBlock('Rechnungsadresse', billingAddress));
|
||||||
|
}
|
||||||
|
if (checked.contract) {
|
||||||
|
blocks.push(formatContractBlock(contract));
|
||||||
|
}
|
||||||
|
if (insertBankText && bankCard) {
|
||||||
|
blocks.push(formatBankBlock(bankCard));
|
||||||
|
}
|
||||||
|
if (insertIdentityText && identityDocument) {
|
||||||
|
blocks.push(formatIdentityBlock(identityDocument));
|
||||||
|
}
|
||||||
|
|
||||||
|
const textToInsert = blocks
|
||||||
|
.filter((b) => b.trim().length > 0)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
// Anhänge sammeln
|
||||||
|
const newAttachments: EmailAttachment[] = [];
|
||||||
|
let runningSize = totalAttachmentBytes(currentAttachments);
|
||||||
|
|
||||||
|
const tryAttach = async (
|
||||||
|
documentPath: string | undefined,
|
||||||
|
filename: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!documentPath) return false;
|
||||||
|
try {
|
||||||
|
const att = await serverFileToAttachment(documentPath, filename);
|
||||||
|
const approxBytes = Math.ceil(att.content.length * 0.75);
|
||||||
|
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
|
||||||
|
toast.error(`"${filename}" gesprengt das 25-MB-Anhang-Limit.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
newAttachments.push(att);
|
||||||
|
runningSize += approxBytes;
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || `Fehler beim Anhängen von "${filename}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attachBankPdf && bankCard?.documentPath) {
|
||||||
|
await tryAttach(
|
||||||
|
bankCard.documentPath,
|
||||||
|
bankCardAttachmentName(bankCard.iban),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (attachIdentityPdf && identityDocument?.documentPath) {
|
||||||
|
await tryAttach(
|
||||||
|
identityDocument.documentPath,
|
||||||
|
identityDocAttachmentName(
|
||||||
|
identityDocument.type,
|
||||||
|
identityDocument.documentNumber,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = currentBody && !currentBody.endsWith('\n') ? '\n\n' : '';
|
||||||
|
const newBody = textToInsert
|
||||||
|
? currentBody + separator + textToInsert
|
||||||
|
: currentBody;
|
||||||
|
|
||||||
|
onResult(newBody, newAttachments);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nothingSelected =
|
||||||
|
!checked.customer &&
|
||||||
|
!checked.deliveryAddress &&
|
||||||
|
!checked.billingAddress &&
|
||||||
|
!checked.contract &&
|
||||||
|
!insertBankText &&
|
||||||
|
!attachBankPdf &&
|
||||||
|
!insertIdentityText &&
|
||||||
|
!attachIdentityPdf;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Kundendaten einfügen"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading || !contract ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-gray-500">
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Daten werden geladen…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{customer && (
|
||||||
|
<SectionRow
|
||||||
|
title="Anrede & Name"
|
||||||
|
checked={checked.customer}
|
||||||
|
onToggle={() => toggle('customer')}
|
||||||
|
preview={previewCustomer(customer, contract)}
|
||||||
|
extra={
|
||||||
|
checked.customer && (
|
||||||
|
<div className="mt-2 ml-6 space-y-1">
|
||||||
|
<div className="text-xs font-medium text-gray-600">
|
||||||
|
E-Mail im Text:
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'master'}
|
||||||
|
onChange={() => setEmailChoice('master')}
|
||||||
|
disabled={!customer.email}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Stammdaten-E-Mail
|
||||||
|
{customer.email ? (
|
||||||
|
<span className="text-gray-400"> ({customer.email})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400"> (nicht hinterlegt)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'sender'}
|
||||||
|
onChange={() => setEmailChoice('sender')}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Absender-Adresse
|
||||||
|
<span className="text-gray-400"> ({senderEmail})</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'none'}
|
||||||
|
onChange={() => setEmailChoice('none')}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>Keine E-Mail einfügen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deliveryAddress && (
|
||||||
|
<SectionRow
|
||||||
|
title="Lieferadresse"
|
||||||
|
checked={checked.deliveryAddress}
|
||||||
|
onToggle={() => toggle('deliveryAddress')}
|
||||||
|
preview={previewAddress(deliveryAddress)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{billingAddress && (
|
||||||
|
<SectionRow
|
||||||
|
title="Rechnungsadresse"
|
||||||
|
checked={checked.billingAddress}
|
||||||
|
onToggle={() => toggle('billingAddress')}
|
||||||
|
preview={previewAddress(billingAddress)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SectionRow
|
||||||
|
title="Vertragsdaten"
|
||||||
|
checked={checked.contract}
|
||||||
|
onToggle={() => toggle('contract')}
|
||||||
|
preview={previewContract(contract)}
|
||||||
|
/>
|
||||||
|
{bankCard && (
|
||||||
|
<DualChoiceRow
|
||||||
|
title="Bankverbindung"
|
||||||
|
preview={previewBank(bankCard)}
|
||||||
|
textChecked={insertBankText}
|
||||||
|
onToggleText={() => setInsertBankText((v) => !v)}
|
||||||
|
textLabel="Letzte 4 IBAN-Stellen einfügen"
|
||||||
|
textDisabled={!lastFourIban(bankCard.iban)}
|
||||||
|
pdfChecked={attachBankPdf}
|
||||||
|
onTogglePdf={() => setAttachBankPdf((v) => !v)}
|
||||||
|
pdfLabel="Bankkarte als PDF anhängen"
|
||||||
|
pdfDisabled={!bankCard.documentPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{identityDocument && (
|
||||||
|
<DualChoiceRow
|
||||||
|
title={identityTypeLabel(identityDocument.type)}
|
||||||
|
preview={previewIdentity(identityDocument)}
|
||||||
|
textChecked={insertIdentityText}
|
||||||
|
onToggleText={() => setInsertIdentityText((v) => !v)}
|
||||||
|
textLabel={`${identityTypeLabel(identityDocument.type)}-Nummer einfügen`}
|
||||||
|
textDisabled={!identityDocument.documentNumber}
|
||||||
|
pdfChecked={attachIdentityPdf}
|
||||||
|
onTogglePdf={() => setAttachIdentityPdf((v) => !v)}
|
||||||
|
pdfLabel={`${identityTypeLabel(identityDocument.type)} als PDF anhängen`}
|
||||||
|
pdfDisabled={!identityDocument.documentPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Falls weder Customer noch Address etc. da sind */}
|
||||||
|
{!customer && !deliveryAddress && !bankCard && !identityDocument && (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
Keine weiteren Daten am Kunden hinterlegt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Text wird ans Ende der Nachricht angehängt.
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={busy || isLoading || nothingSelected}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Einfügen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Einfügen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UI-Helper ====================
|
||||||
|
|
||||||
|
interface SectionRowProps {
|
||||||
|
title: string;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
preview: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DualChoiceRowProps {
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
textChecked: boolean;
|
||||||
|
onToggleText: () => void;
|
||||||
|
textLabel: string;
|
||||||
|
textDisabled?: boolean;
|
||||||
|
pdfChecked: boolean;
|
||||||
|
onTogglePdf: () => void;
|
||||||
|
pdfLabel: string;
|
||||||
|
pdfDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections, die unabhängig Text und PDF anbieten (Bank, Ausweis).
|
||||||
|
* Keine primäre Checkbox – beide Schalter wirken einzeln, deshalb
|
||||||
|
* kein "alle-ein/alle-aus" auf Section-Ebene nötig.
|
||||||
|
*/
|
||||||
|
function DualChoiceRow({
|
||||||
|
title,
|
||||||
|
preview,
|
||||||
|
textChecked,
|
||||||
|
onToggleText,
|
||||||
|
textLabel,
|
||||||
|
textDisabled,
|
||||||
|
pdfChecked,
|
||||||
|
onTogglePdf,
|
||||||
|
pdfLabel,
|
||||||
|
pdfDisabled,
|
||||||
|
}: DualChoiceRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{preview}</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<label className={`flex items-center gap-2 text-xs cursor-pointer ${textDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={textChecked}
|
||||||
|
onChange={onToggleText}
|
||||||
|
disabled={textDisabled}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>{textLabel}</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex items-center gap-2 text-xs cursor-pointer ${pdfDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pdfChecked}
|
||||||
|
onChange={onTogglePdf}
|
||||||
|
disabled={pdfDisabled}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{pdfLabel}
|
||||||
|
{pdfDisabled && <span className="ml-1">(keine PDF hinterlegt)</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionRow({ title, checked, onToggle, preview, extra }: SectionRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="mt-1 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||||
|
<pre className="text-xs text-gray-500 mt-1 whitespace-pre-wrap font-sans">
|
||||||
|
{preview}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Text-Block-Formatierung ====================
|
||||||
|
|
||||||
|
function fullName(
|
||||||
|
customer: { salutation?: string; firstName: string; lastName: string; companyName?: string },
|
||||||
|
contractType: string,
|
||||||
|
): string {
|
||||||
|
if (contractType === 'BUSINESS' && customer.companyName) {
|
||||||
|
return customer.companyName;
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (customer.salutation) parts.push(customer.salutation);
|
||||||
|
parts.push(customer.firstName);
|
||||||
|
parts.push(customer.lastName);
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: das Modal ist für Mails AN den Anbieter gedacht.
|
||||||
|
// Die interne CRM-Kundennummer / -Vertragsnummer interessiert dort
|
||||||
|
// niemanden – relevant ist nur, was der Anbieter selbst vergeben hat
|
||||||
|
// (`customerNumberAtProvider`, `contractNumberAtProvider`). Wir blenden
|
||||||
|
// die internen Nummern komplett aus.
|
||||||
|
function formatCustomerBlock(
|
||||||
|
customer: NonNullable<Contract['customer']>,
|
||||||
|
contract: Contract,
|
||||||
|
email: string,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = ['Kundendaten:'];
|
||||||
|
lines.push(fullName(customer, contract.type));
|
||||||
|
if (contract.customerNumberAtProvider) {
|
||||||
|
lines.push(`Kundennummer beim Anbieter: ${contract.customerNumberAtProvider}`);
|
||||||
|
}
|
||||||
|
if (customer.birthDate) lines.push(`Geburtsdatum: ${formatDate(customer.birthDate)}`);
|
||||||
|
if (email) lines.push(`E-Mail: ${email}`);
|
||||||
|
if (customer.phone) lines.push(`Telefon: ${customer.phone}`);
|
||||||
|
if (customer.mobile) lines.push(`Mobil: ${customer.mobile}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewCustomer(customer: NonNullable<Contract['customer']>, contract: Contract): string {
|
||||||
|
return [
|
||||||
|
fullName(customer, contract.type),
|
||||||
|
contract.customerNumberAtProvider
|
||||||
|
? `Anbieter-Kdnr.: ${contract.customerNumberAtProvider}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddressBlock(label: string, addr: Address): string {
|
||||||
|
const lines: string[] = [`${label}:`];
|
||||||
|
lines.push(`${addr.street} ${addr.houseNumber}`);
|
||||||
|
lines.push(`${addr.postalCode} ${addr.city}`);
|
||||||
|
if (addr.country && addr.country.toLowerCase() !== 'deutschland' && addr.country !== 'DE') {
|
||||||
|
lines.push(addr.country);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAddress(addr: Address): string {
|
||||||
|
return `${addr.street} ${addr.houseNumber}, ${addr.postalCode} ${addr.city}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interne `contractNumber` raus (User-Wunsch 2026-06-21): für eine Mail
|
||||||
|
// an den Provider zählt nur die Vertragsnummer, die der Provider selbst
|
||||||
|
// vergeben hat. Vertriebsplattform-Nummern bleiben drin – die nutzt der
|
||||||
|
// CRM-Mitarbeiter teilweise auch für die Plattform-Korrespondenz.
|
||||||
|
function formatContractBlock(c: Contract): string {
|
||||||
|
const lines: string[] = ['Vertragsdaten:'];
|
||||||
|
if (c.provider?.name) lines.push(`Anbieter: ${c.provider.name}`);
|
||||||
|
if (c.tariff?.name) lines.push(`Tarif: ${c.tariff.name}`);
|
||||||
|
if (c.customerNumberAtProvider) lines.push(`Kundennummer beim Anbieter: ${c.customerNumberAtProvider}`);
|
||||||
|
if (c.contractNumberAtProvider) lines.push(`Vertragsnummer beim Anbieter: ${c.contractNumberAtProvider}`);
|
||||||
|
if (c.orderNumberAtSalesPlatform) lines.push(`Auftragsnummer Vertriebsplattform: ${c.orderNumberAtSalesPlatform}`);
|
||||||
|
if (c.customerNumberAtSalesPlatform) lines.push(`Kundennummer Vertriebsplattform: ${c.customerNumberAtSalesPlatform}`);
|
||||||
|
if (c.contractNumberAtSalesPlatform) lines.push(`Vertragsnummer Vertriebsplattform: ${c.contractNumberAtSalesPlatform}`);
|
||||||
|
if (c.startDate) lines.push(`Vertragsbeginn: ${formatDate(c.startDate)}`);
|
||||||
|
if (c.endDate) lines.push(`Vertragsende: ${formatDate(c.endDate)}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewContract(c: Contract): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (c.contractNumberAtProvider) {
|
||||||
|
parts.push(`Anbieter-Vtr.: ${c.contractNumberAtProvider}`);
|
||||||
|
} else if (c.provider?.name) {
|
||||||
|
parts.push('(keine Anbieter-Vertragsnummer hinterlegt)');
|
||||||
|
}
|
||||||
|
if (c.provider?.name) parts.push(c.provider.name);
|
||||||
|
if (c.tariff?.name) parts.push(c.tariff.name);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: nur die letzten 4 IBAN-Stellen einfügen, nicht
|
||||||
|
// die komplette IBAN/BIC/Bank-Liste. Vollständige Kontonummern per Mail
|
||||||
|
// versenden ist sowieso heikel – der Empfänger kann sich mit den letzten
|
||||||
|
// 4 Stellen für Identifikationszwecke ausweisen, ohne dass die ganze
|
||||||
|
// IBAN im Mail-Verlauf hängenbleibt.
|
||||||
|
function lastFourIban(iban: string | undefined | null): string {
|
||||||
|
if (!iban) return '';
|
||||||
|
return iban.replace(/\s+/g, '').slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBankBlock(b: BankCard): string {
|
||||||
|
const last4 = lastFourIban(b.iban);
|
||||||
|
if (!last4) return '';
|
||||||
|
return `Bankverbindung:\nIBAN endet auf: ${last4}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewBank(b: BankCard): string {
|
||||||
|
const last4 = lastFourIban(b.iban);
|
||||||
|
return last4 ? `IBAN …${last4}` : 'IBAN nicht hinterlegt';
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityTypeLabel(type: IdentityDocument['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'PASSPORT': return 'Reisepass';
|
||||||
|
case 'DRIVERS_LICENSE': return 'Führerschein';
|
||||||
|
case 'OTHER': return 'Ausweisdokument';
|
||||||
|
case 'ID_CARD':
|
||||||
|
default: return 'Personalausweis';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: nur die Ausweisnummer einfügen, keine
|
||||||
|
// Behörde / Daten – wenn der Empfänger mehr Details braucht, soll er
|
||||||
|
// die beigefügte PDF benutzen.
|
||||||
|
function formatIdentityBlock(d: IdentityDocument): string {
|
||||||
|
if (!d.documentNumber) return '';
|
||||||
|
return `${identityTypeLabel(d.type)}-Nummer: ${d.documentNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewIdentity(d: IdentityDocument): string {
|
||||||
|
return d.documentNumber ? `Nr. ${d.documentNumber}` : 'Keine Nummer hinterlegt';
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Hilfs-Funktionen für ComposeEmailModal und die zwei neuen Modals
|
||||||
|
// (Vertragsdokumente anhängen, Kundendaten einfügen).
|
||||||
|
|
||||||
|
import { fileUrl } from '../../utils/fileUrl';
|
||||||
|
import type { EmailAttachment } from '../../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt eine Server-Datei (per fileUrl mit Token) und gibt sie als
|
||||||
|
* EmailAttachment zurück. Wird sowohl für ContractDocuments als auch
|
||||||
|
* für BankCard- und IdentityDocument-PDFs benutzt.
|
||||||
|
*
|
||||||
|
* Wirft mit aussagekräftiger Message, wenn der Download fehlschlägt –
|
||||||
|
* der Caller fängt das ab und zeigt einen Toast.
|
||||||
|
*/
|
||||||
|
export async function serverFileToAttachment(
|
||||||
|
documentPath: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<EmailAttachment> {
|
||||||
|
const url = fileUrl(documentPath);
|
||||||
|
if (!url) throw new Error(`Datei "${filename}" hat keinen Pfad.`);
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Download von "${filename}" fehlgeschlagen (HTTP ${response.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const base64 = await blobToBase64(blob);
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
content: base64,
|
||||||
|
contentType: blob.type || 'application/octet-stream',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
// data:application/pdf;base64,XYZ → XYZ
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(',')[1] ?? '';
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error || new Error('FileReader-Fehler'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesamtgröße aller Anhänge berechnen (in Bytes, näherungsweise).
|
||||||
|
* Base64 ist ~33% größer als die Original-Bytes.
|
||||||
|
*/
|
||||||
|
export function totalAttachmentBytes(attachments: EmailAttachment[]): number {
|
||||||
|
return attachments.reduce(
|
||||||
|
(sum, att) => sum + Math.ceil(att.content.length * 0.75),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filename-Vorschlag für eine Bankkarte – mit IBAN-Suffix damit beim
|
||||||
|
* Empfänger klar ist, welches Konto gemeint ist.
|
||||||
|
*/
|
||||||
|
export function bankCardAttachmentName(iban: string | undefined): string {
|
||||||
|
if (!iban) return 'Bankkarte.pdf';
|
||||||
|
const lastFour = iban.replace(/\s+/g, '').slice(-4);
|
||||||
|
return `Bankkarte-${lastFour}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filename-Vorschlag für Ausweis-PDF abhängig vom Typ.
|
||||||
|
*/
|
||||||
|
export function identityDocAttachmentName(
|
||||||
|
type: string,
|
||||||
|
documentNumber: string | undefined,
|
||||||
|
): string {
|
||||||
|
const base = type === 'PASSPORT'
|
||||||
|
? 'Reisepass'
|
||||||
|
: type === 'DRIVERS_LICENSE'
|
||||||
|
? 'Fuehrerschein'
|
||||||
|
: type === 'OTHER'
|
||||||
|
? 'Ausweisdokument'
|
||||||
|
: 'Personalausweis';
|
||||||
|
return documentNumber ? `${base}-${documentNumber}.pdf` : `${base}.pdf`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Upload } from 'lucide-react';
|
import { Upload, Images } from 'lucide-react';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
|
import JpgToPdfModal from './JpgToPdfModal';
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
onUpload: (file: File) => Promise<void>;
|
onUpload: (file: File) => Promise<void>;
|
||||||
@@ -8,6 +9,10 @@ interface FileUploadProps {
|
|||||||
accept?: string;
|
accept?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Standard: aktiv, sobald `accept` PDF einschließt. Explizit auf false setzen, um den Button auszublenden. */
|
||||||
|
enableJpgToPdf?: boolean;
|
||||||
|
/** Default-Name (ohne .pdf) für die aus JPGs erzeugte PDF. */
|
||||||
|
jpgToPdfFileNameHint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileUpload({
|
export default function FileUpload({
|
||||||
@@ -16,10 +21,16 @@ export default function FileUpload({
|
|||||||
accept = '.pdf,.jpg,.jpeg,.png',
|
accept = '.pdf,.jpg,.jpeg,.png',
|
||||||
label = 'Dokument hochladen',
|
label = 'Dokument hochladen',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
enableJpgToPdf,
|
||||||
|
jpgToPdfFileNameHint,
|
||||||
}: FileUploadProps) {
|
}: FileUploadProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const acceptsPdf = /pdf/i.test(accept);
|
||||||
|
const showJpgButton = (enableJpgToPdf ?? acceptsPdf) && !disabled;
|
||||||
|
|
||||||
const handleFileSelect = async (file: File) => {
|
const handleFileSelect = async (file: File) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -64,40 +75,67 @@ export default function FileUpload({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{existingFile ? (
|
{existingFile ? (
|
||||||
!disabled && (
|
!disabled && (
|
||||||
<Button
|
<div className="flex flex-wrap gap-2">
|
||||||
variant="secondary"
|
<Button
|
||||||
size="sm"
|
variant="secondary"
|
||||||
onClick={() => inputRef.current?.click()}
|
size="sm"
|
||||||
disabled={isUploading}
|
onClick={() => inputRef.current?.click()}
|
||||||
>
|
disabled={isUploading}
|
||||||
{isUploading ? 'Wird hochgeladen...' : 'Ersetzen'}
|
>
|
||||||
</Button>
|
{isUploading ? 'Wird hochgeladen...' : 'Ersetzen'}
|
||||||
|
</Button>
|
||||||
|
{showJpgButton && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsJpgModalOpen(true)}
|
||||||
|
disabled={isUploading}
|
||||||
|
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||||
|
>
|
||||||
|
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
<div
|
||||||
dragOver
|
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||||
? 'border-blue-500 bg-blue-50'
|
dragOver
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
? 'border-blue-500 bg-blue-50'
|
||||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
onClick={() => !disabled && inputRef.current?.click()}
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
onDrop={!disabled ? handleDrop : undefined}
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
onDragOver={!disabled ? handleDragOver : undefined}
|
onDrop={!disabled ? handleDrop : undefined}
|
||||||
onDragLeave={!disabled ? handleDragLeave : undefined}
|
onDragOver={!disabled ? handleDragOver : undefined}
|
||||||
>
|
onDragLeave={!disabled ? handleDragLeave : undefined}
|
||||||
{isUploading ? (
|
>
|
||||||
<div className="text-gray-500">
|
{isUploading ? (
|
||||||
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
|
<div className="text-gray-500">
|
||||||
Wird hochgeladen...
|
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
|
||||||
|
Wird hochgeladen...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">{label}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 25 MB)</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showJpgButton && !isUploading && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsJpgModalOpen(true)}
|
||||||
|
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||||
|
>
|
||||||
|
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-gray-600">{label}</p>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 10MB)</p>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -108,6 +146,15 @@ export default function FileUpload({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={disabled || isUploading}
|
disabled={disabled || isUploading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<JpgToPdfModal
|
||||||
|
isOpen={isJpgModalOpen}
|
||||||
|
onClose={() => setIsJpgModalOpen(false)}
|
||||||
|
onComplete={(file) => {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}}
|
||||||
|
fileNameHint={jpgToPdfFileNameHint}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { jsPDF } from 'jspdf';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
FlipHorizontal,
|
||||||
|
FlipVertical,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Repeat,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
interface ImageItem {
|
||||||
|
id: string;
|
||||||
|
dataUrl: string;
|
||||||
|
naturalWidth: number;
|
||||||
|
naturalHeight: number;
|
||||||
|
rotation: 0 | 90 | 180 | 270;
|
||||||
|
flipH: boolean;
|
||||||
|
flipV: boolean;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JpgToPdfModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onComplete: (pdfFile: File) => void;
|
||||||
|
fileNameHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 68.2 (INFO): Self-DoS-Schutz – Modal kann sonst den Tab des
|
||||||
|
// Uploaders selbst zum Absturz bringen. Werte konservativ gewählt:
|
||||||
|
// 50 Bilder × 25 MB = 1.25 GB ist mehr als jede legitime Vollmacht.
|
||||||
|
const MAX_IMAGES = 50;
|
||||||
|
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Smartphone-Fotos haben oft 4000-6000 px Kante. Bei JPEG-Quality
|
||||||
|
// 0.95 sind das 5-10 MB pro Seite, zwei Bilder = >10 MB PDF.
|
||||||
|
// 2400 px lange Kante entspricht ~290 DPI auf A4 (Druckqualität) und
|
||||||
|
// reduziert die Pixelmenge auf 25-36 % vom Original → PDF wird
|
||||||
|
// drastisch kleiner, sichtbarer Unterschied praktisch null.
|
||||||
|
const MAX_DIMENSION = 2400;
|
||||||
|
const EMBED_QUALITY = 0.92;
|
||||||
|
|
||||||
|
function makeId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('Bild-Decode fehlgeschlagen'));
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downscaleIfNeeded(image: HTMLImageElement): HTMLCanvasElement | null {
|
||||||
|
const w = image.naturalWidth;
|
||||||
|
const h = image.naturalHeight;
|
||||||
|
if (w <= MAX_DIMENSION && h <= MAX_DIMENSION) return null;
|
||||||
|
const scale = MAX_DIMENSION / Math.max(w, h);
|
||||||
|
const newW = Math.round(w * scale);
|
||||||
|
const newH = Math.round(h * scale);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = newW;
|
||||||
|
canvas.height = newH;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(image, 0, 0, newW, newH);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageToCanvas(image: HTMLImageElement, item: ImageItem): HTMLCanvasElement {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Canvas-Kontext konnte nicht erstellt werden');
|
||||||
|
|
||||||
|
const w = image.naturalWidth;
|
||||||
|
const h = image.naturalHeight;
|
||||||
|
const rotated = item.rotation === 90 || item.rotation === 270;
|
||||||
|
|
||||||
|
canvas.width = rotated ? h : w;
|
||||||
|
canvas.height = rotated ? w : h;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||||
|
ctx.rotate((item.rotation * Math.PI) / 180);
|
||||||
|
ctx.scale(item.flipH ? -1 : 1, item.flipV ? -1 : 1);
|
||||||
|
ctx.drawImage(image, -w / 2, -h / 2);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JpgToPdfModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onComplete,
|
||||||
|
fileNameHint,
|
||||||
|
}: JpgToPdfModalProps) {
|
||||||
|
const [images, setImages] = useState<ImageItem[]>([]);
|
||||||
|
const [dragSrcIdx, setDragSrcIdx] = useState<number | null>(null);
|
||||||
|
const [isOverModal, setIsOverModal] = useState(false);
|
||||||
|
const [isBuilding, setIsBuilding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setImages([]);
|
||||||
|
setDragSrcIdx(null);
|
||||||
|
setIsOverModal(false);
|
||||||
|
setIsBuilding(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files).filter((f) => f.type.startsWith('image/'));
|
||||||
|
if (list.length === 0) {
|
||||||
|
setError('Nur Bilddateien erlaubt (JPG/PNG).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
const added: ImageItem[] = [];
|
||||||
|
for (const file of list) {
|
||||||
|
// 68.2: Self-DoS-Schutz – harte Schranken pro Bild und gesamt.
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
setError(`Bild zu groß (max. ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)} MB): ${file.name || 'unbenannt'}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (images.length + added.length >= MAX_IMAGES) {
|
||||||
|
setError(`Maximal ${MAX_IMAGES} Bilder pro PDF erlaubt.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rawDataUrl = await readFileAsDataUrl(file);
|
||||||
|
const img = await loadImage(rawDataUrl);
|
||||||
|
// Beim Hinzufügen direkt auf MAX_DIMENSION runterskalieren, damit
|
||||||
|
// die Vorschau, das Rendern in der PDF und die finale Dateigröße
|
||||||
|
// alle auf vernünftigen Pixelmaßen arbeiten.
|
||||||
|
const downscaled = downscaleIfNeeded(img);
|
||||||
|
const dataUrl = downscaled
|
||||||
|
? downscaled.toDataURL('image/jpeg', EMBED_QUALITY)
|
||||||
|
: rawDataUrl;
|
||||||
|
const finalW = downscaled ? downscaled.width : img.naturalWidth;
|
||||||
|
const finalH = downscaled ? downscaled.height : img.naturalHeight;
|
||||||
|
added.push({
|
||||||
|
id: makeId(),
|
||||||
|
dataUrl,
|
||||||
|
naturalWidth: finalW,
|
||||||
|
naturalHeight: finalH,
|
||||||
|
rotation: 0,
|
||||||
|
flipH: false,
|
||||||
|
flipV: false,
|
||||||
|
fileName: file.name || 'clipboard.png',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setError(`Bild konnte nicht geladen werden: ${file.name || 'unbenannt'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added.length > 0) {
|
||||||
|
setImages((prev) => [...prev, ...added]);
|
||||||
|
}
|
||||||
|
}, [images.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const files: File[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i];
|
||||||
|
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f) files.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
addFiles(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('paste', handler);
|
||||||
|
return () => document.removeEventListener('paste', handler);
|
||||||
|
}, [isOpen, addFiles]);
|
||||||
|
|
||||||
|
const rotate = (id: string, delta: 90 | -90 | 180) => {
|
||||||
|
setImages((prev) =>
|
||||||
|
prev.map((it) =>
|
||||||
|
it.id === id
|
||||||
|
? {
|
||||||
|
...it,
|
||||||
|
rotation: ((((it.rotation + delta) % 360) + 360) % 360) as ImageItem['rotation'],
|
||||||
|
}
|
||||||
|
: it
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flip = (id: string, axis: 'h' | 'v') => {
|
||||||
|
setImages((prev) =>
|
||||||
|
prev.map((it) =>
|
||||||
|
it.id === id
|
||||||
|
? { ...it, ...(axis === 'h' ? { flipH: !it.flipH } : { flipV: !it.flipV }) }
|
||||||
|
: it
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: string) => {
|
||||||
|
setImages((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (idx: number) => (e: React.DragEvent) => {
|
||||||
|
setDragSrcIdx(idx);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', String(idx));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemDragOver = (e: React.DragEvent) => {
|
||||||
|
if (dragSrcIdx === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDropOnItem = (idx: number) => (e: React.DragEvent) => {
|
||||||
|
if (dragSrcIdx === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (dragSrcIdx === idx) {
|
||||||
|
setDragSrcIdx(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImages((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(dragSrcIdx, 1);
|
||||||
|
next.splice(idx, 0, moved);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDragSrcIdx(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalDragOver = (e: React.DragEvent) => {
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOverModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleModalDragLeave = (e: React.DragEvent) => {
|
||||||
|
if (e.target === e.currentTarget) setIsOverModal(false);
|
||||||
|
};
|
||||||
|
const handleModalDrop = (e: React.DragEvent) => {
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOverModal(false);
|
||||||
|
addFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPdf = async () => {
|
||||||
|
if (images.length === 0) return;
|
||||||
|
setIsBuilding(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const a4w = 210;
|
||||||
|
const a4h = 297;
|
||||||
|
let pdf: jsPDF | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const item = images[i];
|
||||||
|
const untouched = item.rotation === 0 && !item.flipH && !item.flipV;
|
||||||
|
|
||||||
|
// Fix 1: ungedrehte/ungespiegelte Bilder bekommen ihre Original-Bytes
|
||||||
|
// direkt in die PDF eingebettet – kein Canvas-Re-Encode, kein
|
||||||
|
// Quality-Aufblasen. Ein 2-MB-JPEG bleibt 2 MB statt 8-15 MB zu werden.
|
||||||
|
// Fix 2: wenn doch transformiert wird (Rotation/Flip), Canvas mit
|
||||||
|
// Quality 0.95 statt 1.0 – visuell identisch für Foto-Inhalte, aber
|
||||||
|
// 50-70 % kleiner.
|
||||||
|
let imageData: string;
|
||||||
|
let imageFormat: 'JPEG' | 'PNG';
|
||||||
|
let srcW: number;
|
||||||
|
let srcH: number;
|
||||||
|
|
||||||
|
if (untouched) {
|
||||||
|
imageData = item.dataUrl;
|
||||||
|
imageFormat = item.dataUrl.startsWith('data:image/png') ? 'PNG' : 'JPEG';
|
||||||
|
srcW = item.naturalWidth;
|
||||||
|
srcH = item.naturalHeight;
|
||||||
|
} else {
|
||||||
|
const img = await loadImage(item.dataUrl);
|
||||||
|
const canvas = renderImageToCanvas(img, item);
|
||||||
|
imageData = canvas.toDataURL('image/jpeg', EMBED_QUALITY);
|
||||||
|
imageFormat = 'JPEG';
|
||||||
|
srcW = canvas.width;
|
||||||
|
srcH = canvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orientation: 'portrait' | 'landscape' =
|
||||||
|
srcW > srcH ? 'landscape' : 'portrait';
|
||||||
|
const pageW = orientation === 'landscape' ? a4h : a4w;
|
||||||
|
const pageH = orientation === 'landscape' ? a4w : a4h;
|
||||||
|
|
||||||
|
if (!pdf) {
|
||||||
|
pdf = new jsPDF({ orientation, unit: 'mm', format: 'a4' });
|
||||||
|
} else {
|
||||||
|
pdf.addPage('a4', orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = 5;
|
||||||
|
const maxW = pageW - 2 * margin;
|
||||||
|
const maxH = pageH - 2 * margin;
|
||||||
|
const ratio = Math.min(maxW / srcW, maxH / srcH);
|
||||||
|
const w = srcW * ratio;
|
||||||
|
const h = srcH * ratio;
|
||||||
|
const x = (pageW - w) / 2;
|
||||||
|
const y = (pageH - h) / 2;
|
||||||
|
|
||||||
|
pdf.addImage(imageData, imageFormat, x, y, w, h, undefined, 'SLOW');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = pdf!.output('blob');
|
||||||
|
const base = (fileNameHint || 'bilder').replace(/[^\w.-]+/g, '_').slice(0, 80) || 'bilder';
|
||||||
|
const file = new File([blob], `${base}.pdf`, { type: 'application/pdf' });
|
||||||
|
onComplete(file);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('PDF-Erstellung fehlgeschlagen:', e);
|
||||||
|
setError('PDF konnte nicht erstellt werden.');
|
||||||
|
} finally {
|
||||||
|
setIsBuilding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="JPGs zu PDF" size="xl">
|
||||||
|
<div
|
||||||
|
onDragOver={handleModalDragOver}
|
||||||
|
onDragLeave={handleModalDragLeave}
|
||||||
|
onDrop={handleModalDrop}
|
||||||
|
className={`space-y-4 ${isOverModal ? 'ring-2 ring-blue-400 rounded-lg' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Bilder per Klick wählen, hier reinziehen, oder mit{' '}
|
||||||
|
<kbd className="px-1.5 py-0.5 border rounded text-xs">Strg</kbd>+
|
||||||
|
<kbd className="px-1.5 py-0.5 border rounded text-xs">V</kbd> aus der Zwischenablage
|
||||||
|
einfügen.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isBuilding}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-1" /> Bilder hinzufügen
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed rounded-lg p-12 text-center cursor-pointer border-gray-300 hover:border-gray-400 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Bilder hier hineinziehen oder zum Auswählen klicken
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
JPG / PNG · Reihenfolge per Drag & Drop, einzeln drehen/spiegeln
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{images.length} {images.length === 1 ? 'Bild' : 'Bilder'} · Reihenfolge per
|
||||||
|
Drag&Drop, einzeln drehen/spiegeln. Jedes Bild = eine Seite.
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 max-h-[60vh] overflow-y-auto p-1">
|
||||||
|
{images.map((item, idx) => {
|
||||||
|
const transforms = [
|
||||||
|
`rotate(${item.rotation}deg)`,
|
||||||
|
`scaleX(${item.flipH ? -1 : 1})`,
|
||||||
|
`scaleY(${item.flipV ? -1 : 1})`,
|
||||||
|
].join(' ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={handleDragStart(idx)}
|
||||||
|
onDragOver={handleItemDragOver}
|
||||||
|
onDrop={handleDropOnItem(idx)}
|
||||||
|
className={`border rounded-lg p-2 bg-white shadow-sm cursor-move ${
|
||||||
|
dragSrcIdx === idx ? 'opacity-40' : ''
|
||||||
|
}`}
|
||||||
|
title="Zum Sortieren ziehen"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5 text-xs">
|
||||||
|
<span className="font-semibold text-gray-700">#{idx + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(item.id)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="aspect-square bg-gray-100 rounded flex items-center justify-center overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={item.dataUrl}
|
||||||
|
alt={item.fileName}
|
||||||
|
style={{ transform: transforms }}
|
||||||
|
className="max-w-full max-h-full object-contain transition-transform"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex justify-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rotate(item.id, -90)}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
title="90° gegen Uhrzeigersinn"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rotate(item.id, 90)}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
title="90° im Uhrzeigersinn"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rotate(item.id, 180)}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
title="180° drehen"
|
||||||
|
>
|
||||||
|
<Repeat className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => flip(item.id, 'h')}
|
||||||
|
className={`p-1 rounded ${
|
||||||
|
item.flipH ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title="Horizontal spiegeln"
|
||||||
|
>
|
||||||
|
<FlipHorizontal className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => flip(item.id, 'v')}
|
||||||
|
className={`p-1 rounded ${
|
||||||
|
item.flipV ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title="Vertikal spiegeln"
|
||||||
|
>
|
||||||
|
<FlipVertical className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={isBuilding}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={buildPdf} disabled={images.length === 0 || isBuilding}>
|
||||||
|
<FileText className="w-4 h-4 mr-1" />
|
||||||
|
{isBuilding ? 'Erstelle PDF...' : 'PDF erstellen & hochladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode, useState, useEffect } from 'react';
|
import { ReactNode, useState, useEffect } from 'react';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,9 +12,21 @@ interface TabsProps {
|
|||||||
defaultTab?: string;
|
defaultTab?: string;
|
||||||
activeTab?: string;
|
activeTab?: string;
|
||||||
onTabChange?: (tabId: string) => void;
|
onTabChange?: (tabId: string) => void;
|
||||||
|
/**
|
||||||
|
* Optional: liefert die URL, unter der ein einzelner Tab in einem
|
||||||
|
* neuen Tab geöffnet werden kann. Wenn gesetzt, erscheint neben jedem
|
||||||
|
* Tab-Label ein kleines „im neuen Tab öffnen"-Icon.
|
||||||
|
*/
|
||||||
|
tabHrefBuilder?: (tabId: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTabChange }: TabsProps) {
|
export default function Tabs({
|
||||||
|
tabs,
|
||||||
|
defaultTab,
|
||||||
|
activeTab: controlledTab,
|
||||||
|
onTabChange,
|
||||||
|
tabHrefBuilder,
|
||||||
|
}: TabsProps) {
|
||||||
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
|
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
|
||||||
const activeTab = controlledTab ?? internalTab;
|
const activeTab = controlledTab ?? internalTab;
|
||||||
|
|
||||||
@@ -31,19 +44,33 @@ export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTab
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex -mb-px space-x-8">
|
<nav className="flex -mb-px space-x-6">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<div key={tab.id} className="flex items-center gap-1">
|
||||||
key={tab.id}
|
<button
|
||||||
onClick={() => handleTabChange(tab.id)}
|
onClick={() => handleTabChange(tab.id)}
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-blue-500 text-blue-600'
|
? 'border-blue-500 text-blue-600'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
|
{tabHrefBuilder && (
|
||||||
|
<a
|
||||||
|
href={tabHrefBuilder(tab.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-0.5"
|
||||||
|
title={`${tab.label} in neuem Tab öffnen`}
|
||||||
|
aria-label={`${tab.label} in neuem Tab öffnen`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import Badge from '../../components/ui/Badge';
|
|||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Modal from '../../components/ui/Modal';
|
import Modal from '../../components/ui/Modal';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
import FileUpload from '../../components/ui/FileUpload';
|
||||||
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText } from 'lucide-react';
|
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText, Images } from 'lucide-react';
|
||||||
|
import JpgToPdfModal from '../../components/ui/JpgToPdfModal';
|
||||||
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
||||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
@@ -137,6 +138,7 @@ function SimCardDisplay({ simCard }: { simCard: SimCard }) {
|
|||||||
<div className="p-3 bg-gray-50 rounded-lg border">
|
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
{simCard.isMain && <Badge variant="success">Hauptkarte</Badge>}
|
{simCard.isMain && <Badge variant="success">Hauptkarte</Badge>}
|
||||||
|
{simCard.isEsim && <Badge variant="info">eSIM</Badge>}
|
||||||
{simCard.isMultisim && <Badge variant="warning">Multisim</Badge>}
|
{simCard.isMultisim && <Badge variant="warning">Multisim</Badge>}
|
||||||
{simCard.cardUser && (
|
{simCard.cardUser && (
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">
|
||||||
@@ -1836,6 +1838,16 @@ export default function ContractDetail() {
|
|||||||
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
|
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
|
||||||
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
||||||
</Link>
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`/customers/${c.customer.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Kundenakte in neuem Tab öffnen"
|
||||||
|
aria-label="Kundenakte in neuem Tab öffnen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCustomerInfo(true)}
|
onClick={() => setShowCustomerInfo(true)}
|
||||||
@@ -2078,6 +2090,33 @@ export default function ContractDetail() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{c.orderNumberAtSalesPlatform && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Auftragsnr. Vertriebsplattform</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{c.orderNumberAtSalesPlatform}
|
||||||
|
<CopyButton value={c.orderNumberAtSalesPlatform} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.customerNumberAtSalesPlatform && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Kundennr. Vertriebsplattform</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{c.customerNumberAtSalesPlatform}
|
||||||
|
<CopyButton value={c.customerNumberAtSalesPlatform} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.contractNumberAtSalesPlatform && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Vertragsnr. Vertriebsplattform</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{c.contractNumberAtSalesPlatform}
|
||||||
|
<CopyButton value={c.contractNumberAtSalesPlatform} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{c.salesPlatform && (
|
{c.salesPlatform && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
|
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
|
||||||
@@ -3484,6 +3523,7 @@ function ContractDocumentsSection({
|
|||||||
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
|
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
|
||||||
() => new Date().toISOString().split('T')[0],
|
() => new Date().toISOString().split('T')[0],
|
||||||
);
|
);
|
||||||
|
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
|
||||||
|
|
||||||
const { data: docsData } = useQuery({
|
const { data: docsData } = useQuery({
|
||||||
queryKey: ['contract-documents', contractId],
|
queryKey: ['contract-documents', contractId],
|
||||||
@@ -3581,15 +3621,31 @@ function ContractDocumentsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<label className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer text-sm">
|
<label className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer text-sm">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Datei wählen (PDF, JPG, PNG)
|
Datei wählen (PDF, JPG, PNG)
|
||||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={handleFileSelect} />
|
<input type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={handleFileSelect} />
|
||||||
</label>
|
</label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsJpgModalOpen(true)} title="Mehrere JPGs zu einer PDF kombinieren">
|
||||||
|
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||||
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setShowUpload(false)}>Abbrechen</Button>
|
<Button variant="secondary" size="sm" onClick={() => setShowUpload(false)}>Abbrechen</Button>
|
||||||
{uploadMutation.isPending && <span className="text-sm text-gray-500">Hochladen...</span>}
|
{uploadMutation.isPending && <span className="text-sm text-gray-500">Hochladen...</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<JpgToPdfModal
|
||||||
|
isOpen={isJpgModalOpen}
|
||||||
|
onClose={() => setIsJpgModalOpen(false)}
|
||||||
|
onComplete={(file) => {
|
||||||
|
uploadMutation.mutate({
|
||||||
|
file,
|
||||||
|
documentType: uploadType,
|
||||||
|
notes: uploadNotes || undefined,
|
||||||
|
deliveryDate: isDelivery ? uploadDeliveryDate : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
fileNameHint={uploadType}
|
||||||
|
/>
|
||||||
{uploadMutation.isError && (
|
{uploadMutation.isError && (
|
||||||
<p className="text-xs text-red-600 mt-2">Fehler beim Hochladen</p>
|
<p className="text-xs text-red-600 mt-2">Fehler beim Hochladen</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import Card from '../../components/ui/Card';
|
|||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
|
import CopyButton from '../../components/ui/CopyButton';
|
||||||
|
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
||||||
import type { ContractType } from '../../types';
|
import type { ContractType } from '../../types';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
@@ -192,6 +194,7 @@ export default function ContractForm() {
|
|||||||
hasExistingPuk?: boolean; // Zeigt an ob PUK bereits in DB vorhanden
|
hasExistingPuk?: boolean; // Zeigt an ob PUK bereits in DB vorhanden
|
||||||
isMultisim: boolean;
|
isMultisim: boolean;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
|
isEsim: boolean;
|
||||||
cardUser: string;
|
cardUser: string;
|
||||||
}
|
}
|
||||||
const [simCards, setSimCards] = useState<SimCardInput[]>([]);
|
const [simCards, setSimCards] = useState<SimCardInput[]>([]);
|
||||||
@@ -219,6 +222,7 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
// Passwort-Sichtbarkeit
|
// Passwort-Sichtbarkeit
|
||||||
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
||||||
|
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
||||||
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
||||||
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
||||||
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
||||||
@@ -301,6 +305,9 @@ export default function ContractForm() {
|
|||||||
tariffName: c.tariffName || '',
|
tariffName: c.tariffName || '',
|
||||||
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
||||||
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
||||||
|
orderNumberAtSalesPlatform: c.orderNumberAtSalesPlatform || '',
|
||||||
|
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
|
||||||
|
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
|
||||||
priceFirst12Months: c.priceFirst12Months || '',
|
priceFirst12Months: c.priceFirst12Months || '',
|
||||||
priceFrom13Months: c.priceFrom13Months || '',
|
priceFrom13Months: c.priceFrom13Months || '',
|
||||||
priceAfter24Months: c.priceAfter24Months || '',
|
priceAfter24Months: c.priceAfter24Months || '',
|
||||||
@@ -384,6 +391,7 @@ export default function ContractForm() {
|
|||||||
hasExistingPuk: !!sc.puk, // true wenn verschlüsselter Wert vorhanden
|
hasExistingPuk: !!sc.puk, // true wenn verschlüsselter Wert vorhanden
|
||||||
isMultisim: sc.isMultisim,
|
isMultisim: sc.isMultisim,
|
||||||
isMain: sc.isMain,
|
isMain: sc.isMain,
|
||||||
|
isEsim: sc.isEsim ?? false,
|
||||||
cardUser: sc.cardUser || '',
|
cardUser: sc.cardUser || '',
|
||||||
})));
|
})));
|
||||||
} else {
|
} else {
|
||||||
@@ -554,6 +562,9 @@ export default function ContractForm() {
|
|||||||
tariffName: emptyToNull(data.tariffName),
|
tariffName: emptyToNull(data.tariffName),
|
||||||
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
||||||
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
||||||
|
orderNumberAtSalesPlatform: emptyToNull(data.orderNumberAtSalesPlatform),
|
||||||
|
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
|
||||||
|
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
|
||||||
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
||||||
priceFrom13Months: emptyToNull(data.priceFrom13Months),
|
priceFrom13Months: emptyToNull(data.priceFrom13Months),
|
||||||
priceAfter24Months: emptyToNull(data.priceAfter24Months),
|
priceAfter24Months: emptyToNull(data.priceAfter24Months),
|
||||||
@@ -650,6 +661,7 @@ export default function ContractForm() {
|
|||||||
puk: sc.puk || undefined, // Passwort: undefined = nicht ändern
|
puk: sc.puk || undefined, // Passwort: undefined = nicht ändern
|
||||||
isMultisim: sc.isMultisim,
|
isMultisim: sc.isMultisim,
|
||||||
isMain: sc.isMain,
|
isMain: sc.isMain,
|
||||||
|
isEsim: sc.isEsim,
|
||||||
cardUser: emptyToNull(sc.cardUser),
|
cardUser: emptyToNull(sc.cardUser),
|
||||||
})) : undefined,
|
})) : undefined,
|
||||||
};
|
};
|
||||||
@@ -765,7 +777,7 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -773,6 +785,36 @@ export default function ContractForm() {
|
|||||||
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{customer && (
|
||||||
|
<p className="text-gray-500 ml-12 mb-6 flex items-center gap-1">
|
||||||
|
Kunde:{' '}
|
||||||
|
<Link
|
||||||
|
to={`/customers/${customer.id}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`/customers/${customer.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Kundenakte in neuem Tab öffnen"
|
||||||
|
aria-label="Kundenakte in neuem Tab öffnen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCustomerInfo(true)}
|
||||||
|
title="Wichtige Kundendaten anzeigen (Schnellansicht mit Copy-Buttons)"
|
||||||
|
aria-label="Kundendaten anzeigen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||||
@@ -947,8 +989,11 @@ export default function ContractForm() {
|
|||||||
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
|
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
disabled={!selectedProviderId}
|
disabled={!selectedProviderId}
|
||||||
/>
|
/>
|
||||||
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
|
<Input label="Kundennummer beim Anbieter" maxLength={100} {...register('customerNumberAtProvider')} />
|
||||||
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
|
<Input label="Vertragsnummer beim Anbieter" maxLength={100} {...register('contractNumberAtProvider')} />
|
||||||
|
<Input label="Auftragsnummer bei Vertriebsplattform" maxLength={100} {...register('orderNumberAtSalesPlatform')} />
|
||||||
|
<Input label="Kundennummer bei Vertriebsplattform" maxLength={100} {...register('customerNumberAtSalesPlatform')} />
|
||||||
|
<Input label="Vertragsnummer bei Vertriebsplattform" maxLength={100} {...register('contractNumberAtSalesPlatform')} />
|
||||||
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
|
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
|
||||||
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
|
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
|
||||||
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
|
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
|
||||||
@@ -1005,7 +1050,24 @@ export default function ContractForm() {
|
|||||||
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
|
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Portal Benutzername</label>
|
{(() => {
|
||||||
|
// Aktiv kopierbaren Wert je nach Modus ermitteln:
|
||||||
|
// - Manuell: aktueller Eingabewert von portalUsername
|
||||||
|
// - Stressfrei: E-Mail der ausgewählten Stressfrei-Adresse
|
||||||
|
const manualUsername = (watch('portalUsername') as string) || '';
|
||||||
|
const selectedStressfreiEmail = selectedStressfreiEmailId
|
||||||
|
? stressfreiEmails.find((e: { id: number; email: string }) => e.id.toString() === selectedStressfreiEmailId)?.email
|
||||||
|
: '';
|
||||||
|
const copyValue = usernameType === 'manual'
|
||||||
|
? manualUsername.trim()
|
||||||
|
: (selectedStressfreiEmail || '');
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 mb-2 text-sm font-medium text-gray-700">
|
||||||
|
<span>Portal Benutzername</span>
|
||||||
|
{copyValue && <CopyButton value={copyValue} title={`Benutzername "${copyValue}" in Zwischenablage kopieren`} />}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -1023,6 +1085,7 @@ export default function ContractForm() {
|
|||||||
{usernameType === 'manual' && (
|
{usernameType === 'manual' && (
|
||||||
<Input
|
<Input
|
||||||
{...register('portalUsername')}
|
{...register('portalUsername')}
|
||||||
|
maxLength={100}
|
||||||
placeholder="Benutzername eingeben..."
|
placeholder="Benutzername eingeben..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1517,6 +1580,19 @@ export default function ContractForm() {
|
|||||||
/>
|
/>
|
||||||
Hauptkarte
|
Hauptkarte
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={card.isEsim}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...simCards];
|
||||||
|
updated[index].isEsim = e.target.checked;
|
||||||
|
setSimCards(updated);
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
eSIM
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-1 text-sm">
|
<label className="flex items-center gap-1 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -1642,6 +1718,7 @@ export default function ContractForm() {
|
|||||||
puk: '',
|
puk: '',
|
||||||
isMultisim: false,
|
isMultisim: false,
|
||||||
isMain: simCards.length === 0, // Erste Karte ist Hauptkarte
|
isMain: simCards.length === 0, // Erste Karte ist Hauptkarte
|
||||||
|
isEsim: false,
|
||||||
cardUser: '',
|
cardUser: '',
|
||||||
}]);
|
}]);
|
||||||
}}
|
}}
|
||||||
@@ -1719,6 +1796,15 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
{/* Status-Info Modal */}
|
{/* Status-Info Modal */}
|
||||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||||
|
|
||||||
|
{/* Kunden-Schnellansicht */}
|
||||||
|
{customer && (
|
||||||
|
<CustomerInfoModal
|
||||||
|
customerId={customer.id}
|
||||||
|
open={showCustomerInfo}
|
||||||
|
onClose={() => setShowCustomerInfo(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import Modal from '../../components/ui/Modal';
|
|||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
import FileUpload from '../../components/ui/FileUpload';
|
||||||
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake, RefreshCw, ExternalLink } from 'lucide-react';
|
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake, RefreshCw, ExternalLink, Images } from 'lucide-react';
|
||||||
|
import JpgToPdfModal from '../../components/ui/JpgToPdfModal';
|
||||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
|
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
@@ -410,7 +411,13 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Tabs tabs={tabs} defaultTab={defaultTab} activeTab={activeTab} onTabChange={handleTabChange} />
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTab={defaultTab}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
tabHrefBuilder={(tabId) => `${location.pathname}?tab=${tabId}`}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AddressModal
|
<AddressModal
|
||||||
@@ -2450,10 +2457,13 @@ function AddressModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when address prop changes
|
// Beim Öffnen / Wechsel zwischen Adressen aus den Props re-initialisieren.
|
||||||
if (isEditing && formData.street !== address.street) {
|
// Vorher als unbedingte if-setState im Render-Body → Reset bei jedem
|
||||||
|
// Tastendruck, Straße ließ sich nicht eintippen.
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [address?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
|
||||||
@@ -2669,10 +2679,12 @@ function BankCardModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when bankCard prop changes
|
// Re-Init nur beim Wechsel zur anderen Karte – nicht bei jedem
|
||||||
if (isEditing && formData.iban !== bankCard.iban) {
|
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bankCard?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
|
||||||
@@ -2819,10 +2831,12 @@ function DocumentModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when document prop changes
|
// Re-Init nur beim Wechsel zum anderen Ausweis – nicht bei jedem
|
||||||
if (isEditing && formData.documentNumber !== document.documentNumber) {
|
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [document?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
|
||||||
@@ -3029,10 +3043,12 @@ function MeterModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when meter prop changes
|
// Re-Init nur beim Wechsel zum anderen Zähler – nicht bei jedem
|
||||||
if (isEditing && formData.meterNumber !== meter.meterNumber) {
|
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [meter?.id]);
|
||||||
|
|
||||||
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
||||||
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
|
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
|
||||||
@@ -3266,10 +3282,12 @@ function MeterReadingModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when reading prop changes
|
// Re-Init nur beim Wechsel zum anderen Zählerstand – nicht bei
|
||||||
if (isEditing && formData.value !== reading.value.toString()) {
|
// jedem Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [reading?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
|
||||||
@@ -3726,6 +3744,8 @@ function StressfreiEmailModal({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
|
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
|
||||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||||
|
const [showForwardsModal, setShowForwardsModal] = useState(false);
|
||||||
|
const [additionalForwards, setAdditionalForwards] = useState<string[]>([]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isEditing = !!email;
|
const isEditing = !!email;
|
||||||
|
|
||||||
@@ -3864,6 +3884,15 @@ function StressfreiEmailModal({
|
|||||||
setNotes(email.notes || '');
|
setNotes(email.notes || '');
|
||||||
setProviderStatus('idle');
|
setProviderStatus('idle');
|
||||||
setMailboxEnabled(email.hasMailbox || false);
|
setMailboxEnabled(email.hasMailbox || false);
|
||||||
|
// Aktuelle Zusatz-Weiterleitungen aus dem JSON-Feld parsen.
|
||||||
|
let parsed: string[] = [];
|
||||||
|
if (email.additionalForwardingEmails) {
|
||||||
|
try {
|
||||||
|
const x = JSON.parse(email.additionalForwardingEmails);
|
||||||
|
if (Array.isArray(x)) parsed = x.filter((s): s is string => typeof s === 'string');
|
||||||
|
} catch {/* fällt auf [] zurück */}
|
||||||
|
}
|
||||||
|
setAdditionalForwards(parsed);
|
||||||
// Status beim Provider prüfen wenn Provider vorhanden
|
// Status beim Provider prüfen wenn Provider vorhanden
|
||||||
if (hasProvider) {
|
if (hasProvider) {
|
||||||
checkProviderStatus(emailLocalPart);
|
checkProviderStatus(emailLocalPart);
|
||||||
@@ -3877,6 +3906,7 @@ function StressfreiEmailModal({
|
|||||||
setCreateMailbox(false);
|
setCreateMailbox(false);
|
||||||
setProviderStatus('idle');
|
setProviderStatus('idle');
|
||||||
setMailboxEnabled(false);
|
setMailboxEnabled(false);
|
||||||
|
setAdditionalForwards([]);
|
||||||
}
|
}
|
||||||
setProvisionError(null);
|
setProvisionError(null);
|
||||||
// Zugangsdaten zurücksetzen
|
// Zugangsdaten zurücksetzen
|
||||||
@@ -3888,12 +3918,25 @@ function StressfreiEmailModal({
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
|
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
|
||||||
// Verwendet die neue API-Funktion, die Provisioning und Mailbox-Erstellung unterstützt
|
// Verwendet die neue API-Funktion, die Provisioning und Mailbox-Erstellung unterstützt
|
||||||
return stressfreiEmailApi.create(customerId, {
|
const result = await stressfreiEmailApi.create(customerId, {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
provisionAtProvider: data.provision,
|
provisionAtProvider: data.provision,
|
||||||
createMailbox: data.createMailbox,
|
createMailbox: data.createMailbox,
|
||||||
});
|
});
|
||||||
|
// Wenn der User zusätzliche Weiterleitungen im Sub-Modal gepflegt
|
||||||
|
// hat: nach der Erstellung gleich am Provider nachziehen. Das
|
||||||
|
// sorgt für einen `set:`-Sync mit der vollen Liste, kein
|
||||||
|
// Edit-Modus-Roundtrip nötig.
|
||||||
|
if (data.provision && additionalForwards.length > 0 && result.data?.id) {
|
||||||
|
try {
|
||||||
|
await stressfreiEmailApi.updateAdditionalForwards(result.data.id, additionalForwards);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Zusatz-Weiterleitungen konnten nicht gesetzt werden:', e);
|
||||||
|
// Adresse selbst wurde angelegt – nicht hart fehlschlagen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||||
@@ -3902,6 +3945,7 @@ function StressfreiEmailModal({
|
|||||||
setNotes('');
|
setNotes('');
|
||||||
setProvisionAtProvider(false);
|
setProvisionAtProvider(false);
|
||||||
setCreateMailbox(false);
|
setCreateMailbox(false);
|
||||||
|
setAdditionalForwards([]);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -3916,6 +3960,9 @@ function StressfreiEmailModal({
|
|||||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setProvisionError(err instanceof Error ? err.message : 'Fehler beim Speichern');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -4157,15 +4204,243 @@ function StressfreiEmailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-between items-center gap-2">
|
||||||
<Button type="button" variant="secondary" onClick={onClose}>
|
<div>
|
||||||
Abbrechen
|
{((isEditing && email && providerStatus === 'exists') ||
|
||||||
</Button>
|
(!isEditing && provisionAtProvider)) && (
|
||||||
<Button type="submit" disabled={isPending || !localPart}>
|
<Button
|
||||||
{isPending ? 'Speichern...' : 'Speichern'}
|
type="button"
|
||||||
</Button>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowForwardsModal(true)}
|
||||||
|
title="Zusätzliche Weiterleitungs-Adressen pflegen"
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 mr-1" />
|
||||||
|
Weitere Weiterleitungen
|
||||||
|
{additionalForwards.length > 0 && (
|
||||||
|
<span className="ml-1 text-xs bg-blue-100 text-blue-700 rounded-full px-1.5">
|
||||||
|
{additionalForwards.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending || !localPart}>
|
||||||
|
{isPending ? 'Speichern...' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<AdditionalForwardsModal
|
||||||
|
isOpen={showForwardsModal}
|
||||||
|
onClose={() => setShowForwardsModal(false)}
|
||||||
|
email={email ?? undefined}
|
||||||
|
customerEmail={customerEmail}
|
||||||
|
selfEmail={localPart ? localPart + domainSuffix : undefined}
|
||||||
|
value={additionalForwards}
|
||||||
|
onChange={setAdditionalForwards}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untermodal: zusätzliche Weiterleitungs-E-Mails verwalten.
|
||||||
|
// Edit-Modus: `email` prop gesetzt → jede Änderung wird sofort am
|
||||||
|
// Provider gesynct.
|
||||||
|
// Create-Modus: `email` undefined → reine lokale Verwaltung über
|
||||||
|
// `value`/`onChange`. Wird beim createEmail-Submit mitgegeben.
|
||||||
|
function AdditionalForwardsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
email,
|
||||||
|
customerEmail,
|
||||||
|
selfEmail,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
email?: StressfreiEmail;
|
||||||
|
customerEmail?: string;
|
||||||
|
/** Die Stressfrei-Adresse selbst (für Self-Forward-Check im Create-Modus,
|
||||||
|
* wo es noch kein `email`-Prop gibt). Edit-Modus zieht's aus `email`. */
|
||||||
|
selfEmail?: string;
|
||||||
|
/** Aktuelle Liste – im Create-Modus controlled, im Edit-Modus initialer Wert. */
|
||||||
|
value: string[];
|
||||||
|
onChange: (next: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newEmail, setNewEmail] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNewEmail('');
|
||||||
|
setError(null);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
||||||
|
|
||||||
|
const persist = async (next: string[]) => {
|
||||||
|
setError(null);
|
||||||
|
if (email) {
|
||||||
|
// Edit-Modus: sofort am Provider syncen.
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await stressfreiEmailApi.updateAdditionalForwards(email.id, next);
|
||||||
|
onChange(next);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stressfrei-emails', email.customerId] });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
|
||||||
|
setError(msg);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create-Modus: nur lokal updaten. Persistierung beim Submit
|
||||||
|
// des Haupt-Modals.
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plus-Tag wegstrippen + lowercase, identisch zum Backend-canonicalEmailKey.
|
||||||
|
// Dann landen `billing+x@y` und `billing@y` im selben Key.
|
||||||
|
const canonicalize = (raw: string) => {
|
||||||
|
const lower = raw.trim().toLowerCase();
|
||||||
|
const at = lower.lastIndexOf('@');
|
||||||
|
if (at < 1) return lower;
|
||||||
|
const local = lower.slice(0, at);
|
||||||
|
const plus = local.indexOf('+');
|
||||||
|
return (plus === -1 ? local : local.slice(0, plus)) + '@' + lower.slice(at + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const candidate = newEmail.trim().toLowerCase();
|
||||||
|
if (!candidate) return;
|
||||||
|
if (!EMAIL_REGEX.test(candidate)) {
|
||||||
|
setError('Bitte eine gültige E-Mail-Adresse eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidateKey = canonicalize(candidate);
|
||||||
|
if (customerEmail && candidateKey === canonicalize(customerEmail)) {
|
||||||
|
setError('Die Stamm-E-Mail des Kunden ist bereits Weiterleitungsziel.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ownAddress = email?.email ?? selfEmail;
|
||||||
|
if (ownAddress && candidateKey === canonicalize(ownAddress)) {
|
||||||
|
setError(`"${candidate}" zeigt auf die Adresse selbst – das würde einen Mail-Loop erzeugen.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.some((f) => canonicalize(f) === candidateKey)) {
|
||||||
|
setError('Diese Adresse ist schon in der Liste.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await persist([...value, candidate]);
|
||||||
|
setNewEmail('');
|
||||||
|
} catch {
|
||||||
|
/* error wird oben gesetzt */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (target: string) => {
|
||||||
|
try {
|
||||||
|
await persist(value.filter((f) => f !== target));
|
||||||
|
} catch {
|
||||||
|
/* error wird oben gesetzt */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = email
|
||||||
|
? `Weiterleitungen für ${email.email}`
|
||||||
|
: 'Weitere Weiterleitungen';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Posteingänge gehen immer an die Stamm-E-Mail des Kunden
|
||||||
|
{customerEmail && (
|
||||||
|
<> (<span className="font-mono">{customerEmail}</span>)</>
|
||||||
|
)}
|
||||||
|
. Hier kannst du zusätzliche Adressen hinterlegen, die ebenfalls eine Kopie bekommen.
|
||||||
|
{email
|
||||||
|
? ' Änderungen werden sofort am E-Mail-Provider übernommen.'
|
||||||
|
: ' Sie werden zusammen mit der Adresse angelegt, sobald du speicherst.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Aktuelle zusätzliche Ziele
|
||||||
|
</label>
|
||||||
|
{value.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 italic">Noch keine zusätzlichen Adressen.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{value.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-center justify-between bg-gray-50 border border-gray-200 rounded px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm">{f}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(f)}
|
||||||
|
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} className="border-t pt-3 space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Weitere Adresse hinzufügen
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewEmail(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="z.B. info@partner.de"
|
||||||
|
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
maxLength={254}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !newEmail.trim()}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4560,6 +4835,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [sendDropdownFor, setSendDropdownFor] = useState<number | null>(null);
|
const [sendDropdownFor, setSendDropdownFor] = useState<number | null>(null);
|
||||||
|
const [jpgModalFor, setJpgModalFor] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: authData, isLoading } = useQuery({
|
const { data: authData, isLoading } = useQuery({
|
||||||
queryKey: ['authorizations', customerId],
|
queryKey: ['authorizations', customerId],
|
||||||
@@ -4730,21 +5006,42 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<label className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1">
|
<>
|
||||||
<Plus className="w-3 h-3" />
|
<label className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1">
|
||||||
Vollmacht-PDF hochladen
|
<Plus className="w-3 h-3" />
|
||||||
<input
|
Vollmacht-PDF hochladen
|
||||||
type="file"
|
<input
|
||||||
accept=".pdf"
|
type="file"
|
||||||
className="hidden"
|
accept=".pdf"
|
||||||
onChange={(e) => handleFileUpload(auth.representativeId, e)}
|
className="hidden"
|
||||||
/>
|
onChange={(e) => handleFileUpload(auth.representativeId, e)}
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setJpgModalFor(auth.representativeId)}
|
||||||
|
className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1"
|
||||||
|
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||||
|
>
|
||||||
|
<Images className="w-3 h-3" />
|
||||||
|
JPGs → PDF
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<JpgToPdfModal
|
||||||
|
isOpen={jpgModalFor !== null}
|
||||||
|
onClose={() => setJpgModalFor(null)}
|
||||||
|
onComplete={(file) => {
|
||||||
|
if (jpgModalFor !== null) {
|
||||||
|
uploadMutation.mutate({ representativeId: jpgModalFor, file });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fileNameHint="vollmacht"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,6 +292,13 @@ function ProviderModal({
|
|||||||
portalUrl: '',
|
portalUrl: '',
|
||||||
usernameFieldName: '',
|
usernameFieldName: '',
|
||||||
passwordFieldName: '',
|
passwordFieldName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactFax: '',
|
||||||
|
contactAddress: '',
|
||||||
|
cancellationEmail: '',
|
||||||
|
cancellationFax: '',
|
||||||
|
cancellationAddress: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
|
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
|
||||||
// Admin sein eigenes Passwort mitsenden – Schutz gegen kompromittierten
|
// Admin sein eigenes Passwort mitsenden – Schutz gegen kompromittierten
|
||||||
@@ -316,6 +323,13 @@ function ProviderModal({
|
|||||||
portalUrl: provider.portalUrl || '',
|
portalUrl: provider.portalUrl || '',
|
||||||
usernameFieldName: provider.usernameFieldName || '',
|
usernameFieldName: provider.usernameFieldName || '',
|
||||||
passwordFieldName: provider.passwordFieldName || '',
|
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,
|
isActive: provider.isActive,
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
@@ -325,6 +339,13 @@ function ProviderModal({
|
|||||||
portalUrl: '',
|
portalUrl: '',
|
||||||
usernameFieldName: '',
|
usernameFieldName: '',
|
||||||
passwordFieldName: '',
|
passwordFieldName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactFax: '',
|
||||||
|
contactAddress: '',
|
||||||
|
cancellationEmail: '',
|
||||||
|
cancellationFax: '',
|
||||||
|
cancellationAddress: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
@@ -434,6 +455,75 @@ function ProviderModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 Musterstraße 1 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 Abteilung Kündigung Musterstraße 1 12345 Berlin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{provider && (
|
{provider && (
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ export interface StressfreiEmail {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isProvisioned?: boolean;
|
isProvisioned?: boolean;
|
||||||
hasMailbox: boolean;
|
hasMailbox: boolean;
|
||||||
|
/** Zusätzliche Weiterleitungs-E-Mails als JSON-Array-String. */
|
||||||
|
additionalForwardingEmails?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -537,6 +539,14 @@ export const stressfreiEmailApi = {
|
|||||||
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
|
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
// Zusätzliche Weiterleitungs-Adressen ersetzen + sofort am Provider syncen.
|
||||||
|
updateAdditionalForwards: async (id: number, emails: string[]) => {
|
||||||
|
const res = await api.put<ApiResponse<{ forwardTargets: string[] }>>(
|
||||||
|
`/stressfrei-emails/${id}/additional-forwards`,
|
||||||
|
{ emails },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
// E-Mails synchronisieren
|
// E-Mails synchronisieren
|
||||||
syncEmails: async (id: number, fullSync = false) => {
|
syncEmails: async (id: number, fullSync = false) => {
|
||||||
const res = await api.post<ApiResponse<SyncResult>>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });
|
const res = await api.post<ApiResponse<SyncResult>>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });
|
||||||
@@ -587,19 +597,24 @@ export const cachedEmailApi = {
|
|||||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// E-Mails für Vertrag abrufen
|
// E-Mails für Vertrag abrufen (optional pro Postfach gefiltert)
|
||||||
getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
|
getForContract: async (
|
||||||
|
contractId: number,
|
||||||
|
options?: { folder?: 'INBOX' | 'SENT'; accountId?: number; limit?: number; offset?: number },
|
||||||
|
) => {
|
||||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails)
|
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails, optional pro Postfach)
|
||||||
getContractFolderCounts: async (contractId: number) => {
|
getContractFolderCounts: async (contractId: number, accountId?: number) => {
|
||||||
const res = await api.get<ApiResponse<{
|
const res = await api.get<ApiResponse<{
|
||||||
inbox: number;
|
inbox: number;
|
||||||
inboxUnread: number;
|
inboxUnread: number;
|
||||||
sent: number;
|
sent: number;
|
||||||
sentUnread: number;
|
sentUnread: number;
|
||||||
}>>(`/contracts/${contractId}/emails/folder-counts`);
|
trash: number;
|
||||||
|
trashUnread: number;
|
||||||
|
}>>(`/contracts/${contractId}/emails/folder-counts`, { params: accountId ? { accountId } : undefined });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Mailbox-Konten eines Kunden abrufen
|
// Mailbox-Konten eines Kunden abrufen
|
||||||
@@ -659,14 +674,14 @@ export const cachedEmailApi = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// ==================== PAPIERKORB ====================
|
// ==================== PAPIERKORB ====================
|
||||||
// Papierkorb-E-Mails für Kunden abrufen
|
// Papierkorb-E-Mails für Kunden abrufen (optional pro Postfach/Vertrag gefiltert)
|
||||||
getTrash: async (customerId: number) => {
|
getTrash: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
|
||||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`);
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Papierkorb-Anzahl für Kunden
|
// Papierkorb-Anzahl für Kunden (gleiche Filter wie getTrash)
|
||||||
getTrashCount: async (customerId: number) => {
|
getTrashCount: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
|
||||||
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`);
|
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// E-Mail aus Papierkorb wiederherstellen
|
// E-Mail aus Papierkorb wiederherstellen
|
||||||
|
|||||||
@@ -396,6 +396,13 @@ export interface Provider {
|
|||||||
portalUrl?: string;
|
portalUrl?: string;
|
||||||
usernameFieldName?: string;
|
usernameFieldName?: string;
|
||||||
passwordFieldName?: string;
|
passwordFieldName?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
contactFax?: string;
|
||||||
|
contactAddress?: string;
|
||||||
|
cancellationEmail?: string;
|
||||||
|
cancellationFax?: string;
|
||||||
|
cancellationAddress?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
tariffs?: Tariff[];
|
tariffs?: Tariff[];
|
||||||
_count?: {
|
_count?: {
|
||||||
@@ -454,6 +461,9 @@ export interface Contract {
|
|||||||
tariffName?: string;
|
tariffName?: string;
|
||||||
customerNumberAtProvider?: string;
|
customerNumberAtProvider?: string;
|
||||||
contractNumberAtProvider?: string;
|
contractNumberAtProvider?: string;
|
||||||
|
orderNumberAtSalesPlatform?: string;
|
||||||
|
customerNumberAtSalesPlatform?: string;
|
||||||
|
contractNumberAtSalesPlatform?: string;
|
||||||
priceFirst12Months?: string;
|
priceFirst12Months?: string;
|
||||||
priceFrom13Months?: string;
|
priceFrom13Months?: string;
|
||||||
priceAfter24Months?: string;
|
priceAfter24Months?: string;
|
||||||
@@ -561,6 +571,7 @@ export interface SimCard {
|
|||||||
puk?: string; // verschlüsselt
|
puk?: string; // verschlüsselt
|
||||||
isMultisim: boolean;
|
isMultisim: boolean;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
|
isEsim: boolean;
|
||||||
// Tatsächlicher Nutzer der Karte (z.B. Mitarbeiter bei Firmenvertrag,
|
// Tatsächlicher Nutzer der Karte (z.B. Mitarbeiter bei Firmenvertrag,
|
||||||
// Kind bei Eltern-Vertrag) – optional, kann vom Vertragsinhaber abweichen.
|
// Kind bei Eltern-Vertrag) – optional, kann vom Vertragsinhaber abweichen.
|
||||||
cardUser?: string;
|
cardUser?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user