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>
This commit is contained in:
@@ -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,111 @@ 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,
|
||||
});
|
||||
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'PortalSettings',
|
||||
resourceId: customerId.toString(),
|
||||
label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail}`,
|
||||
customerId,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet` } 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user