Compare commits

..

5 Commits

Author SHA1 Message Date
duffyduck 8534be22d0 Einmalpasswort-Flow für Portal-Credentials
Wenn Admin "Zugangsdaten versenden" klickt, ist das Passwort jetzt ein
echtes Einmalpasswort: beim ersten erfolgreichen Portal-Login werden
Hash + Encrypted-Feld sofort genullt und der Kunde wird zwangsweise
auf eine "Neues Passwort vergeben"-Seite geleitet. Erst nach eigenem
Passwort kommt er ins Portal.

Schema:
- Customer.portalPasswordMustChange: Boolean @default(false)

Backend:
- sendPortalCredentials setzt Flag = true + erweitertes Mail-Template
  mit Einmalpasswort-Warnung
- customerLogin: bei Flag=true wird OTP konsumiert (Hash+Encrypted=null,
  portalLastLogin aktualisiert), Response enthält mustChangePassword=true
  in token-payload + user-objekt
- setCustomerPortalPassword (manuelles Setzen) räumt Flag wieder auf
- changeInitialPortalPassword: neue Service-Funktion + Endpoint
  POST /api/auth/change-initial-portal-password (authenticated, nur
  Portal-User), validiert Komplexität, setzt neuen Hash, löscht
  Encrypted, invalidiert Session via portalTokenInvalidatedAt

Frontend:
- User-Type erweitert um mustChangePassword
- AuthContext.customerLogin gibt User zurück (für sofortige Routing-
  Entscheidung)
- Login.tsx: redirect zu /change-initial-password wenn mustChangePassword
- ProtectedRoute: zwingt eingeloggte User mit Flag immer zur Change-Seite
- ChangeInitialPasswordGate: blockt User OHNE Flag vom Zugriff
- ChangeInitialPassword: eigene Seite mit Live-Komplexitäts-Hint,
  Passwort-Wiederholung, automatischer Logout + Redirect nach Erfolg

Live-verifiziert (10 Schritte):
- Setzen → Send → DB-Flag=true → OTP-Login gibt mustChange=true und
  consumed Hash → Re-Login mit OTP fehlschlägt → Change schwach=400,
  komplex=200 → neues Passwort funktioniert → Session invalidated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:48:13 +02:00
duffyduck f0c97cd46d todo.md: Passwort-Komplexität + Real-IP-Fix dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:26:52 +02:00
duffyduck 8a5ffbb563 Passwort-Komplexität + Portal-Credentials-UX
validatePasswordComplexity (12 Zeichen, Groß/Klein/Zahl/Sonderzeichen)
zentral in passwordGenerator.ts; jetzt erzwungen in setPortalPassword,
confirmPasswordReset, register, createUser, updateUser.

Neue Endpoints:
- POST /customers/:id/portal/password/generate → 16-Zeichen Zufallspasswort
- POST /customers/:id/portal/send-credentials → Versand per Mail
  (nur wenn portalEnabled aktiv)

Frontend (CustomerDetail): Generate-Button vor Setzen, Send-Credentials
nach gesetztem Passwort, Live-Komplexitäts-Hint (✓/○) während Eingabe,
alert() durch Toast-Notifications ersetzt.

Live-verifiziert: schwaches Passwort → 400 mit Detail-Fehler, komplexes
Passwort → 200, Generator liefert 16-Zeichen-Passwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:26:11 +02:00
duffyduck 6af1a4bbd4 fix(security): trust proxy = 1 bei HTTPS_ENABLED – echte Client-IP statt Proxy-IP
Wenn der TLS-Reverse-Proxy (Nginx Proxy Manager) auf einer SEPARATEN Box
läuft, kommt nicht von 127.0.0.1 → `trust proxy = 'loopback'` greift
nicht → req.ip bleibt die NPM-IP statt der echten Client-IP. Folgen:

- Rate-Limiter sieht alle Angriffe als von "einem" Client (= NPM)
- Security-Monitor loggt Proxy-IP statt Angreifer-IP (Beweis im
  Audit-Log: "ACCESS_DENIED ... 172.0.2.12" für alle Versuche)
- IDOR-Threshold-Detection (>5 in 5 min pro IP) triggert auf der NPM-IP
  und blockt damit alle legitimen User durch denselben Proxy

Fix: bei HTTPS_ENABLED=true `trust proxy = 1` (vertraue genau einem Hop –
den vorgelagerten TLS-Proxy). Bei HTTPS_ENABLED=false bleibt es bei
`loopback` (keine Proxy-Annahme bei direkter http://ip:port-Nutzung).

Voraussetzung für HTTPS_ENABLED=true: Backend ist nicht direkt aus
dem Internet erreichbar, sonst könnte ein direkter Connect ein
X-Forwarded-For faken und den Limiter umgehen. Bei NPM-Setup
gewährleistet durch Docker-Network + nicht-veröffentlichten
Backend-Port.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:24:21 +02:00
duffyduck 92d2e62e79 security: portalPasswordHash + Encrypted aus embedded customer in /contracts/* entfernen
Folgefix zum CRITICAL-IDOR auf Stressfrei-Sub-Routes: der separate
/customers/:id-Endpoint sanitizt seinen Output schon, aber GET /contracts/:id
embeddete weiterhin das volle Customer-Objekt inkl.
- portalPasswordHash (bcrypt-Hash des Portal-Login-Passworts)
- portalPasswordEncrypted (AES-256-GCM des Klartext-Passworts)
- portalPasswordResetToken (langlebiger 1-time-Token)

Zwei Lecks im contract.service:
- getContractById hatte `customer: true` ohne Sanitize
- createContract hatte dasselbe Muster

Beide jetzt mit sanitizeCustomerStrict() nach dem Load. Der Helper war schon
im utils/sanitize.ts vorhanden – wurde nur nicht aufgerufen.

Live-verifiziert: GET /api/contracts/1 → embedded customer enthält 30 saubere
Felder, KEIN portalPasswordHash/Encrypted/ResetToken mehr.

Weitere `customer: true`-Stellen geprüft und freigegeben:
- pdfTemplate.service.generateFilledPdf: nur internal, gibt PDF-Buffer zurück
- cachedEmail.controller.saveEmailAsPdf: nur internal für File-Ops
- getAllContracts: schon mit explizitem Select (5 sichere Felder)
- updateContract: kein customer-Include

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:06:01 +02:00
18 changed files with 760 additions and 35 deletions
+4
View File
@@ -172,6 +172,10 @@ model Customer {
portalPasswordResetExpiresAt DateTime?
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
portalTokenInvalidatedAt DateTime?
// Einmalpasswort: gesetzt durch "Zugangsdaten versenden"-Button. Beim ersten
// erfolgreichen Login wird der Hash sofort gelöscht (OTP verbraucht) und
// Frontend in Force-Change-Password-Flow geleitet.
portalPasswordMustChange Boolean @default(false)
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
lastBirthdayGreetingYear Int?
+52 -2
View File
@@ -3,6 +3,7 @@ import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { validatePasswordComplexity } from '../utils/passwordGenerator.js';
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
@@ -223,10 +224,11 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
return;
}
if (password.length < 6) {
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
@@ -355,6 +357,15 @@ export async function register(req: Request, res: Response): Promise<void> {
return;
}
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
const user = await authService.createUser({
email,
password,
@@ -374,3 +385,42 @@ export async function register(req: Request, res: Response): Promise<void> {
} as ApiResponse);
}
}
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
// loggt aus und schickt zurück zum Login.
export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user?.isCustomerPortal || !req.user?.customerId) {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Login',
} as ApiResponse);
return;
}
const { newPassword } = req.body || {};
if (!newPassword || typeof newPassword !== 'string') {
res.status(400).json({
success: false,
error: 'Neues Passwort erforderlich',
} as ApiResponse);
return;
}
const complexity = validatePasswordComplexity(newPassword);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
await authService.changeInitialPortalPassword(req.user.customerId, newPassword);
clearRefreshCookie(res);
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
} as ApiResponse);
}
}
+105 -2
View File
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js';
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import {
sanitizeCustomer,
@@ -957,13 +958,115 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
}
}
/**
* Generiert ein zufälliges, komplexes Passwort (16 Zeichen, gemischt).
* Setzt es NICHT direkt — wird im Frontend in den Setzen-Button-Flow gefüttert.
* Damit hat der Admin Wahlfreiheit (Generieren → ggf. anpassen → speichern).
*/
export async function generatePortalPassword(req: Request, res: Response): Promise<void> {
try {
const password = generateSecurePassword({ length: 16 });
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
} as ApiResponse);
}
}
/**
* Verschickt die Portal-Zugangsdaten per E-Mail an die hinterlegte
* `email` (bevorzugt) oder fallback auf `portalEmail` des Kunden. Das
* Passwort wird aus dem `portalPasswordEncrypted`-Feld entschlüsselt
* (= das aktuell aktive Klartext-Passwort, das auch in der UI angezeigt wird).
*/
export async function sendPortalCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
id: true, firstName: true, lastName: true, salutation: true, companyName: true,
email: true, portalEmail: true, portalEnabled: true,
portalPasswordEncrypted: true, portalPasswordHash: true,
},
});
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
}
if (!customer.portalEnabled) {
res.status(400).json({
success: false,
error: 'Portal ist für diesen Kunden nicht aktiviert',
} as ApiResponse);
return;
}
if (!customer.portalPasswordHash) {
res.status(400).json({
success: false,
error: 'Es ist noch kein Portal-Passwort gesetzt',
} as ApiResponse);
return;
}
const targetEmail = customer.email || customer.portalEmail;
if (!targetEmail) {
res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
} as ApiResponse);
return;
}
const loginEmail = customer.portalEmail || customer.email!;
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
if (!plaintextPassword) {
res.status(400).json({
success: false,
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld bitte neu setzen)',
} as ApiResponse);
return;
}
await authService.sendPortalCredentialsEmail({
to: targetEmail,
customer,
loginEmail,
password: plaintextPassword,
});
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
// der Kunde sich ein eigenes setzen.
await authService.markPortalPasswordForChange(customerId);
await logChange({
req,
action: 'UPDATE',
resourceType: 'PortalSettings',
resourceId: customerId.toString(),
label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail} (Einmalpasswort)`,
customerId,
});
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
} as ApiResponse);
}
}
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
try {
const { password } = req.body;
if (!password || password.length < 6) {
// Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
+23 -1
View File
@@ -4,6 +4,7 @@ import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
import { validatePasswordComplexity } from '../utils/passwordGenerator.js';
// Users
export async function getUsers(req: Request, res: Response): Promise<void> {
@@ -51,7 +52,18 @@ export async function getUser(req: Request, res: Response): Promise<void> {
export async function createUser(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const user = await userService.createUser(pickUserCreate(req.body) as any);
const data = pickUserCreate(req.body) as any;
if (data?.password) {
const c = validatePasswordComplexity(data.password);
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
}
const user = await userService.createUser(data);
await logChange({
req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(),
@@ -71,6 +83,16 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
const userId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body);
if ((data as any)?.password) {
const c = validatePasswordComplexity((data as any).password);
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
}
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
+18 -9
View File
@@ -84,16 +84,25 @@ if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
const app = express();
const PORT = process.env.PORT || 3001;
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
// auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen,
// während gleichzeitig der lokale Reverse-Proxy die echte Client-IP liefern darf.
// Trust-Proxy-Konfiguration für `req.ip` und `X-Forwarded-For`.
//
// WICHTIG für Production: Backend nur auf 127.0.0.1 lauschen lassen
// (LISTEN_ADDR=127.0.0.1) sonst kann ein direkter Connect von außen
// trotzdem als loopback gelten, falls das Routing das so durchstellt.
app.set('trust proxy', 'loopback');
// Zwei Szenarien:
// 1) **HTTPS_ENABLED=true** (Produktion mit vorgelagertem TLS-Proxy auf
// EIGENER Box, z.B. Nginx Proxy Manager): `trust proxy = 1` vertraut
// genau einem Hop → req.ip = echter Client (nicht der Proxy).
// Voraussetzung: Backend ist NICHT direkt aus dem Internet erreichbar,
// sonst könnte ein Direkt-Connect X-Forwarded-For faken und den
// Rate-Limiter / Security-Monitor umgehen. Bei NPM-Setup ist das
// durch das Docker-Network + nicht-veröffentlichten Backend-Port
// gewährleistet.
// 2) **HTTPS_ENABLED=false** (lokales Dev oder direkter http://ip:port-
// Zugriff): `loopback` reicht kein vertrauenswürdiger Hop davor.
//
// Vor dem Fix stand das auf `'loopback'` was im Produktiv-NPM-Setup
// IMMER die Proxy-IP statt der Client-IP lieferte → Rate-Limit und
// IDOR-Threshold-Detection sahen alle Angriffe als von „einem" Client.
const trustProxyValue = process.env.HTTPS_ENABLED === 'true' ? 1 : 'loopback';
app.set('trust proxy', trustProxyValue);
// ==================== SECURITY MIDDLEWARE ====================
+3
View File
@@ -16,4 +16,7 @@ router.post('/register', authenticate, requirePermission('users:create'), authCo
router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset);
router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset);
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
export default router;
+2
View File
@@ -37,6 +37,8 @@ router.get('/:customerId/portal', authenticate, requirePermission('customers:upd
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword);
router.post('/:customerId/portal/password/generate', authenticate, requirePermission('customers:update'), customerController.generatePortalPassword);
router.post('/:customerId/portal/send-credentials', authenticate, requirePermission('customers:update'), customerController.sendPortalCredentials);
// Representatives (Vertreter)
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
+133 -7
View File
@@ -180,14 +180,30 @@ export async function customerLogin(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten');
}
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Einmalpasswort-Check: wurde es per "Zugangsdaten versenden" verschickt?
// Falls ja, jetzt sofort verbrauchen Hash + Encrypted nullen, damit
// weder Re-Login noch Klartext-Abruf möglich ist. Customer landet im
// Force-Change-Password-Flow.
const mustChangePassword = customer.portalPasswordMustChange === true;
if (mustChangePassword) {
await prisma.customer.update({
where: { id: customer.id },
data: {
portalPasswordHash: null,
portalPasswordEncrypted: null,
portalLastLogin: new Date(),
},
});
} else {
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
data: { portalLastLogin: new Date() },
});
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
data: { portalLastLogin: new Date() },
});
}
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
const representedCustomerIds = customer.representingFor.map(
@@ -214,6 +230,7 @@ export async function customerLogin(email: string, password: string) {
return {
accessToken,
refreshToken,
mustChangePassword,
user: {
id: customer.id,
email: customer.portalEmail,
@@ -222,6 +239,7 @@ export async function customerLogin(email: string, password: string) {
permissions: customerPermissions,
customerId: customer.id,
isCustomerPortal: true,
mustChangePassword,
representedCustomers: customer.representingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
@@ -331,17 +349,45 @@ export async function setCustomerPortalPassword(customerId: number, password: st
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
// Manuelles Setzen ist KEIN Einmalpasswort → Flag immer zurücksetzen,
// falls vorher ein OTP gesetzt war.
await prisma.customer.update({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: encryptedPassword,
portalPasswordMustChange: false,
},
});
console.log('[SetPortalPassword] Passwort gespeichert');
}
// Vom Endkunden selbst gesetztes Initial-Passwort nach OTP-Login.
// Speichert neuen Hash, löscht das verbrauchte Encrypted-Feld (Klartext-
// Speicherung soll bei OFF self-service nicht zurückkommen) und invalidiert
// sofort alle bestehenden Sessions, damit Login mit dem neuen Passwort
// gefordert wird.
export async function changeInitialPortalPassword(customerId: number, newPassword: string) {
const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.customer.update({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: null,
portalPasswordMustChange: false,
portalTokenInvalidatedAt: new Date(),
},
});
}
export async function markPortalPasswordForChange(customerId: number) {
await prisma.customer.update({
where: { id: customerId },
data: { portalPasswordMustChange: true },
});
}
// Kundenportal-Passwort im Klartext abrufen
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
const customer = await prisma.customer.findUnique({
@@ -513,6 +559,86 @@ function getPublicUrl(): string {
return process.env.PUBLIC_URL || 'http://localhost:5173';
}
/**
* Portal-Zugangsdaten per E-Mail an den Kunden versenden. Nur durch Admin-
* UI ausgelöst nie automatisch , weil das Klartext-Passwort im Mail-
* Body steht. Login-URL zeigt auf das `/portal/login`-Frontend-Route.
*/
export async function sendPortalCredentialsEmail(params: {
to: string;
customer: { firstName: string | null; lastName: string | null; salutation: string | null; companyName: string | null };
loginEmail: string;
password: string;
}): Promise<void> {
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
throw new Error('Kein System-E-Mail-Konto konfiguriert (Einstellungen → E-Mail-Provider)');
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const loginUrl = `${getPublicUrl()}/portal/login`;
const name = params.customer.companyName?.trim()
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|| 'Kunde';
// HTML-Escape Customer-Namen können theoretisch Sonderzeichen enthalten,
// die wir nicht ungefiltert in die Mail rendern wollen.
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Ihre Zugangsdaten zum Kundenportal</h2>
<p>Hallo ${esc(name)},</p>
<p>anbei Ihre Zugangsdaten zum Kundenportal:</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr><td style="padding: 6px 12px; color: #6b7280;">Login-URL:</td>
<td style="padding: 6px 12px;"><a href="${loginUrl}">${esc(loginUrl)}</a></td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280;">E-Mail:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.loginEmail)}</td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280;">Passwort:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.password)}</td></tr>
</table>
<p style="color: #b91c1c; font-size: 14px; font-weight: 600;">
⚠️ Dieses Passwort ist ein <u>Einmalpasswort</u>.
</p>
<p style="color: #6b7280; font-size: 14px;">
Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben.
Danach ist dieses Passwort hier <strong>nicht mehr gültig</strong> falls Sie den
Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die
Passwort-vergessen-Funktion.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Diese Nachricht enthält sensible Zugangsdaten bitte sicher verwahren oder nach
dem Login löschen.
</p>
</div>
`;
await sendEmail(
credentials,
systemEmail.emailAddress,
{
to: params.to,
subject: 'Ihre Zugangsdaten zum Kundenportal',
html,
},
{
context: 'portal-credentials',
triggeredBy: 'admin-action',
},
);
}
/**
* Passwort-Reset-Link per Email senden.
* Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
+22 -1
View File
@@ -2,6 +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';
export interface ContractFilters {
customerId?: number;
@@ -154,7 +155,18 @@ export async function getContractById(id: number, decryptPassword = false) {
if (!contract) return null;
// Decrypt password if requested and exists
// SECURITY: Embedded Customer-Objekt sanitizen, sonst leaken
// portalPasswordHash + portalPasswordEncrypted + Reset-Token in jede
// contract.customer-Response. Der direkte `/customers/:id`-Endpoint hat
// den Schutz schon; hier wäre er ohne Sanitize bypassbar.
if (contract.customer) {
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
contract.customer as Record<string, unknown>,
);
}
// Decrypt password if requested and exists (Contract-Anbieter-Passwort,
// nicht zu verwechseln mit Customer-Portal-Passwort)
if (decryptPassword && contract.portalPasswordEncrypted) {
try {
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
@@ -385,6 +397,15 @@ export async function createContract(data: ContractCreateData) {
},
});
// Embedded Customer-Objekt sanitizen (siehe getContractById derselbe
// Schutz; createContract gibt den frisch erstellten Vertrag inkl. Customer
// zurück, und der darf keine Passwort-Hashes/-Encryptions leaken).
if (contract.customer) {
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
contract.customer as Record<string, unknown>,
);
}
return contract;
}
+37
View File
@@ -88,6 +88,43 @@ export function generateSimplePassword(length = 12): string {
});
}
// ==================== PASSWORD COMPLEXITY VALIDATION ====================
/**
* Mindestanforderungen für vom User vergebene Passwörter.
* Generator-Output (generateSecurePassword) erfüllt diese standardmäßig.
*/
export interface PasswordComplexityResult {
ok: boolean;
errors: string[];
}
export function validatePasswordComplexity(pw: unknown): PasswordComplexityResult {
const errors: string[] = [];
if (typeof pw !== 'string') {
return { ok: false, errors: ['Passwort fehlt oder ist kein Text'] };
}
if (pw.length < 12) errors.push('mindestens 12 Zeichen');
if (!/[a-z]/.test(pw)) errors.push('mindestens einen Kleinbuchstaben');
if (!/[A-Z]/.test(pw)) errors.push('mindestens einen Großbuchstaben');
if (!/[0-9]/.test(pw)) errors.push('mindestens eine Ziffer');
// Sonderzeichen-Set bewusst breit auch Leerzeichen + Unicode-Punktuation
// zulassen, damit gängige Passwort-Manager-Outputs nicht abgelehnt werden.
if (!/[^A-Za-z0-9]/.test(pw)) errors.push('mindestens ein Sonderzeichen');
return { ok: errors.length === 0, errors };
}
/**
* Wirft mit sprechender Fehlermeldung, wenn das Passwort die Komplexität
* nicht erfüllt. Für Aufruf direkt im Controller, der die Exception fängt.
*/
export function assertPasswordComplexity(pw: unknown): void {
const r = validatePasswordComplexity(pw);
if (!r.ok) {
throw new Error('Passwort erfüllt Mindestanforderungen nicht: ' + r.errors.join(', '));
}
}
// Kryptografisch sichere Zufallszahl
function getRandomInt(max: number): number {
const bytes = randomBytes(4);
+82
View File
@@ -97,6 +97,88 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔐 Einmalpasswort-Flow für Portal-Credentials**
- **Intention**: Wenn wir Zugangsdaten per E-Mail an den Kunden
schicken, kennen wir das Passwort als Admin das ist solange OK,
bis er sich einmal eingeloggt hat. Danach soll er gezwungen sein,
sich ein eigenes zu vergeben, und das per-Mail-Passwort ist tot.
- **Datenmodell**: neues Feld `portalPasswordMustChange: Boolean
@default(false)` am Customer.
- **Flow**:
1. Admin klickt **Zugangsdaten versenden** → Flag wird gesetzt,
Mail-Template weist explizit auf „Einmalpasswort" hin.
2. Kunde loggt sich mit dem OTP ein → Backend gibt
`mustChangePassword: true` im Login-Response zurück UND
**konsumiert das OTP sofort**: setzt `portalPasswordHash =
null` und `portalPasswordEncrypted = null`. Ein zweiter
Login mit demselben Passwort schlägt fehl (401).
3. Frontend (`ProtectedRoute`) sieht `mustChangePassword=true`
und leitet auf `/change-initial-password` um egal welche
Route der Kunde aufrufen will, er kommt nicht weiter.
4. Auf der Seite gibt er ein neues, komplexes Passwort vor
(Live-Hint mit ✓/○, dieselben Regeln wie Backend).
5. `POST /api/auth/change-initial-portal-password` speichert
neuen Hash, **löscht das Encrypted-Feld** (Admin kann das
eigene Passwort des Kunden nicht mehr im Klartext lesen),
setzt `portalTokenInvalidatedAt = now()` und
`portalPasswordMustChange = false`.
6. Frontend loggt aus, leitet zu `/login?changed=1`,
Erfolgs-Banner: „Passwort wurde geändert. Bitte mit dem
neuen Passwort anmelden."
- **Edge case**: Tab geschlossen ohne Setzen → Kunde ist
ausgesperrt (OTP weg, eigenes Passwort nicht gesetzt). Lösung
aus seiner Sicht: Passwort-vergessen-Funktion oder Admin
versendet neue Zugangsdaten.
- **Edge case**: Admin macht zwischendurch nochmal manuelles
„Setzen" → `mustChange` wird automatisch wieder `false`. So
kann ein versehentlich versendetes OTP problemlos durch ein
direkt-gesetztes Passwort ersetzt werden.
- **Live-verifiziert (10 Schritte)**: Setzen → Send → Flag in
DB=true → Login mit OTP gibt mustChange=true zurück + Hash
in DB ist null → Re-Login mit OTP → 401 → Change-Endpoint
schwach → 400 → komplex → 200 → Login mit neuem PW →
mustChange=false + tokenInvalidatedAt gesetzt.
- [x] **🔐 Passwort-Komplexität + Portal-Credentials-UX**
- **Problem**: Bisher reichten 6 Zeichen für gesetzte Passwörter
(Portal-Login, User-Reset, Registrierung, User-Anlage). Das hat
der Pentest bemängelt, und es entsprach auch nicht dem, was wir
selbst von Endkunden erwarten würden.
- **Lösung**:
* `validatePasswordComplexity()` in `passwordGenerator.ts`:
mind. 12 Zeichen + Großbuchstaben + Kleinbuchstaben + Ziffer
+ Sonderzeichen, mit detaillierter Fehlerliste auf deutsch.
* Erzwungen in **5 Endpoints**: `setPortalPassword`,
`confirmPasswordReset`, `register`, `createUser`, `updateUser`.
- **Neue UX im Kunden-Portal-Block (CustomerDetail)**:
* **Generate-Button**: erzeugt 16-Zeichen-Zufallspasswort, das
garantiert allen Komplexitätsregeln entspricht, und füllt
das Eingabefeld direkt aus.
* **Send-Credentials-Button**: schickt Login-URL + Username +
Klartext-Passwort an die Kunden-E-Mail. Funktioniert nur,
wenn "Portal aktiviert" tatsächlich aktiviert ist.
* **Live-Komplexitäts-Hint** beim Tippen: ✓/○-Liste zeigt
sofort, welche Regeln noch fehlen.
* `alert()`-Boxen durch Toast-Notifications ersetzt.
- **Live-verifiziert**: schwaches Passwort `hallo123` → HTTP 400
mit Fehlerliste, komplexes Passwort `Hallo123!Test` → HTTP 200,
Generator-Endpoint liefert 16-Zeichen-Passwort, Send-Credentials
versendet Mail nur bei portalEnabled=true.
- [x] **🌐 Real-IP hinter Nginx-Proxy-Manager**
- **Problem**: Rate-Limiter und Security-Monitor haben statt der
echten Client-IP nur die NPM-IP (`172.0.2.12`) geloggt. Damit
wären alle Threshold-basierten Blockings nutzlos ein Brute-
Force von 100 verschiedenen Clients wäre für uns 1 Quelle.
- **Root Cause**: `app.set('trust proxy', 'loopback')` das passt
nur, wenn der Proxy auf 127.0.0.1 läuft. NPM läuft aber auf
einem anderen Host, also wurde X-Forwarded-For ignoriert.
- **Fix**: trust-proxy abhängig von `HTTPS_ENABLED`:
`HTTPS_ENABLED=true` → `1` (genau 1 Hop, der NPM), sonst
`loopback` (Direkt-Verbindungen lokal).
- **Live-verifiziert**: req.ip zeigt jetzt die echte Browser-IP
statt der NPM-IP, Threshold-Events triggern korrekt.
- [x] **🚨 KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Pentest-Fund)**
- **Realer Angriff erfolgreich durchgespielt**: Portal-User konnte über
`/api/stressfrei-emails/{id}/credentials` die kompletten Klartext-
+27 -1
View File
@@ -8,6 +8,7 @@ import Layout from './components/layout/Layout';
import Login from './pages/Login';
import PasswordResetRequest from './pages/PasswordResetRequest';
import PasswordResetConfirm from './pages/PasswordResetConfirm';
import ChangeInitialPassword from './pages/ChangeInitialPassword';
import Dashboard from './pages/Dashboard';
import CustomerList from './pages/customers/CustomerList';
import CustomerDetail from './pages/customers/CustomerDetail';
@@ -49,7 +50,7 @@ import PortalProfile from './pages/portal/PortalProfile';
import PortalMeters from './pages/portal/PortalMeters';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return (
@@ -63,9 +64,31 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <Navigate to="/login" replace />;
}
// Force-Change-Password-Flow: nach Einmalpasswort-Login muss der Kunde
// zwingend ein eigenes Passwort vergeben, bevor er irgendwohin sonst
// navigieren darf.
if (user?.mustChangePassword) {
return <Navigate to="/change-initial-password" replace />;
}
return <>{children}</>;
}
function ChangeInitialPasswordGate() {
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Laden...</div>
</div>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
// Wer nicht im Einmalpasswort-Flow ist, hat hier nichts zu suchen.
if (!user?.mustChangePassword) return <Navigate to="/" replace />;
return <ChangeInitialPassword />;
}
function PortalConsentGate({ children }: { children: React.ReactNode }) {
const { isCustomerPortal } = useAuth();
@@ -153,6 +176,9 @@ function App() {
<Route path="/password-reset/request" element={<PasswordResetRequest />} />
<Route path="/password-reset" element={<PasswordResetConfirm />} />
{/* Einmalpasswort → eigenes Passwort vergeben (eingeloggt, eigene Gate-Logik) */}
<Route path="/change-initial-password" element={<ChangeInitialPasswordGate />} />
<Route
path="/"
element={
+4 -4
View File
@@ -8,7 +8,7 @@ interface AuthContextType {
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
customerLogin: (email: string, password: string) => Promise<void>;
customerLogin: (email: string, password: string) => Promise<User>;
logout: () => Promise<void>;
hasPermission: (permission: string) => boolean;
isCustomer: boolean;
@@ -72,14 +72,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const customerLogin = async (email: string, password: string) => {
const customerLogin = async (email: string, password: string): Promise<User> => {
const res = await authApi.customerLogin(email, password);
if (res.success && res.data) {
setAccessToken(res.data.token);
setUser(res.data.user);
} else {
throw new Error(res.error || 'Login fehlgeschlagen');
return res.data.user;
}
throw new Error(res.error || 'Login fehlgeschlagen');
};
const logout = async () => {
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { authApi } from '../services/api';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
import Card from '../components/ui/Card';
const MIN_LENGTH = 12;
function checkComplexity(pw: string) {
return {
length: pw.length >= MIN_LENGTH,
upper: /[A-Z]/.test(pw),
lower: /[a-z]/.test(pw),
digit: /[0-9]/.test(pw),
special: /[^A-Za-z0-9]/.test(pw),
};
}
function ComplexityHint({ pw }: { pw: string }) {
const c = checkComplexity(pw);
const items: [boolean, string][] = [
[c.length, `Mindestens ${MIN_LENGTH} Zeichen`],
[c.upper, 'Großbuchstabe'],
[c.lower, 'Kleinbuchstabe'],
[c.digit, 'Ziffer'],
[c.special, 'Sonderzeichen'],
];
return (
<ul className="text-xs mt-2 space-y-0.5">
{items.map(([ok, label]) => (
<li key={label} className={ok ? 'text-green-700' : 'text-gray-500'}>
{ok ? '✓' : '○'} {label}
</li>
))}
</ul>
);
}
export default function ChangeInitialPassword() {
const { logout, user } = useAuth();
const navigate = useNavigate();
const [newPassword, setNewPassword] = useState('');
const [repeat, setRepeat] = useState('');
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
const c = checkComplexity(newPassword);
const meetsComplexity = c.length && c.upper && c.lower && c.digit && c.special;
const matches = newPassword.length > 0 && newPassword === repeat;
const canSubmit = meetsComplexity && matches && !isSaving;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!canSubmit) return;
setIsSaving(true);
try {
const res = await authApi.changeInitialPortalPassword(newPassword);
if (!res.success) {
throw new Error(res.error || 'Passwort konnte nicht geändert werden');
}
await logout();
navigate('/login?changed=1', { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Setzen des Passworts');
setIsSaving(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 p-4">
<Card className="w-full max-w-md">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">Neues Passwort vergeben</h1>
<p className="text-gray-600 mt-2 text-sm">
Hallo {user?.firstName || 'Kunde'}, Sie haben sich mit einem Einmalpasswort
angemeldet. Bitte vergeben Sie jetzt Ihr eigenes Passwort. Danach werden Sie
ausgeloggt und können sich mit dem neuen Passwort anmelden.
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
label="Neues Passwort"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
/>
{newPassword.length > 0 && <ComplexityHint pw={newPassword} />}
</div>
<div>
<Input
label="Passwort wiederholen"
type="password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
required
autoComplete="new-password"
/>
{repeat.length > 0 && !matches && (
<p className="text-xs text-red-600 mt-1">Passwörter stimmen nicht überein</p>
)}
</div>
<Button type="submit" className="w-full" disabled={!canSubmit}>
{isSaving ? 'Speichere...' : 'Passwort setzen und ausloggen'}
</Button>
</form>
</Card>
</div>
);
}
+16 -3
View File
@@ -1,11 +1,13 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
import Card from '../components/ui/Card';
export default function Login() {
const [searchParams] = useSearchParams();
const passwordChanged = searchParams.get('changed') === '1';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -28,8 +30,13 @@ export default function Login() {
}
try {
await customerLogin(email, password);
navigate('/');
const portalUser = await customerLogin(email, password);
// Einmalpasswort-Login → erzwungenes Passwort-Setzen vor Dashboard
if (portalUser?.mustChangePassword) {
navigate('/change-initial-password', { replace: true });
} else {
navigate('/');
}
} catch {
// Beide fehlgeschlagen
setError('Ungültige Anmeldedaten');
@@ -45,6 +52,12 @@ export default function Login() {
<p className="text-gray-600 mt-2">Melden Sie sich an</p>
</div>
{passwordChanged && !error && (
<div className="mb-4 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
Passwort wurde geändert. Bitte mit dem neuen Passwort anmelden.
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
@@ -1796,6 +1796,38 @@ function ContractsTab({
);
}
// Passwort-Komplexität muss zur Backend-Regel in
// backend/src/utils/passwordGenerator.ts:validatePasswordComplexity passen.
function passwordMeetsComplexity(pw: string): boolean {
return (
pw.length >= 12 &&
/[a-z]/.test(pw) &&
/[A-Z]/.test(pw) &&
/[0-9]/.test(pw) &&
/[^A-Za-z0-9]/.test(pw)
);
}
// Live-Hinweis welche Komplexitäts-Anforderungen noch fehlen
function PasswordComplexityHint({ password }: { password: string }) {
const checks = [
{ ok: password.length >= 12, label: '≥ 12 Zeichen' },
{ ok: /[a-z]/.test(password), label: 'Kleinbuchstabe' },
{ ok: /[A-Z]/.test(password), label: 'Großbuchstabe' },
{ ok: /[0-9]/.test(password), label: 'Ziffer' },
{ ok: /[^A-Za-z0-9]/.test(password), label: 'Sonderzeichen' },
];
return (
<ul className="mt-2 text-xs space-y-0.5">
{checks.map((c) => (
<li key={c.label} className={c.ok ? 'text-green-600' : 'text-gray-500'}>
{c.ok ? '✓' : '○'} {c.label}
</li>
))}
</ul>
);
}
// Gespeichertes Passwort anzeigen
function StoredPasswordDisplay({ customerId }: { customerId: number }) {
const [showStoredPassword, setShowStoredPassword] = useState(false);
@@ -1898,10 +1930,35 @@ function PortalTab({
onSuccess: () => {
setNewPassword('');
queryClient.invalidateQueries({ queryKey: ['customer-portal', customerId] });
alert('Passwort wurde gesetzt');
toast.success('Passwort wurde gesetzt');
},
onError: (error: Error) => {
alert(error.message);
toast.error(error.message);
},
});
// Passwort generieren (16 Zeichen, komplex) ins Input-Feld füllen
const generatePasswordMutation = useMutation({
mutationFn: () => customerApi.generatePortalPassword(customerId),
onSuccess: (res) => {
const generated = res.data?.password || '';
setNewPassword(generated);
setShowPassword(true);
toast.success('Komplexes Passwort generiert jetzt „Setzen" klicken.');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
// Zugangsdaten per E-Mail an den Kunden senden
const sendCredentialsMutation = useMutation({
mutationFn: () => customerApi.sendPortalCredentials(customerId),
onSuccess: (res) => {
toast.success(res.message || 'Zugangsdaten gesendet');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
@@ -2003,7 +2060,7 @@ function PortalTab({
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 6 Zeichen"
placeholder="Mind. 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen"
disabled={!canEdit}
/>
<button
@@ -2014,15 +2071,48 @@ function PortalTab({
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button
variant="secondary"
onClick={() => generatePasswordMutation.mutate()}
disabled={!canEdit || generatePasswordMutation.isPending}
title='Komplexes Passwort generieren (16 Zeichen, Groß/Klein/Zahl/Sonderzeichen). Wird ins Feld geschrieben danach "Setzen" klicken.'
>
{generatePasswordMutation.isPending ? 'Generieren...' : 'Generieren'}
</Button>
<Button
onClick={() => setPasswordMutation.mutate(newPassword)}
disabled={!canEdit || newPassword.length < 6 || setPasswordMutation.isPending}
disabled={!canEdit || !passwordMeetsComplexity(newPassword) || setPasswordMutation.isPending}
title={passwordMeetsComplexity(newPassword) ? 'Passwort speichern' : 'Komplexität nicht erfüllt'}
>
{setPasswordMutation.isPending ? 'Speichern...' : 'Setzen'}
</Button>
</div>
{/* Komplexitäts-Hinweise: zeigt live welche Anforderungen erfüllt sind */}
{newPassword.length > 0 && !passwordMeetsComplexity(newPassword) && (
<PasswordComplexityHint password={newPassword} />
)}
{portal?.hasPassword && (
<StoredPasswordDisplay customerId={customerId} />
<>
<StoredPasswordDisplay customerId={customerId} />
<div className="mt-3">
<Button
variant="secondary"
size="sm"
onClick={() => {
if (confirm(
'Aktuelles Portal-Passwort und Login-URL per E-Mail an den Kunden senden?\n\n' +
'Hinweis: Das Passwort wird im Klartext in der E-Mail enthalten sein.'
)) {
sendCredentialsMutation.mutate();
}
}}
disabled={!canEdit || sendCredentialsMutation.isPending}
title="Login-URL + E-Mail + Passwort an die Kunden-E-Mail versenden"
>
{sendCredentialsMutation.isPending ? 'Sende...' : 'Zugangsdaten per E-Mail versenden'}
</Button>
</div>
</>
)}
</div>
)}
+12
View File
@@ -127,6 +127,10 @@ export const authApi = {
const res = await api.post<ApiResponse<void>>('/auth/logout');
return res.data;
},
changeInitialPortalPassword: async (newPassword: string) => {
const res = await api.post<ApiResponse<void>>('/auth/change-initial-portal-password', { newPassword });
return res.data;
},
};
// Customers
@@ -168,6 +172,14 @@ export const customerApi = {
const res = await api.get<ApiResponse<{ password: string | null }>>(`/customers/${customerId}/portal/password`);
return res.data;
},
generatePortalPassword: async (customerId: number) => {
const res = await api.post<ApiResponse<{ password: string }>>(`/customers/${customerId}/portal/password/generate`);
return res.data;
},
sendPortalCredentials: async (customerId: number) => {
const res = await api.post<ApiResponse<void>>(`/customers/${customerId}/portal/send-credentials`);
return res.data;
},
// Vertreter-Verwaltung
getRepresentatives: async (customerId: number) => {
const res = await api.get<ApiResponse<CustomerRepresentative[]>>(`/customers/${customerId}/representatives`);
+1
View File
@@ -7,6 +7,7 @@ export interface User {
customerId?: number;
roles?: Role[];
isCustomerPortal?: boolean;
mustChangePassword?: boolean;
representedCustomers?: CustomerSummary[];
whatsappNumber?: string;
telegramUsername?: string;