Portal-Login-URL als App-Setting (statt nur PUBLIC_URL-Env)

Bugfix: in der "Zugangsdaten versenden"-Mail stand bisher
http://localhost:5173/portal/login als Login-Link, wenn die
PUBLIC_URL-Env nicht gesetzt war – Kunden klickten auf einen
toten Link.

Neue Einstellung "portalLoginUrl" unter Einstellungen → Kundenportal.
Wenn gepflegt, wird sie als Basis-URL für:
  - Portal-Zugangsdaten-Mail (Login-Link)
  - Passwort-Reset-Link
verwendet. Reihenfolge: AppSetting → PUBLIC_URL-Env → localhost-Default.

Backend: getPublicUrl() jetzt async, liest erst aus AppSetting,
fällt auf Env zurück. Trailing-Slash-Bereinigung im Backend
(damit Links nicht doppelt-Slash bekommen) und im Frontend
(damit der gespeicherte Wert sauber ist).

Frontend: neue Card "Portal-Login-URL" oberhalb der Support-
Anfragen-Card in PortalSettings.tsx. Input + Save-Button +
http(s)://-Schema-Validierung + Erfolgs-Toast.

Live-verifiziert: PUT setzt 'https://crm.beispiel.de', Backend-
getPublicUrl liefert 'https://crm.beispiel.de/portal/login'
statt localhost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 12:49:55 +02:00
parent ee4ca9df07
commit 2d4e4cdcc7
3 changed files with 79 additions and 5 deletions
@@ -28,6 +28,10 @@ export const ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
'monitoringLastDigestAt',
'companyName',
'defaultEmailDomain',
// Basis-URL für an Kunden verschickte Portal-Links (Login + Passwort-Reset).
// Vorher kam aus `PUBLIC_URL`-Env, default localhost Mails enthielten
// dann unklickbare Links. Wird in Settings → Kundenportal gepflegt.
'portalLoginUrl',
]);
export function isAllowedSettingKey(key: string): boolean {
+20 -4
View File
@@ -7,6 +7,7 @@ import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import { getAuthorizedCustomerIds } from './authorization.service.js';
import * as appSettingService from './appSetting.service.js';
// Token-Lifetimes
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
@@ -581,8 +582,23 @@ function generateResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
function getPublicUrl(): string {
return process.env.PUBLIC_URL || 'http://localhost:5173';
/**
* Liefert die Basis-URL für an Kunden verschickte Links (Portal-Login,
* Passwort-Reset). Reihenfolge:
* 1. `portalLoginUrl` aus AppSettings (vom Admin in Settings → Kundenportal
* gepflegt). Wenn HTTPS-Domain hier eingetragen, wird die in Mails
* verwendet, nicht der Localhost-Default.
* 2. `PUBLIC_URL`-Env (für Setups ohne Admin-UI-Konfiguration).
* 3. Fallback `http://localhost:5173` (Dev-Default).
* Hat Trailing-Slash-Bereinigung, sonst kommen Links wie
* `https://crm.de//portal/login` zustande.
*/
async function getPublicUrl(): Promise<string> {
const fromSettings = await appSettingService.getSetting('portalLoginUrl');
const raw = (fromSettings && fromSettings.trim())
|| process.env.PUBLIC_URL
|| 'http://localhost:5173';
return raw.replace(/\/+$/, '');
}
/**
@@ -610,7 +626,7 @@ export async function sendPortalCredentialsEmail(params: {
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const loginUrl = `${getPublicUrl()}/portal/login`;
const loginUrl = `${await getPublicUrl()}/portal/login`;
const name = params.customer.companyName?.trim()
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|| 'Kunde';
@@ -709,7 +725,7 @@ export async function requestPasswordReset(email: string, userType: 'admin' | 'p
if (!recipient) return;
// Reset-Link + Email senden
const resetUrl = `${getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
const resetUrl = `${await getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {