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
|
||||
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.
|
||||
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
|
||||
USER_COUNT=$(node -e "
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* mehrfach aufrufbar.
|
||||
*/
|
||||
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';
|
||||
|
||||
const CUSTOMER_STRING_FIELDS = [
|
||||
@@ -93,18 +93,10 @@ const ALLOWED_CONSENT_SOURCES = new Set([
|
||||
]);
|
||||
|
||||
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
||||
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
|
||||
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
|
||||
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
|
||||
// raus (Pentest 2026-05-20 LOW 27.1).
|
||||
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);
|
||||
}
|
||||
// Pentest 27.1 (2026-05-20). Helper jetzt zentral in utils/sanitize – wir
|
||||
// re-exportieren hier nur, damit der bestehende Code keinen Schaden nimmt
|
||||
// und wir nur EINE Quelle der Wahrheit pflegen müssen.
|
||||
const isValidDocumentPath = isValidDocumentPathShared;
|
||||
|
||||
async function cleanupConsents() {
|
||||
// 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
|
||||
commercialRegisterNumber String? // Handelsregisternummer (Text)
|
||||
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
|
||||
|
||||
// ===== Portal-Zugangsdaten =====
|
||||
@@ -401,6 +402,12 @@ model StressfreiEmail {
|
||||
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
|
||||
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
|
||||
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
|
||||
createdAt DateTime @default(now())
|
||||
@@ -569,6 +576,15 @@ model Provider {
|
||||
portalUrl String? // Kundenkontourl (Login-Seite)
|
||||
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
|
||||
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
|
||||
// Kontaktdaten beim Anbieter (für CRM-Mitarbeiter zum Nachschlagen)
|
||||
contactEmail String? // Allgemeine Kontakt-Emailadresse
|
||||
contactPhone String? // Kontakt-Telefonnummer
|
||||
contactFax String? // Kontakt-Faxnummer
|
||||
contactAddress String? @db.Text // Kontakt-Postadresse (mehrzeilig)
|
||||
// Dedizierte Kündigungs-Endpunkte (wenn separat vom allgemeinen Kontakt)
|
||||
cancellationEmail String? // Kündigungs-Emailadresse
|
||||
cancellationFax String? // Kündigungs-Faxnummer
|
||||
cancellationAddress String? @db.Text // Kündigungs-Postadresse (mehrzeilig)
|
||||
isActive Boolean @default(true)
|
||||
tariffs Tariff[]
|
||||
contracts Contract[]
|
||||
@@ -686,6 +702,9 @@ model Contract {
|
||||
tariffName String?
|
||||
customerNumberAtProvider String?
|
||||
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
|
||||
priceFrom13Months String? // Preis ab 13. Monat
|
||||
priceAfter24Months String? // Preis nach 24 Monaten
|
||||
@@ -933,6 +952,7 @@ model SimCard {
|
||||
puk String? // PUK (verschlüsselt gespeichert)
|
||||
isMultisim Boolean @default(false) // Ist dies eine Multisim-Karte?
|
||||
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,
|
||||
// Kind bei Eltern-Vertrag) – kann vom Vertragsinhaber abweichen.
|
||||
cardUser String?
|
||||
|
||||
@@ -8,11 +8,13 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
|
||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.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 { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.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 prisma from '../lib/prisma.js';
|
||||
import path from 'path';
|
||||
@@ -39,12 +41,80 @@ function parseDateParam(v: unknown): Date | undefined {
|
||||
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
|
||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
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 limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
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> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
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 offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||
|
||||
const emails = await cachedEmailService.getCachedEmails({
|
||||
contractId,
|
||||
stressfreiEmailId,
|
||||
folder,
|
||||
limit,
|
||||
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> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
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);
|
||||
} catch (error) {
|
||||
@@ -309,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
|
||||
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
|
||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -325,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
||||
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
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||
|
||||
@@ -880,13 +1056,25 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
||||
|
||||
// ==================== 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> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
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);
|
||||
} 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> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
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);
|
||||
} catch (error) {
|
||||
@@ -1281,6 +1476,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
||||
const filePath = path.join(uploadsDir, newFilename);
|
||||
const relativePath = `/uploads/${targetDir}/${newFilename}`;
|
||||
|
||||
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen (JS/Launch/Embed).
|
||||
assertSafePdf(attachment.content);
|
||||
|
||||
// Datei speichern
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
|
||||
@@ -1431,8 +1629,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentTo error:', error);
|
||||
// Detailliertere Fehlermeldung für Debugging
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
@@ -1826,8 +2025,21 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const { documentType, notes } = req.body;
|
||||
|
||||
if (!documentType || typeof documentType !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'documentType ist erforderlich' } as ApiResponse);
|
||||
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1871,33 +2083,40 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
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 filePath = path.join(uploadsDir, newFilename);
|
||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||
|
||||
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
|
||||
// PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
|
||||
// Datei-Müll bei Race-Reject.
|
||||
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||||
fs.writeFileSync(filePath, pdfBuffer);
|
||||
|
||||
const doc = await prisma.contractDocument.create({
|
||||
return prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: contract.id,
|
||||
documentType,
|
||||
documentType: validatedType,
|
||||
documentPath: relativePath,
|
||||
originalName: `${email.subject || 'email'}.pdf`,
|
||||
notes: notes || null,
|
||||
notes: sanitizeNotes(notes),
|
||||
uploadedBy: (req as any).user?.email || 'email-import',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||||
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (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';
|
||||
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 relativePath = `/uploads/invoices/${newFilename}`;
|
||||
|
||||
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
|
||||
assertSafePdf(attachment.content);
|
||||
|
||||
// Datei speichern
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
|
||||
@@ -2056,8 +2278,9 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsInvoice error:', error);
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
@@ -2075,14 +2298,27 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
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({
|
||||
success: false,
|
||||
error: 'documentType ist erforderlich',
|
||||
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;
|
||||
}
|
||||
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
if (!email) {
|
||||
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 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 filePath = path.join(uploadsDir, 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).
|
||||
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
return prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: contract.id,
|
||||
documentType,
|
||||
documentType: validatedType,
|
||||
documentPath: relativePath,
|
||||
originalName: filename,
|
||||
notes: notes || null,
|
||||
notes: sanitizeNotes(notes),
|
||||
uploadedBy: (req as any).user?.email || 'email-import',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||||
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (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';
|
||||
res.status(500).json({
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as contractService from '../services/contract.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 { ApiResponse, AuthRequest } from '../types/index.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 { 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
|
||||
@@ -34,6 +36,29 @@ function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
|
||||
if (body === null || body === undefined) return body;
|
||||
if (typeof body === 'string') {
|
||||
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);
|
||||
}
|
||||
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> {
|
||||
try {
|
||||
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
|
||||
const before = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
@@ -196,6 +226,10 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
const fieldLabels: Record<string, string> = {
|
||||
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
||||
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',
|
||||
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
||||
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> {
|
||||
try {
|
||||
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 } });
|
||||
await contractService.deleteContract(contractId);
|
||||
await logChange({
|
||||
@@ -714,7 +750,7 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const { documentType, notes, deliveryDate } = req.body;
|
||||
const { documentType, notes } = req.body;
|
||||
|
||||
if (!req.file) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 doc = await prisma.contractDocument.create({
|
||||
// Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
|
||||
// die Datei schon geschrieben – bei Reject räumen wir sie wieder weg.
|
||||
let cleanType: string;
|
||||
try {
|
||||
cleanType = validateContractDocumentType(documentType);
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||
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,
|
||||
documentType: cleanType,
|
||||
documentPath,
|
||||
originalName: req.file.originalname,
|
||||
notes: notes || null,
|
||||
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 } });
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'ContractDocument',
|
||||
resourceId: doc.id.toString(),
|
||||
label: `Dokument "${documentType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
|
||||
details: { typ: documentType, datei: req.file.originalname },
|
||||
label: `Dokument "${cleanType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
|
||||
details: { typ: cleanType, datei: req.file.originalname },
|
||||
customerId: contract?.customerId,
|
||||
});
|
||||
|
||||
// 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);
|
||||
} 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,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
||||
} as ApiResponse);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sanitizeCustomerStrict,
|
||||
pickCustomerCreate,
|
||||
pickCustomerUpdate,
|
||||
sanitizePhoneField,
|
||||
isValidEmail,
|
||||
} from '../utils/sanitize.js';
|
||||
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);
|
||||
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
|
||||
if (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);
|
||||
|
||||
// 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
|
||||
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.
|
||||
//
|
||||
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
||||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
|
||||
// ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
|
||||
// Magic eines bekannten safe Typs (PDF, PNG, JPEG, GIF, WebP) zeigen.
|
||||
// Bei Mismatch fällt's auf attachment zurück – Stored XSS bleibt
|
||||
// weiterhin unmöglich.
|
||||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
|
||||
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
|
||||
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
|
||||
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
|
||||
// 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 wantsInline = req.query.disposition === 'inline';
|
||||
let useInline = false;
|
||||
let inlineContentType: string | null = null;
|
||||
if (wantsInline) {
|
||||
try {
|
||||
const fd = fs.openSync(absolute, 'r');
|
||||
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 */ }
|
||||
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
|
||||
|
||||
if (wantsInline && !safeContentType) {
|
||||
console.warn(
|
||||
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
|
||||
);
|
||||
}
|
||||
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (useInline && inlineContentType) {
|
||||
res.setHeader('Content-Type', inlineContentType);
|
||||
if (safeContentType) {
|
||||
res.setHeader('Content-Type', safeContentType);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
}
|
||||
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 consentPublicService from '../services/consent-public.service.js';
|
||||
import * as appSettingService from '../services/appSetting.service.js';
|
||||
import { getPublicUrl } from '../services/auth.service.js';
|
||||
import { canAccessCustomer } from '../utils/accessControl.js';
|
||||
import { createAuditLog, logChange } from '../services/audit.service.js';
|
||||
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) {
|
||||
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 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' });
|
||||
}
|
||||
|
||||
// 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> = {
|
||||
DATA_PROCESSING: 'Datenverarbeitung',
|
||||
MARKETING_EMAIL: 'E-Mail-Marketing',
|
||||
@@ -559,7 +585,10 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
|
||||
|
||||
// ConsentHash sicherstellen
|
||||
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}`;
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
// E-Mail senden
|
||||
|
||||
@@ -53,6 +53,23 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
|
||||
/**
|
||||
* 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> {
|
||||
try {
|
||||
const ecdId = parseInt(req.params.ecdId);
|
||||
@@ -66,6 +83,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!assertValidInvoiceType(invoiceType, res)) return;
|
||||
|
||||
const invoice = await invoiceService.addInvoice(ecdId, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
@@ -99,6 +117,8 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
||||
const invoiceId = parseInt(req.params.invoiceId);
|
||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||
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, {
|
||||
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);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
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, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
|
||||
@@ -3,10 +3,33 @@ import * as stressfreiEmailService from '../services/stressfreiEmail.service.js'
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.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> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const customerId = requireIdParam(req, res, 'customerId');
|
||||
if (customerId === null) return;
|
||||
// requireCustomerAccess in der Route greift nicht ausreichend:
|
||||
// Portal-User haben `customers:read` (für eigene Daten) und werden
|
||||
// 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> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
const emailId = requireIdParam(req, res, 'id');
|
||||
if (emailId === null) return;
|
||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||
|
||||
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> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const customerId = requireIdParam(req, res, 'customerId');
|
||||
if (customerId === null) return;
|
||||
const email = await stressfreiEmailService.createEmail({
|
||||
...req.body,
|
||||
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);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
||||
} 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> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
const emailId = requireIdParam(req, res, 'id');
|
||||
if (emailId === null) return;
|
||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
||||
await logChange({
|
||||
@@ -86,7 +113,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
||||
});
|
||||
res.json({ success: true, data: email } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
||||
} 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> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
const emailId = requireIdParam(req, res, 'id');
|
||||
if (emailId === null) return;
|
||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||
await stressfreiEmailService.deleteEmail(emailId);
|
||||
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> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
const emailId = requireIdParam(req, res, 'id');
|
||||
if (emailId === null) return;
|
||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||
|
||||
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> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
const emailId = requireIdParam(req, res, 'id');
|
||||
if (emailId === null) return;
|
||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import prisma from '../lib/prisma.js';
|
||||
import * as userService from '../services/user.service.js';
|
||||
import { logChange } from '../services/audit.service.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';
|
||||
|
||||
// Users
|
||||
@@ -70,6 +70,20 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
||||
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);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'User',
|
||||
@@ -114,13 +128,27 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
return;
|
||||
}
|
||||
// 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).
|
||||
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
||||
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||
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 /
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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
|
||||
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
|
||||
// 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 invoiceController from '../controllers/invoice.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -22,11 +23,15 @@ const docUpload = multer({
|
||||
},
|
||||
}),
|
||||
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);
|
||||
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);
|
||||
@@ -54,7 +59,7 @@ router.post('/:id/invoices', authenticate, requirePermission('contracts:update')
|
||||
|
||||
// Vertragsdokumente
|
||||
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);
|
||||
|
||||
// Folgezähler
|
||||
|
||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
import { requireSafeUploadedPdf } from '../middleware/pdfUploadSafety.js';
|
||||
import * as gdprController from '../controllers/gdpr.controller.js';
|
||||
|
||||
const router = Router();
|
||||
@@ -27,7 +28,7 @@ const authUpload = multer({
|
||||
cb(new Error('Nur PDF-Dateien sind erlaubt'));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: 25 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
// 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/grant', requirePermission('customers:update'), gdprController.grantAuthorization);
|
||||
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);
|
||||
|
||||
// Portal: Vollmachten
|
||||
|
||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
import { requireSafeUploadedPdf } from '../middleware/pdfUploadSafety.js';
|
||||
import * as pdfTemplateController from '../controllers/pdfTemplate.controller.js';
|
||||
|
||||
const router = Router();
|
||||
@@ -34,7 +35,7 @@ router.use(authenticate);
|
||||
router.get('/', requirePermission('settings:read'), pdfTemplateController.getTemplates);
|
||||
router.get('/crm-fields', requirePermission('settings:read'), pdfTemplateController.getCrmFields);
|
||||
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.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)
|
||||
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;
|
||||
|
||||
@@ -6,7 +6,33 @@ import prisma from '../lib/prisma.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
import { AuthRequest } from '../types/index.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();
|
||||
|
||||
@@ -57,7 +83,10 @@ const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
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) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-Upload-Validierung: prüft die Magic-Bytes der gerade geschriebenen
|
||||
* Datei und vergleicht mit dem fileFilter-Whitelist. Bei Mismatch
|
||||
* (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' });
|
||||
}
|
||||
}
|
||||
// Magic-Byte-Whitelist + canonical Rename + PDF-Scan: siehe
|
||||
// middleware/uploadFileTypeValidator.ts (zentralisiert, damit auch
|
||||
// contract.routes.ts denselben Check fahren kann – Pentest 69.3).
|
||||
|
||||
// Upload für Bankkarten-Dokumente
|
||||
router.post(
|
||||
@@ -164,9 +118,20 @@ router.post(
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
// Bankkarte in der DB aktualisieren
|
||||
await prisma.bankCard.update({
|
||||
where: { id: bankCardId },
|
||||
data: { documentPath: relativePath },
|
||||
@@ -183,6 +148,7 @@ router.post(
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
cleanupFile(req.file?.path);
|
||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
@@ -204,9 +170,20 @@ router.post(
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
// Ausweis in der DB aktualisieren
|
||||
await prisma.identityDocument.update({
|
||||
where: { id: documentId },
|
||||
data: { documentPath: relativePath },
|
||||
@@ -223,6 +200,7 @@ router.post(
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
cleanupFile(req.file?.path);
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
// Pentest 56.1: Ownership-Check.
|
||||
if (!(await canAccessBankCard(req, res, bankCardId))) return;
|
||||
|
||||
if (!bankCard.documentPath) {
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
// Pentest 56.1: Ownership-Check.
|
||||
if (!(await canAccessIdentityDocument(req, res, documentId))) return;
|
||||
|
||||
if (!document.documentPath) {
|
||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||
@@ -334,6 +316,11 @@ router.post(
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
// Alte Datei löschen falls vorhanden
|
||||
@@ -383,6 +370,11 @@ router.post(
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
// Alte Datei löschen falls vorhanden
|
||||
@@ -424,6 +416,8 @@ router.delete(
|
||||
async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
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 } });
|
||||
if (!customer) {
|
||||
@@ -464,6 +458,8 @@ router.delete(
|
||||
async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
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 } });
|
||||
if (!customer) {
|
||||
@@ -514,6 +510,14 @@ router.post(
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
// Alte Datei löschen falls vorhanden
|
||||
@@ -574,6 +578,8 @@ router.delete(
|
||||
async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
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 } });
|
||||
if (!customer) {
|
||||
@@ -662,11 +668,18 @@ async function handleContractDocumentUpload(
|
||||
const dateField = fieldName === 'cancellationConfirmationPath'
|
||||
? 'cancellationConfirmationDate'
|
||||
: '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;
|
||||
if (provided) {
|
||||
const parsed = new Date(provided);
|
||||
if (!isNaN(parsed.getTime())) target = parsed;
|
||||
target = new Date(provided);
|
||||
}
|
||||
if (target) {
|
||||
updateData[dateField] = target;
|
||||
@@ -846,14 +859,20 @@ router.post(
|
||||
}
|
||||
|
||||
const invoiceId = parseInt(req.params.id);
|
||||
const relativePath = `/uploads/invoices/${req.file.filename}`;
|
||||
|
||||
// Alte Datei löschen falls vorhanden
|
||||
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||
if (!invoice) {
|
||||
cleanupFile(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||
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) {
|
||||
const oldPath = path.join(process.cwd(), invoice.documentPath);
|
||||
@@ -898,6 +917,9 @@ router.delete(
|
||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||
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) {
|
||||
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
|
||||
* `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 raw = (fromSettings && fromSettings.trim())
|
||||
|| process.env.PUBLIC_URL
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import prisma from '../lib/prisma.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||||
|
||||
/**
|
||||
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
|
||||
@@ -53,6 +54,8 @@ export async function grantAuthorization(
|
||||
representativeId: number,
|
||||
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({
|
||||
where: {
|
||||
customerId_representativeId: { customerId, representativeId },
|
||||
|
||||
@@ -1040,20 +1040,41 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
|
||||
const finalBackupName = path.basename(finalBackupDir);
|
||||
|
||||
// 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);
|
||||
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) {
|
||||
// Pfade mit absoluten Pfaden oder Traversal ablehnen
|
||||
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}` };
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(absBackupDir, entryName);
|
||||
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
|
||||
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
|
||||
// Zip-Slip-Check: aufgelöster Pfad muss strikt im Backup-Verzeichnis
|
||||
// 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 {
|
||||
success: false,
|
||||
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) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
} 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
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
// Datei schreiben
|
||||
|
||||
@@ -603,33 +603,35 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
||||
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||
}
|
||||
|
||||
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails)
|
||||
export async function getFolderCountsForContract(contractId: number): Promise<{
|
||||
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
|
||||
// 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;
|
||||
inboxUnread: number;
|
||||
sent: number;
|
||||
sentUnread: number;
|
||||
trash: number;
|
||||
trashUnread: number;
|
||||
}> {
|
||||
const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([
|
||||
// INBOX total
|
||||
prisma.cachedEmail.count({
|
||||
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false },
|
||||
}),
|
||||
// INBOX unread
|
||||
prisma.cachedEmail.count({
|
||||
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, 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 },
|
||||
}),
|
||||
const baseWhere: Prisma.CachedEmailWhereInput = { contractId };
|
||||
if (stressfreiEmailId) baseWhere.stressfreiEmailId = stressfreiEmailId;
|
||||
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
|
||||
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false } }),
|
||||
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false, isRead: false } }),
|
||||
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false } }),
|
||||
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false, isRead: false } }),
|
||||
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true } }),
|
||||
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true, isRead: false } }),
|
||||
]);
|
||||
|
||||
return { inbox, inboxUnread, sent, sentUnread };
|
||||
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||
}
|
||||
|
||||
// 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
|
||||
export async function getTrashEmails(customerId: number): Promise<CachedEmailWithRelations[]> {
|
||||
return prisma.cachedEmail.findMany({
|
||||
where: {
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
stressfreiEmail: { customerId },
|
||||
};
|
||||
if (options?.stressfreiEmailId) {
|
||||
where.stressfreiEmailId = options.stressfreiEmailId;
|
||||
}
|
||||
if (options?.contractId) {
|
||||
where.contractId = options.contractId;
|
||||
}
|
||||
return prisma.cachedEmail.findMany({
|
||||
where,
|
||||
include: {
|
||||
stressfreiEmail: {
|
||||
select: {
|
||||
@@ -931,16 +945,22 @@ export async function getTrashEmails(customerId: number): Promise<CachedEmailWit
|
||||
}) as Promise<CachedEmailWithRelations[]>;
|
||||
}
|
||||
|
||||
// Papierkorb-E-Mails zählen
|
||||
export async function getTrashCount(customerId: number): Promise<number> {
|
||||
return prisma.cachedEmail.count({
|
||||
where: {
|
||||
// Papierkorb-E-Mails zählen (gleiche Filter wie getTrashEmails)
|
||||
export async function getTrashCount(
|
||||
customerId: number,
|
||||
options?: { stressfreiEmailId?: number; contractId?: number },
|
||||
): Promise<number> {
|
||||
const where: Prisma.CachedEmailWhereInput = {
|
||||
isDeleted: true,
|
||||
stressfreiEmail: {
|
||||
customerId,
|
||||
},
|
||||
},
|
||||
});
|
||||
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)
|
||||
|
||||
@@ -5,8 +5,19 @@ import * as consentService from './consent.service.js';
|
||||
import * as appSettingService from './appSetting.service.js';
|
||||
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) {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
@@ -18,28 +29,40 @@ export async function getCustomerByConsentHash(hash: string) {
|
||||
customerNumber: true,
|
||||
salutation: true,
|
||||
email: true,
|
||||
consentHashExpiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!customer) return null;
|
||||
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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) {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { consentHash: hash },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
select: { id: true, firstName: true, lastName: true, consentHashExpiresAt: true },
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
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 = [];
|
||||
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> {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { consentHash: true },
|
||||
select: { consentHash: true, consentHashExpiresAt: true },
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('Kunde nicht gefunden');
|
||||
}
|
||||
|
||||
if (customer.consentHash) {
|
||||
return customer.consentHash;
|
||||
const stillValid = customer.consentHash
|
||||
&& customer.consentHashExpiresAt
|
||||
&& customer.consentHashExpiresAt.getTime() > Date.now();
|
||||
|
||||
if (stillValid) {
|
||||
return customer.consentHash!;
|
||||
}
|
||||
|
||||
const hash = crypto.randomUUID();
|
||||
const expiresAt = new Date(Date.now() + CONSENT_HASH_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
await prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: { consentHash: hash },
|
||||
data: { consentHash: hash, consentHashExpiresAt: expiresAt },
|
||||
});
|
||||
|
||||
return hash;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ConsentType, ConsentStatus } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||||
|
||||
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
|
||||
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
|
||||
@@ -80,6 +81,12 @@ export async function updateConsent(
|
||||
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 updateData = {
|
||||
status: data.status,
|
||||
|
||||
@@ -2,25 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||||
import { encrypt, decrypt } from '../utils/encryption.js';
|
||||
import { sanitizeCustomerStrict } 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;
|
||||
}
|
||||
import { sanitizeCustomerStrict, sanitizePhoneField } from '../utils/sanitize.js';
|
||||
|
||||
export interface ContractFilters {
|
||||
customerId?: number;
|
||||
@@ -221,6 +203,9 @@ interface ContractCreateData {
|
||||
providerName?: string;
|
||||
tariffName?: string;
|
||||
customerNumberAtProvider?: string;
|
||||
orderNumberAtSalesPlatform?: string;
|
||||
customerNumberAtSalesPlatform?: string;
|
||||
contractNumberAtSalesPlatform?: string;
|
||||
priceFirst12Months?: string;
|
||||
priceFrom13Months?: string;
|
||||
priceAfter24Months?: string;
|
||||
@@ -294,6 +279,7 @@ interface ContractCreateData {
|
||||
puk?: string;
|
||||
isMultisim?: boolean;
|
||||
isMain?: boolean;
|
||||
isEsim?: boolean;
|
||||
cardUser?: string;
|
||||
}[];
|
||||
};
|
||||
@@ -399,6 +385,7 @@ export async function createContract(data: ContractCreateData) {
|
||||
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
||||
isMultisim: sc.isMultisim ?? false,
|
||||
isMain: sc.isMain ?? false,
|
||||
isEsim: sc.isEsim ?? false,
|
||||
cardUser: sc.cardUser,
|
||||
})),
|
||||
}
|
||||
@@ -638,6 +625,7 @@ export async function updateContract(
|
||||
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
|
||||
isMultisim: sc.isMultisim ?? false,
|
||||
isMain: sc.isMain ?? false,
|
||||
isEsim: sc.isEsim ?? false,
|
||||
cardUser: sc.cardUser,
|
||||
};
|
||||
}),
|
||||
@@ -657,6 +645,7 @@ export async function updateContract(
|
||||
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
||||
isMultisim: sc.isMultisim ?? false,
|
||||
isMain: sc.isMain ?? false,
|
||||
isEsim: sc.isEsim ?? false,
|
||||
cardUser: sc.cardUser,
|
||||
})),
|
||||
}
|
||||
@@ -784,6 +773,7 @@ export async function createFollowUpContract(previousContractId: number) {
|
||||
simCardNumber: sc.simCardNumber ?? undefined,
|
||||
isMultisim: sc.isMultisim,
|
||||
isMain: sc.isMain,
|
||||
isEsim: sc.isEsim,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -909,6 +899,10 @@ export async function createRenewalContract(previousContractId: number) {
|
||||
providerName: previousContract.providerName,
|
||||
tariffName: previousContract.tariffName,
|
||||
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
||||
contractNumberAtProvider: previousContract.contractNumberAtProvider,
|
||||
orderNumberAtSalesPlatform: previousContract.orderNumberAtSalesPlatform,
|
||||
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
|
||||
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
|
||||
portalUsername: previousContract.portalUsername,
|
||||
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
||||
commission: previousContract.commission,
|
||||
@@ -1008,6 +1002,7 @@ export async function createRenewalContract(previousContractId: number) {
|
||||
simCardNumber: sc.simCardNumber,
|
||||
isMultisim: sc.isMultisim,
|
||||
isMain: sc.isMain,
|
||||
isEsim: sc.isEsim,
|
||||
pin: sc.pin,
|
||||
puk: sc.puk,
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import cron from 'node-cron';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { createAuditLog, logChange } from './audit.service.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
|
||||
async function runExpireCheck(): Promise<void> {
|
||||
const today = new Date();
|
||||
@@ -84,6 +85,61 @@ export function startContractStatusScheduler(): void {
|
||||
|
||||
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
|
||||
* Lieferbestätigung ist:
|
||||
|
||||
@@ -180,17 +180,56 @@ export class PleskEmailProvider implements IEmailProvider {
|
||||
|
||||
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
|
||||
let hasMailbox: boolean | undefined;
|
||||
let mailgroupActive: boolean | undefined;
|
||||
let mailgroupMembers: string[] | undefined;
|
||||
let forwardingActive: boolean | undefined;
|
||||
let forwardingTargets: string[] | undefined;
|
||||
if (exists && result.stdout) {
|
||||
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
|
||||
if (mailboxMatch) {
|
||||
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 {
|
||||
exists,
|
||||
email: exists ? email : undefined,
|
||||
hasMailbox,
|
||||
mailgroupActive,
|
||||
mailgroupMembers,
|
||||
forwardingActive,
|
||||
forwardingTargets,
|
||||
};
|
||||
} catch (error) {
|
||||
// HTTP-Fehler oder Netzwerkfehler
|
||||
@@ -458,15 +497,63 @@ export class PleskEmailProvider implements IEmailProvider {
|
||||
};
|
||||
}
|
||||
|
||||
// Plesk CLI API: Weiterleitungsziele aktualisieren
|
||||
// Format für -forwarding-addresses: "set:email1,email2" ersetzt alle Adressen
|
||||
await this.request('POST', '/api/v2/cli/mail/call', {
|
||||
params: [
|
||||
// Plesk-CLI-Eigenheit: `-forwarding-addresses` akzeptiert NUR
|
||||
// `add:` und `del:`, KEIN `set:`. Und `-forwarding` ist der
|
||||
// Mailgroup-Schalter (Plesk nennt das im `--info` "Mailgroup",
|
||||
// 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,
|
||||
'-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 {
|
||||
success: true,
|
||||
|
||||
@@ -42,6 +42,14 @@ export interface EmailExistsResult {
|
||||
exists: boolean;
|
||||
email?: string;
|
||||
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 {
|
||||
|
||||
@@ -46,6 +46,13 @@ export interface ProviderExport {
|
||||
portalUrl: string | null;
|
||||
usernameFieldName: string | null;
|
||||
passwordFieldName: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
contactFax: string | null;
|
||||
contactAddress: string | null;
|
||||
cancellationEmail: string | null;
|
||||
cancellationFax: string | null;
|
||||
cancellationAddress: string | null;
|
||||
isActive: boolean;
|
||||
tariffs: { name: string; isActive: boolean }[];
|
||||
}
|
||||
@@ -90,6 +97,13 @@ export async function collectFactoryDefaults() {
|
||||
portalUrl: p.portalUrl,
|
||||
usernameFieldName: p.usernameFieldName,
|
||||
passwordFieldName: p.passwordFieldName,
|
||||
contactEmail: p.contactEmail,
|
||||
contactPhone: p.contactPhone,
|
||||
contactFax: p.contactFax,
|
||||
contactAddress: p.contactAddress,
|
||||
cancellationEmail: p.cancellationEmail,
|
||||
cancellationFax: p.cancellationFax,
|
||||
cancellationAddress: p.cancellationAddress,
|
||||
isActive: p.isActive,
|
||||
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
|
||||
})),
|
||||
@@ -284,6 +298,13 @@ export async function importFactoryDefaults(
|
||||
portalUrl: p.portalUrl ?? null,
|
||||
usernameFieldName: p.usernameFieldName ?? null,
|
||||
passwordFieldName: p.passwordFieldName ?? null,
|
||||
contactEmail: p.contactEmail ?? null,
|
||||
contactPhone: p.contactPhone ?? null,
|
||||
contactFax: p.contactFax ?? null,
|
||||
contactAddress: p.contactAddress ?? null,
|
||||
cancellationEmail: p.cancellationEmail ?? null,
|
||||
cancellationFax: p.cancellationFax ?? null,
|
||||
cancellationAddress: p.cancellationAddress ?? null,
|
||||
isActive: p.isActive ?? true,
|
||||
},
|
||||
create: {
|
||||
@@ -291,6 +312,13 @@ export async function importFactoryDefaults(
|
||||
portalUrl: p.portalUrl ?? null,
|
||||
usernameFieldName: p.usernameFieldName ?? null,
|
||||
passwordFieldName: p.passwordFieldName ?? null,
|
||||
contactEmail: p.contactEmail ?? null,
|
||||
contactPhone: p.contactPhone ?? null,
|
||||
contactFax: p.contactFax ?? null,
|
||||
contactAddress: p.contactAddress ?? null,
|
||||
cancellationEmail: p.cancellationEmail ?? null,
|
||||
cancellationFax: p.cancellationFax ?? null,
|
||||
cancellationAddress: p.cancellationAddress ?? null,
|
||||
isActive: p.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,13 +45,16 @@ export const CRM_FIELDS = [
|
||||
{ path: 'owner.phone', label: 'Telefon', group: 'Eigentümer' },
|
||||
{ path: 'owner.mobile', label: 'Mobil', 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.houseNumber', label: 'Hausnummer (Rechnung)', group: 'Rechnungsadresse' },
|
||||
{ path: 'billingAddress.streetFull', label: 'Straße + Nr. (Rechnung)', group: 'Rechnungsadresse' },
|
||||
{ path: 'billingAddress.postalCode', label: 'PLZ (Rechnung)', group: 'Rechnungsadresse' },
|
||||
{ path: 'billingAddress.city', label: '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
|
||||
{ path: 'bankCard.iban', label: 'IBAN', 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 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> = {
|
||||
// Kunde
|
||||
@@ -437,6 +445,8 @@ export async function generateFilledPdf(
|
||||
'billingAddress.postalCode': bAddr?.postalCode || '',
|
||||
'billingAddress.city': 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
|
||||
'bankCard.iban': bankCard?.iban || '',
|
||||
'bankCard.bic': bankCard?.bic || '',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
// 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
|
||||
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
|
||||
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
|
||||
function stripProviderStrings<T extends { name?: string; usernameFieldName?: string; passwordFieldName?: string }>(data: T): T {
|
||||
//
|
||||
// 2026-06-21: contactEmail/cancellationEmail laufen zusätzlich durch
|
||||
// isValidEmail (Header-Injection-Schutz für künftige Mail-Templates),
|
||||
// contactPhone/contactFax/cancellationFax durch sanitizePhoneField
|
||||
// (kein CRLF/Control-Char), Postadressen durch sanitizeNotes mit
|
||||
// 500-Cap (mehrzeilig, normalisierte Newlines).
|
||||
function stripProviderStrings<T extends object>(data: T): T {
|
||||
const out: any = { ...data };
|
||||
if (typeof out.name === 'string') out.name = stripHtml(out.name);
|
||||
if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName);
|
||||
if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName);
|
||||
for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) {
|
||||
if (typeof out[k] === 'string') out[k] = stripHtml(out[k]);
|
||||
}
|
||||
for (const k of ['contactEmail', 'cancellationEmail'] as const) {
|
||||
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
|
||||
if (out[k] === undefined) continue;
|
||||
const stripped = typeof out[k] === 'string' ? stripHtml(out[k]) : out[k];
|
||||
const value = typeof stripped === 'string' ? stripped.trim() : stripped;
|
||||
if (value === '' ) { out[k] = null; continue; }
|
||||
if (!isValidEmail(value)) {
|
||||
throw new Error(`${k === 'contactEmail' ? 'Kontakt-Emailadresse' : 'Kündigungs-Emailadresse'} ist ungültig.`);
|
||||
}
|
||||
out[k] = value;
|
||||
}
|
||||
const phoneLabels: Record<string, string> = {
|
||||
contactPhone: 'Kontakt-Telefonnummer',
|
||||
contactFax: 'Kontakt-Faxnummer',
|
||||
cancellationFax: 'Kündigungs-Faxnummer',
|
||||
};
|
||||
for (const k of ['contactPhone', 'contactFax', 'cancellationFax'] as const) {
|
||||
if (out[k] === undefined) continue;
|
||||
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
|
||||
const v = sanitizePhoneField(out[k], phoneLabels[k]);
|
||||
out[k] = v === undefined ? null : v;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
export async function createProvider(data: {
|
||||
name: string;
|
||||
interface ProviderWritable {
|
||||
name?: string;
|
||||
portalUrl?: string;
|
||||
usernameFieldName?: string;
|
||||
passwordFieldName?: string;
|
||||
}) {
|
||||
contactEmail?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactFax?: string | null;
|
||||
contactAddress?: string | null;
|
||||
cancellationEmail?: string | null;
|
||||
cancellationFax?: string | null;
|
||||
cancellationAddress?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export async function createProvider(data: ProviderWritable & { name: string }) {
|
||||
const clean = stripProviderStrings(data);
|
||||
const portalUrl = assertValidPortalUrl(clean.portalUrl);
|
||||
return prisma.provider.create({
|
||||
data: {
|
||||
...clean,
|
||||
name: clean.name,
|
||||
portalUrl,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProvider(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
portalUrl?: string;
|
||||
usernameFieldName?: string;
|
||||
passwordFieldName?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
) {
|
||||
export async function updateProvider(id: number, data: ProviderWritable) {
|
||||
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
|
||||
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
|
||||
// damit Prisma nicht den alten Wert hält.
|
||||
const updateData: typeof data = stripProviderStrings(data);
|
||||
const updateData: any = stripProviderStrings(data);
|
||||
if (data.portalUrl !== undefined) {
|
||||
const validated = assertValidPortalUrl(data.portalUrl);
|
||||
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
|
||||
updateData.portalUrl = validated ?? null;
|
||||
}
|
||||
return prisma.provider.update({
|
||||
where: { id },
|
||||
|
||||
@@ -11,6 +11,75 @@ import {
|
||||
getActiveProviderConfig,
|
||||
} from './emailProvider/emailProviderService.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) {
|
||||
const where: Record<string, unknown> = { customerId };
|
||||
@@ -83,6 +152,27 @@ export interface CreateEmailData {
|
||||
export async function createEmail(data: CreateEmailData) {
|
||||
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
|
||||
if (provisionAtProvider) {
|
||||
// Kunde laden für Weiterleitung
|
||||
@@ -153,6 +243,34 @@ export async function updateEmail(
|
||||
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({
|
||||
where: { id },
|
||||
data,
|
||||
@@ -163,6 +281,98 @@ export async function deleteEmail(id: number) {
|
||||
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)
|
||||
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
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).
|
||||
export async function syncForwardingForEmail(
|
||||
id: number,
|
||||
options: { autoImportPleskMembers?: boolean } = {},
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
forwardTargets?: string[];
|
||||
@@ -305,6 +516,14 @@ export async function syncForwardingForEmail(
|
||||
passwordReset?: boolean;
|
||||
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({
|
||||
where: { id },
|
||||
select: {
|
||||
@@ -313,6 +532,7 @@ export async function syncForwardingForEmail(
|
||||
isProvisioned: true,
|
||||
hasMailbox: true,
|
||||
emailPasswordEncrypted: true,
|
||||
additionalForwardingEmails: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -333,10 +553,90 @@ export async function syncForwardingForEmail(
|
||||
if (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];
|
||||
|
||||
// 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);
|
||||
if (!forwardResult.success) {
|
||||
// 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.
|
||||
*/
|
||||
|
||||
import { ApiError } from './apiError.js';
|
||||
|
||||
// Felder die NIE in einer API-Response an den Client gehen dürfen
|
||||
const SENSITIVE_CUSTOMER_FIELDS = [
|
||||
'portalPasswordHash',
|
||||
@@ -15,6 +17,10 @@ const SENSITIVE_CUSTOMER_FIELDS = [
|
||||
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
|
||||
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
|
||||
'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
|
||||
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
|
||||
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
|
||||
@@ -69,6 +75,9 @@ const CONTRACT_DISPLAY_STRING_FIELDS = [
|
||||
'tariffName',
|
||||
'customerNumberAtProvider',
|
||||
'contractNumberAtProvider',
|
||||
'orderNumberAtSalesPlatform',
|
||||
'customerNumberAtSalesPlatform',
|
||||
'contractNumberAtSalesPlatform',
|
||||
'portalUsername',
|
||||
'previousProviderName',
|
||||
'previousCustomerNumber',
|
||||
@@ -166,6 +175,341 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
|
||||
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
||||
* 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
|
||||
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
|
||||
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
|
||||
|
||||
@@ -84,6 +84,57 @@ const BLOCKED_HOSTNAMES = new Set([
|
||||
'[::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 {
|
||||
if (!host) return false;
|
||||
const h = host.trim().toLowerCase();
|
||||
@@ -92,6 +143,10 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
||||
for (const pattern of BLOCKED_PATTERNS) {
|
||||
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 (PRIVATE_HOSTNAMES.has(h)) return true;
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
100 % gibt es nicht. Erreicht ist:
|
||||
|
||||
+586
@@ -97,6 +97,592 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ 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"**
|
||||
- `customerEmailLabel` existierte im Backend (Schema +
|
||||
Update-Logik + Public-Endpoint), war im UI aber nicht
|
||||
|
||||
Generated
+190
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "opencrm-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opencrm-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
@@ -15,6 +15,7 @@
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"axios": "^1.7.7",
|
||||
"dompurify": "^3.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -264,6 +265,14 @@
|
||||
"@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": {
|
||||
"version": "7.28.6",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"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": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
@@ -1767,6 +1787,15 @@
|
||||
"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": {
|
||||
"version": "2.9.16",
|
||||
"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": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1936,11 +1984,31 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"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": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2169,6 +2237,16 @@
|
||||
"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": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -2178,6 +2256,11 @@
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -2370,6 +2453,24 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2465,6 +2566,22 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -3189,6 +3326,12 @@
|
||||
"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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -3219,6 +3362,15 @@
|
||||
"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": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
||||
@@ -3317,6 +3469,15 @@
|
||||
"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": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -3351,6 +3512,15 @@
|
||||
"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": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
@@ -3388,6 +3558,15 @@
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -3534,6 +3713,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"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": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"axios": "^1.7.7",
|
||||
"dompurify": "^3.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
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 Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
@@ -218,6 +219,7 @@ function InvoiceModal({
|
||||
});
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
|
||||
|
||||
const addInvoiceFn = async (data: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
||||
if (ecdId) {
|
||||
@@ -386,6 +388,7 @@ function InvoiceModal({
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -393,8 +396,23 @@ function InvoiceModal({
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<JpgToPdfModal
|
||||
isOpen={isJpgModalOpen}
|
||||
onClose={() => setIsJpgModalOpen(false)}
|
||||
onComplete={(file) => setSelectedFile(file)}
|
||||
fileNameHint="rechnung"
|
||||
/>
|
||||
|
||||
{formData.invoiceType === 'NOT_AVAILABLE' && (
|
||||
<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 { Send, Paperclip, X, FileText } from 'lucide-react';
|
||||
import { Send, Paperclip, X, FileText, FilePlus, UserPlus } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from '../ui/Modal';
|
||||
import Button from '../ui/Button';
|
||||
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import AttachContractDocumentsModal from './AttachContractDocumentsModal';
|
||||
import InsertCustomerDataModal from './InsertCustomerDataModal';
|
||||
|
||||
interface ComposeEmailModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -31,6 +33,8 @@ export default function ComposeEmailModal({
|
||||
const [body, setBody] = useState('');
|
||||
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAttachDocsModal, setShowAttachDocsModal] = useState(false);
|
||||
const [showInsertDataModal, setShowInsertDataModal] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Formular bei Modal-Öffnung initialisieren
|
||||
@@ -308,7 +312,8 @@ export default function ComposeEmailModal({
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Anhang hinzufügen Button */}
|
||||
{/* Anhang-/Daten-Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -317,6 +322,29 @@ export default function ComposeEmailModal({
|
||||
<Paperclip className="w-4 h-4 mr-2" />
|
||||
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 */}
|
||||
{attachments.length > 0 && (
|
||||
@@ -374,6 +402,35 @@ export default function ComposeEmailModal({
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Button from '../ui/Button';
|
||||
import Card from '../ui/Card';
|
||||
import CopyButton from '../ui/CopyButton';
|
||||
import EmailDetail from './EmailDetail';
|
||||
import ComposeEmailModal from './ComposeEmailModal';
|
||||
import TrashEmailList from './TrashEmailList';
|
||||
@@ -49,28 +50,44 @@ export default function ContractEmailsSection({
|
||||
|
||||
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({
|
||||
queryKey: ['emails', 'contract', contractId, selectedFolder],
|
||||
queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder as 'INBOX' | 'SENT' }),
|
||||
enabled: selectedFolder !== 'TRASH',
|
||||
queryKey: ['emails', 'contract', contractId, selectedAccountId, selectedFolder],
|
||||
queryFn: () => cachedEmailApi.getForContract(contractId, {
|
||||
folder: selectedFolder as 'INBOX' | 'SENT',
|
||||
accountId: selectedAccountId ?? undefined,
|
||||
}),
|
||||
enabled: selectedFolder !== 'TRASH' && !!selectedAccountId,
|
||||
});
|
||||
|
||||
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({
|
||||
queryKey: ['emails', 'trash', customerId],
|
||||
queryFn: () => cachedEmailApi.getTrash(customerId),
|
||||
enabled: selectedFolder === 'TRASH' && canAccessTrash,
|
||||
queryKey: ['emails', 'trash', customerId, selectedAccountId, contractId],
|
||||
queryFn: () => cachedEmailApi.getTrash(customerId, {
|
||||
accountId: selectedAccountId ?? undefined,
|
||||
contractId,
|
||||
}),
|
||||
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
|
||||
});
|
||||
|
||||
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({
|
||||
queryKey: ['contract-folder-counts', contractId],
|
||||
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId),
|
||||
queryKey: ['contract-folder-counts', contractId, selectedAccountId],
|
||||
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId, selectedAccountId ?? undefined),
|
||||
enabled: !!selectedAccountId,
|
||||
});
|
||||
|
||||
const folderCounts = folderCountsData?.data || {
|
||||
@@ -78,16 +95,6 @@ export default function ContractEmailsSection({
|
||||
inboxUnread: 0,
|
||||
sent: 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,
|
||||
trashUnread: 0,
|
||||
};
|
||||
@@ -358,11 +365,23 @@ export default function ContractEmailsSection({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedAccount?.email && (
|
||||
<CopyButton
|
||||
value={selectedAccount.email}
|
||||
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Inbox className="w-4 h-4 text-gray-500" />
|
||||
<span>{selectedAccount?.email}</span>
|
||||
{selectedAccount?.email && (
|
||||
<CopyButton
|
||||
value={selectedAccount.email}
|
||||
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -429,18 +448,18 @@ export default function ContractEmailsSection({
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Papierkorb
|
||||
{accountFolderCounts.trash > 0 && (
|
||||
{folderCounts.trash > 0 && (
|
||||
<span
|
||||
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-gray-100 text-gray-500'
|
||||
}`}
|
||||
title={`${accountFolderCounts.trashUnread} ungelesen / ${accountFolderCounts.trash} gesamt`}
|
||||
title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`}
|
||||
>
|
||||
{accountFolderCounts.trashUnread > 0
|
||||
? `${accountFolderCounts.trashUnread}/${accountFolderCounts.trash}`
|
||||
: accountFolderCounts.trash}
|
||||
{folderCounts.trashUnread > 0
|
||||
? `${folderCounts.trashUnread}/${folderCounts.trash}`
|
||||
: folderCounts.trash}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -470,9 +489,13 @@ export default function ContractEmailsSection({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
|
||||
{/* Email List */}
|
||||
<div className="w-1/3 border-r border-gray-200 overflow-auto">
|
||||
<div
|
||||
className="flex -mx-6 -mb-6"
|
||||
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' ? (
|
||||
<TrashEmailList
|
||||
emails={trashEmails}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } fr
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||
import Button from '../ui/Button';
|
||||
import CopyButton from '../ui/CopyButton';
|
||||
import EmailList from './EmailList';
|
||||
import EmailDetail from './EmailDetail';
|
||||
import ComposeEmailModal from './ComposeEmailModal';
|
||||
@@ -124,11 +125,17 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||
|
||||
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({
|
||||
queryKey: ['emails', 'trash', customerId],
|
||||
queryFn: () => cachedEmailApi.getTrash(customerId),
|
||||
enabled: selectedFolder === 'TRASH' && canAccessTrash,
|
||||
queryKey: ['emails', 'trash', customerId, selectedAccountId],
|
||||
queryFn: () => cachedEmailApi.getTrash(customerId, {
|
||||
accountId: selectedAccountId ?? undefined,
|
||||
}),
|
||||
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
|
||||
});
|
||||
|
||||
const trashEmails = trashData?.data || [];
|
||||
@@ -288,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
||||
{/* Account Selector */}
|
||||
@@ -309,11 +322,25 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedAccount?.email && (
|
||||
<CopyButton
|
||||
value={selectedAccount.email}
|
||||
size="md"
|
||||
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
<Inbox className="w-5 h-5 text-gray-500" />
|
||||
<span>{selectedAccount?.email}</span>
|
||||
{selectedAccount?.email && (
|
||||
<CopyButton
|
||||
value={selectedAccount.email}
|
||||
size="md"
|
||||
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||
/>
|
||||
)}
|
||||
</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 DOMPurify from 'dompurify';
|
||||
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
||||
@@ -52,6 +52,24 @@ export default function EmailDetail({
|
||||
setLocalStarred(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({
|
||||
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
||||
onMutate: () => {
|
||||
@@ -312,6 +330,16 @@ export default function EmailDetail({
|
||||
{email.contract.contractNumber}
|
||||
</Link>
|
||||
</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) */}
|
||||
{!email.isAutoAssigned && (
|
||||
<button
|
||||
@@ -411,16 +439,7 @@ export default function EmailDetail({
|
||||
{showHtml && email.htmlBody ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__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'],
|
||||
}),
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtmlBody }}
|
||||
/>
|
||||
) : (
|
||||
<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 { Upload } from 'lucide-react';
|
||||
import { Upload, Images } from 'lucide-react';
|
||||
import Button from './Button';
|
||||
import JpgToPdfModal from './JpgToPdfModal';
|
||||
|
||||
interface FileUploadProps {
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
@@ -8,6 +9,10 @@ interface FileUploadProps {
|
||||
accept?: string;
|
||||
label?: string;
|
||||
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({
|
||||
@@ -16,10 +21,16 @@ export default function FileUpload({
|
||||
accept = '.pdf,.jpg,.jpeg,.png',
|
||||
label = 'Dokument hochladen',
|
||||
disabled = false,
|
||||
enableJpgToPdf,
|
||||
jpgToPdfFileNameHint,
|
||||
}: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = 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) => {
|
||||
if (!file) return;
|
||||
@@ -64,6 +75,7 @@ export default function FileUpload({
|
||||
<div className="space-y-2">
|
||||
{existingFile ? (
|
||||
!disabled && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -72,8 +84,21 @@ export default function FileUpload({
|
||||
>
|
||||
{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 ${
|
||||
dragOver
|
||||
@@ -94,10 +119,23 @@ export default function FileUpload({
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
@@ -108,6 +146,15 @@ export default function FileUpload({
|
||||
className="hidden"
|
||||
disabled={disabled || isUploading}
|
||||
/>
|
||||
|
||||
<JpgToPdfModal
|
||||
isOpen={isJpgModalOpen}
|
||||
onClose={() => setIsJpgModalOpen(false)}
|
||||
onComplete={(file) => {
|
||||
handleFileSelect(file);
|
||||
}}
|
||||
fileNameHint={jpgToPdfFileNameHint}
|
||||
/>
|
||||
</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 { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
@@ -11,9 +12,21 @@ interface TabsProps {
|
||||
defaultTab?: string;
|
||||
activeTab?: string;
|
||||
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 activeTab = controlledTab ?? internalTab;
|
||||
|
||||
@@ -31,10 +44,10 @@ export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTab
|
||||
return (
|
||||
<div>
|
||||
<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) => (
|
||||
<div key={tab.id} className="flex items-center gap-1">
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
@@ -44,6 +57,20 @@ export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTab
|
||||
>
|
||||
{tab.label}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,8 @@ import Badge from '../../components/ui/Badge';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
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 CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
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="flex items-center gap-2 mb-2 flex-wrap">
|
||||
{simCard.isMain && <Badge variant="success">Hauptkarte</Badge>}
|
||||
{simCard.isEsim && <Badge variant="info">eSIM</Badge>}
|
||||
{simCard.isMultisim && <Badge variant="warning">Multisim</Badge>}
|
||||
{simCard.cardUser && (
|
||||
<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">
|
||||
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setShowCustomerInfo(true)}
|
||||
@@ -2078,6 +2090,33 @@ export default function ContractDetail() {
|
||||
</dd>
|
||||
</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 && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
|
||||
@@ -3484,6 +3523,7 @@ function ContractDocumentsSection({
|
||||
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
|
||||
() => new Date().toISOString().split('T')[0],
|
||||
);
|
||||
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
|
||||
|
||||
const { data: docsData } = useQuery({
|
||||
queryKey: ['contract-documents', contractId],
|
||||
@@ -3581,15 +3621,31 @@ function ContractDocumentsSection({
|
||||
</p>
|
||||
</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">
|
||||
<Plus className="w-4 h-4" />
|
||||
Datei wählen (PDF, JPG, PNG)
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={handleFileSelect} />
|
||||
</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>
|
||||
{uploadMutation.isPending && <span className="text-sm text-gray-500">Hochladen...</span>}
|
||||
</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 && (
|
||||
<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 Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import CopyButton from '../../components/ui/CopyButton';
|
||||
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
||||
import type { ContractType } from '../../types';
|
||||
import { formatDate } from '../../utils/dateFormat';
|
||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||
@@ -192,6 +194,7 @@ export default function ContractForm() {
|
||||
hasExistingPuk?: boolean; // Zeigt an ob PUK bereits in DB vorhanden
|
||||
isMultisim: boolean;
|
||||
isMain: boolean;
|
||||
isEsim: boolean;
|
||||
cardUser: string;
|
||||
}
|
||||
const [simCards, setSimCards] = useState<SimCardInput[]>([]);
|
||||
@@ -219,6 +222,7 @@ export default function ContractForm() {
|
||||
|
||||
// Passwort-Sichtbarkeit
|
||||
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
||||
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
||||
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
||||
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
||||
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
||||
@@ -301,6 +305,9 @@ export default function ContractForm() {
|
||||
tariffName: c.tariffName || '',
|
||||
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
||||
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
||||
orderNumberAtSalesPlatform: c.orderNumberAtSalesPlatform || '',
|
||||
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
|
||||
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
|
||||
priceFirst12Months: c.priceFirst12Months || '',
|
||||
priceFrom13Months: c.priceFrom13Months || '',
|
||||
priceAfter24Months: c.priceAfter24Months || '',
|
||||
@@ -384,6 +391,7 @@ export default function ContractForm() {
|
||||
hasExistingPuk: !!sc.puk, // true wenn verschlüsselter Wert vorhanden
|
||||
isMultisim: sc.isMultisim,
|
||||
isMain: sc.isMain,
|
||||
isEsim: sc.isEsim ?? false,
|
||||
cardUser: sc.cardUser || '',
|
||||
})));
|
||||
} else {
|
||||
@@ -554,6 +562,9 @@ export default function ContractForm() {
|
||||
tariffName: emptyToNull(data.tariffName),
|
||||
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
||||
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
||||
orderNumberAtSalesPlatform: emptyToNull(data.orderNumberAtSalesPlatform),
|
||||
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
|
||||
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
|
||||
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
||||
priceFrom13Months: emptyToNull(data.priceFrom13Months),
|
||||
priceAfter24Months: emptyToNull(data.priceAfter24Months),
|
||||
@@ -650,6 +661,7 @@ export default function ContractForm() {
|
||||
puk: sc.puk || undefined, // Passwort: undefined = nicht ändern
|
||||
isMultisim: sc.isMultisim,
|
||||
isMain: sc.isMain,
|
||||
isEsim: sc.isEsim,
|
||||
cardUser: emptyToNull(sc.cardUser),
|
||||
})) : undefined,
|
||||
};
|
||||
@@ -765,7 +777,7 @@ export default function ContractForm() {
|
||||
|
||||
return (
|
||||
<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 })}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -773,6 +785,36 @@ export default function ContractForm() {
|
||||
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
||||
</h1>
|
||||
</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 && (
|
||||
<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 }))}
|
||||
disabled={!selectedProviderId}
|
||||
/>
|
||||
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
|
||||
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
|
||||
<Input label="Kundennummer beim Anbieter" maxLength={100} {...register('customerNumberAtProvider')} />
|
||||
<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="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" />
|
||||
@@ -1005,7 +1050,24 @@ export default function ContractForm() {
|
||||
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -1023,6 +1085,7 @@ export default function ContractForm() {
|
||||
{usernameType === 'manual' && (
|
||||
<Input
|
||||
{...register('portalUsername')}
|
||||
maxLength={100}
|
||||
placeholder="Benutzername eingeben..."
|
||||
/>
|
||||
)}
|
||||
@@ -1517,6 +1580,19 @@ export default function ContractForm() {
|
||||
/>
|
||||
Hauptkarte
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1642,6 +1718,7 @@ export default function ContractForm() {
|
||||
puk: '',
|
||||
isMultisim: false,
|
||||
isMain: simCards.length === 0, // Erste Karte ist Hauptkarte
|
||||
isEsim: false,
|
||||
cardUser: '',
|
||||
}]);
|
||||
}}
|
||||
@@ -1719,6 +1796,15 @@ export default function ContractForm() {
|
||||
|
||||
{/* Status-Info Modal */}
|
||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||
|
||||
{/* Kunden-Schnellansicht */}
|
||||
{customer && (
|
||||
<CustomerInfoModal
|
||||
customerId={customer.id}
|
||||
open={showCustomerInfo}
|
||||
onClose={() => setShowCustomerInfo(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import Modal from '../../components/ui/Modal';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
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 BirthdayManagementModal from '../../components/BirthdayManagementModal';
|
||||
import { formatDate } from '../../utils/dateFormat';
|
||||
@@ -410,7 +411,13 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<AddressModal
|
||||
@@ -2450,10 +2457,13 @@ function AddressModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Update form when address prop changes
|
||||
if (isEditing && formData.street !== address.street) {
|
||||
// Beim Öffnen / Wechsel zwischen Adressen aus den Props re-initialisieren.
|
||||
// Vorher als unbedingte if-setState im Render-Body → Reset bei jedem
|
||||
// Tastendruck, Straße ließ sich nicht eintippen.
|
||||
useEffect(() => {
|
||||
setFormData(getInitialFormData());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address?.id]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
|
||||
@@ -2669,10 +2679,12 @@ function BankCardModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Update form when bankCard prop changes
|
||||
if (isEditing && formData.iban !== bankCard.iban) {
|
||||
// Re-Init nur beim Wechsel zur anderen Karte – nicht bei jedem
|
||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||
useEffect(() => {
|
||||
setFormData(getInitialFormData());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bankCard?.id]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
|
||||
@@ -2819,10 +2831,12 @@ function DocumentModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Update form when document prop changes
|
||||
if (isEditing && formData.documentNumber !== document.documentNumber) {
|
||||
// Re-Init nur beim Wechsel zum anderen Ausweis – nicht bei jedem
|
||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||
useEffect(() => {
|
||||
setFormData(getInitialFormData());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [document?.id]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
|
||||
@@ -3029,10 +3043,12 @@ function MeterModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Update form when meter prop changes
|
||||
if (isEditing && formData.meterNumber !== meter.meterNumber) {
|
||||
// Re-Init nur beim Wechsel zum anderen Zähler – nicht bei jedem
|
||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||
useEffect(() => {
|
||||
setFormData(getInitialFormData());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meter?.id]);
|
||||
|
||||
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
||||
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
|
||||
@@ -3266,10 +3282,12 @@ function MeterReadingModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Update form when reading prop changes
|
||||
if (isEditing && formData.value !== reading.value.toString()) {
|
||||
// Re-Init nur beim Wechsel zum anderen Zählerstand – nicht bei
|
||||
// jedem Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||
useEffect(() => {
|
||||
setFormData(getInitialFormData());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reading?.id]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
|
||||
@@ -3726,6 +3744,8 @@ function StressfreiEmailModal({
|
||||
} | null>(null);
|
||||
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
const [showForwardsModal, setShowForwardsModal] = useState(false);
|
||||
const [additionalForwards, setAdditionalForwards] = useState<string[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!email;
|
||||
|
||||
@@ -3864,6 +3884,15 @@ function StressfreiEmailModal({
|
||||
setNotes(email.notes || '');
|
||||
setProviderStatus('idle');
|
||||
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
|
||||
if (hasProvider) {
|
||||
checkProviderStatus(emailLocalPart);
|
||||
@@ -3877,6 +3906,7 @@ function StressfreiEmailModal({
|
||||
setCreateMailbox(false);
|
||||
setProviderStatus('idle');
|
||||
setMailboxEnabled(false);
|
||||
setAdditionalForwards([]);
|
||||
}
|
||||
setProvisionError(null);
|
||||
// Zugangsdaten zurücksetzen
|
||||
@@ -3888,12 +3918,25 @@ function StressfreiEmailModal({
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
|
||||
// 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,
|
||||
notes: data.notes,
|
||||
provisionAtProvider: data.provision,
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
@@ -3902,6 +3945,7 @@ function StressfreiEmailModal({
|
||||
setNotes('');
|
||||
setProvisionAtProvider(false);
|
||||
setCreateMailbox(false);
|
||||
setAdditionalForwards([]);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -3916,6 +3960,9 @@ function StressfreiEmailModal({
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setProvisionError(err instanceof Error ? err.message : 'Fehler beim Speichern');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -4157,7 +4204,28 @@ function StressfreiEmailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div>
|
||||
{((isEditing && email && providerStatus === 'exists') ||
|
||||
(!isEditing && provisionAtProvider)) && (
|
||||
<Button
|
||||
type="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>
|
||||
@@ -4165,7 +4233,214 @@ function StressfreiEmailModal({
|
||||
{isPending ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -4560,6 +4835,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [sendDropdownFor, setSendDropdownFor] = useState<number | null>(null);
|
||||
const [jpgModalFor, setJpgModalFor] = useState<number | null>(null);
|
||||
|
||||
const { data: authData, isLoading } = useQuery({
|
||||
queryKey: ['authorizations', customerId],
|
||||
@@ -4730,6 +5006,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" />
|
||||
Vollmacht-PDF hochladen
|
||||
@@ -4740,11 +5017,31 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
||||
onChange={(e) => handleFileUpload(auth.representativeId, e)}
|
||||
/>
|
||||
</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>
|
||||
<JpgToPdfModal
|
||||
isOpen={jpgModalFor !== null}
|
||||
onClose={() => setJpgModalFor(null)}
|
||||
onComplete={(file) => {
|
||||
if (jpgModalFor !== null) {
|
||||
uploadMutation.mutate({ representativeId: jpgModalFor, file });
|
||||
}
|
||||
}}
|
||||
fileNameHint="vollmacht"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,6 +292,13 @@ function ProviderModal({
|
||||
portalUrl: '',
|
||||
usernameFieldName: '',
|
||||
passwordFieldName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
contactFax: '',
|
||||
contactAddress: '',
|
||||
cancellationEmail: '',
|
||||
cancellationFax: '',
|
||||
cancellationAddress: '',
|
||||
isActive: true,
|
||||
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
|
||||
// Admin sein eigenes Passwort mitsenden – Schutz gegen kompromittierten
|
||||
@@ -316,6 +323,13 @@ function ProviderModal({
|
||||
portalUrl: provider.portalUrl || '',
|
||||
usernameFieldName: provider.usernameFieldName || '',
|
||||
passwordFieldName: provider.passwordFieldName || '',
|
||||
contactEmail: provider.contactEmail || '',
|
||||
contactPhone: provider.contactPhone || '',
|
||||
contactFax: provider.contactFax || '',
|
||||
contactAddress: provider.contactAddress || '',
|
||||
cancellationEmail: provider.cancellationEmail || '',
|
||||
cancellationFax: provider.cancellationFax || '',
|
||||
cancellationAddress: provider.cancellationAddress || '',
|
||||
isActive: provider.isActive,
|
||||
currentPassword: '',
|
||||
});
|
||||
@@ -325,6 +339,13 @@ function ProviderModal({
|
||||
portalUrl: '',
|
||||
usernameFieldName: '',
|
||||
passwordFieldName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
contactFax: '',
|
||||
contactAddress: '',
|
||||
cancellationEmail: '',
|
||||
cancellationFax: '',
|
||||
cancellationAddress: '',
|
||||
isActive: true,
|
||||
currentPassword: '',
|
||||
});
|
||||
@@ -434,6 +455,75 @@ function ProviderModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Kontakt & Kündigung</strong> (optional)<br />
|
||||
Erreichbarkeit des Anbieters – wird im CRM zum Nachschlagen
|
||||
angezeigt, nicht an Portal-Kunden ausgespielt.
|
||||
</p>
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Kontakt</div>
|
||||
<Input
|
||||
label="Kontakt-Emailadresse"
|
||||
type="email"
|
||||
value={formData.contactEmail}
|
||||
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
|
||||
placeholder="z.B. service@anbieter.de"
|
||||
/>
|
||||
<Input
|
||||
label="Kontakt-Telefonnummer"
|
||||
value={formData.contactPhone}
|
||||
onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
|
||||
placeholder="z.B. +49 30 1234567"
|
||||
/>
|
||||
<Input
|
||||
label="Kontakt-Faxnummer"
|
||||
value={formData.contactFax}
|
||||
onChange={(e) => setFormData({ ...formData, contactFax: e.target.value })}
|
||||
placeholder="z.B. +49 30 7654321"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kontakt-Postadresse
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.contactAddress}
|
||||
onChange={(e) => setFormData({ ...formData, contactAddress: e.target.value })}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="z.B. Musteranbieter GmbH 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 && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -369,6 +369,8 @@ export interface StressfreiEmail {
|
||||
isActive: boolean;
|
||||
isProvisioned?: boolean;
|
||||
hasMailbox: boolean;
|
||||
/** Zusätzliche Weiterleitungs-E-Mails als JSON-Array-String. */
|
||||
additionalForwardingEmails?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -537,6 +539,14 @@ export const stressfreiEmailApi = {
|
||||
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
|
||||
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
|
||||
syncEmails: async (id: number, fullSync = false) => {
|
||||
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 });
|
||||
return res.data;
|
||||
},
|
||||
// E-Mails für Vertrag abrufen
|
||||
getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
|
||||
// E-Mails für Vertrag abrufen (optional pro Postfach gefiltert)
|
||||
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 });
|
||||
return res.data;
|
||||
},
|
||||
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails)
|
||||
getContractFolderCounts: async (contractId: number) => {
|
||||
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails, optional pro Postfach)
|
||||
getContractFolderCounts: async (contractId: number, accountId?: number) => {
|
||||
const res = await api.get<ApiResponse<{
|
||||
inbox: number;
|
||||
inboxUnread: number;
|
||||
sent: number;
|
||||
sentUnread: number;
|
||||
}>>(`/contracts/${contractId}/emails/folder-counts`);
|
||||
trash: number;
|
||||
trashUnread: number;
|
||||
}>>(`/contracts/${contractId}/emails/folder-counts`, { params: accountId ? { accountId } : undefined });
|
||||
return res.data;
|
||||
},
|
||||
// Mailbox-Konten eines Kunden abrufen
|
||||
@@ -659,14 +674,14 @@ export const cachedEmailApi = {
|
||||
return res.data;
|
||||
},
|
||||
// ==================== PAPIERKORB ====================
|
||||
// Papierkorb-E-Mails für Kunden abrufen
|
||||
getTrash: async (customerId: number) => {
|
||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`);
|
||||
// Papierkorb-E-Mails für Kunden abrufen (optional pro Postfach/Vertrag gefiltert)
|
||||
getTrash: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
|
||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`, { params: options });
|
||||
return res.data;
|
||||
},
|
||||
// Papierkorb-Anzahl für Kunden
|
||||
getTrashCount: async (customerId: number) => {
|
||||
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`);
|
||||
// Papierkorb-Anzahl für Kunden (gleiche Filter wie getTrash)
|
||||
getTrashCount: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
|
||||
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`, { params: options });
|
||||
return res.data;
|
||||
},
|
||||
// E-Mail aus Papierkorb wiederherstellen
|
||||
|
||||
@@ -396,6 +396,13 @@ export interface Provider {
|
||||
portalUrl?: string;
|
||||
usernameFieldName?: string;
|
||||
passwordFieldName?: string;
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
contactFax?: string;
|
||||
contactAddress?: string;
|
||||
cancellationEmail?: string;
|
||||
cancellationFax?: string;
|
||||
cancellationAddress?: string;
|
||||
isActive: boolean;
|
||||
tariffs?: Tariff[];
|
||||
_count?: {
|
||||
@@ -454,6 +461,9 @@ export interface Contract {
|
||||
tariffName?: string;
|
||||
customerNumberAtProvider?: string;
|
||||
contractNumberAtProvider?: string;
|
||||
orderNumberAtSalesPlatform?: string;
|
||||
customerNumberAtSalesPlatform?: string;
|
||||
contractNumberAtSalesPlatform?: string;
|
||||
priceFirst12Months?: string;
|
||||
priceFrom13Months?: string;
|
||||
priceAfter24Months?: string;
|
||||
@@ -561,6 +571,7 @@ export interface SimCard {
|
||||
puk?: string; // verschlüsselt
|
||||
isMultisim: boolean;
|
||||
isMain: boolean;
|
||||
isEsim: boolean;
|
||||
// Tatsächlicher Nutzer der Karte (z.B. Mitarbeiter bei Firmenvertrag,
|
||||
// Kind bei Eltern-Vertrag) – optional, kann vom Vertragsinhaber abweichen.
|
||||
cardUser?: string;
|
||||
|
||||
Reference in New Issue
Block a user