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 * as authService from '../services/auth.service.js';
|
|||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
import { validatePasswordComplexity } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
|
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
|
||||||
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
|
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
|
||||||
@@ -223,10 +224,11 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
const complexity = validatePasswordComplexity(password);
|
||||||
|
if (!complexity.ok) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
|
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,6 +357,15 @@ export async function register(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
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({
|
const user = await authService.createUser({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as customerService from '../services/customer.service.js';
|
import * as customerService from '../services/customer.service.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import {
|
import {
|
||||||
sanitizeCustomer,
|
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> {
|
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { password } = req.body;
|
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({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Passwort muss mindestens 6 Zeichen lang sein',
|
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as userService from '../services/user.service.js';
|
|||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
|
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
|
||||||
|
import { validatePasswordComplexity } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
export async function getUsers(req: Request, res: Response): Promise<void> {
|
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> {
|
export async function createUser(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// 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({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
@@ -71,6 +83,16 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
const data = pickUserUpdate(req.body);
|
const data = pickUserUpdate(req.body);
|
||||||
|
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 /
|
// Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess /
|
||||||
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
|
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ router.get('/:customerId/portal', authenticate, requirePermission('customers:upd
|
|||||||
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
|
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
|
||||||
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
|
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
|
||||||
router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword);
|
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)
|
// Representatives (Vertreter)
|
||||||
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
|
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
|
||||||
|
|||||||
@@ -513,6 +513,80 @@ function getPublicUrl(): string {
|
|||||||
return process.env.PUBLIC_URL || 'http://localhost:5173';
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
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: #6b7280; font-size: 14px;">
|
||||||
|
Bitte ändern Sie Ihr Passwort nach dem ersten Login (im Portal unter „Mein Konto").
|
||||||
|
</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.
|
* Passwort-Reset-Link per Email senden.
|
||||||
* Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
|
* Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
|
||||||
|
|||||||
@@ -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
|
// Kryptografisch sichere Zufallszahl
|
||||||
function getRandomInt(max: number): number {
|
function getRandomInt(max: number): number {
|
||||||
const bytes = randomBytes(4);
|
const bytes = randomBytes(4);
|
||||||
|
|||||||
@@ -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
|
// Gespeichertes Passwort anzeigen
|
||||||
function StoredPasswordDisplay({ customerId }: { customerId: number }) {
|
function StoredPasswordDisplay({ customerId }: { customerId: number }) {
|
||||||
const [showStoredPassword, setShowStoredPassword] = useState(false);
|
const [showStoredPassword, setShowStoredPassword] = useState(false);
|
||||||
@@ -1898,10 +1930,35 @@ function PortalTab({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
queryClient.invalidateQueries({ queryKey: ['customer-portal', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['customer-portal', customerId] });
|
||||||
alert('Passwort wurde gesetzt');
|
toast.success('Passwort wurde gesetzt');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
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'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="Mindestens 6 Zeichen"
|
placeholder="Mind. 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -2014,15 +2071,48 @@ function PortalTab({
|
|||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<Button
|
||||||
onClick={() => setPasswordMutation.mutate(newPassword)}
|
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'}
|
{setPasswordMutation.isPending ? 'Speichern...' : 'Setzen'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Komplexitäts-Hinweise: zeigt live welche Anforderungen erfüllt sind */}
|
||||||
|
{newPassword.length > 0 && !passwordMeetsComplexity(newPassword) && (
|
||||||
|
<PasswordComplexityHint password={newPassword} />
|
||||||
|
)}
|
||||||
{portal?.hasPassword && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -168,6 +168,14 @@ export const customerApi = {
|
|||||||
const res = await api.get<ApiResponse<{ password: string | null }>>(`/customers/${customerId}/portal/password`);
|
const res = await api.get<ApiResponse<{ password: string | null }>>(`/customers/${customerId}/portal/password`);
|
||||||
return res.data;
|
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
|
// Vertreter-Verwaltung
|
||||||
getRepresentatives: async (customerId: number) => {
|
getRepresentatives: async (customerId: number) => {
|
||||||
const res = await api.get<ApiResponse<CustomerRepresentative[]>>(`/customers/${customerId}/representatives`);
|
const res = await api.get<ApiResponse<CustomerRepresentative[]>>(`/customers/${customerId}/representatives`);
|
||||||
|
|||||||
Reference in New Issue
Block a user