diff --git a/backend/src/services/appSetting.service.ts b/backend/src/services/appSetting.service.ts index 0d2e2462..f1502fcb 100644 --- a/backend/src/services/appSetting.service.ts +++ b/backend/src/services/appSetting.service.ts @@ -28,6 +28,10 @@ export const ALLOWED_SETTING_KEYS: ReadonlySet = 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 { diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index e8ce903b..bc360271 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -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 { + 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) { diff --git a/frontend/src/pages/settings/PortalSettings.tsx b/frontend/src/pages/settings/PortalSettings.tsx index fb287e0b..4d7a33c4 100644 --- a/frontend/src/pages/settings/PortalSettings.tsx +++ b/frontend/src/pages/settings/PortalSettings.tsx @@ -1,9 +1,12 @@ import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; +import toast from 'react-hot-toast'; import { appSettingsApi } from '../../services/api'; import Card from '../../components/ui/Card'; -import { ArrowLeft, Globe, MessageSquare } from 'lucide-react'; +import Input from '../../components/ui/Input'; +import Button from '../../components/ui/Button'; +import { ArrowLeft, Globe, MessageSquare, Link as LinkIcon } from 'lucide-react'; export default function PortalSettings() { const queryClient = useQueryClient(); @@ -14,10 +17,12 @@ export default function PortalSettings() { }); const [customerSupportEnabled, setCustomerSupportEnabled] = useState(false); + const [portalLoginUrl, setPortalLoginUrl] = useState(''); useEffect(() => { if (settingsData?.data) { setCustomerSupportEnabled(settingsData.data.customerSupportTicketsEnabled === 'true'); + setPortalLoginUrl(settingsData.data.portalLoginUrl || ''); } }, [settingsData]); @@ -27,6 +32,9 @@ export default function PortalSettings() { queryClient.invalidateQueries({ queryKey: ['app-settings'] }); queryClient.invalidateQueries({ queryKey: ['app-settings-public'] }); }, + onError: (err: Error) => { + toast.error(err.message || 'Speichern fehlgeschlagen'); + }, }); const handleSupportToggle = (enabled: boolean) => { @@ -34,6 +42,18 @@ export default function PortalSettings() { updateMutation.mutate({ customerSupportTicketsEnabled: enabled ? 'true' : 'false' }); }; + const handleSavePortalLoginUrl = () => { + const trimmed = portalLoginUrl.trim().replace(/\/+$/, ''); + if (trimmed && !/^https?:\/\//i.test(trimmed)) { + toast.error('URL muss mit http:// oder https:// beginnen'); + return; + } + updateMutation.mutate( + { portalLoginUrl: trimmed }, + { onSuccess: () => toast.success('Portal-Login-URL gespeichert') }, + ); + }; + if (isLoading) { return (
@@ -54,6 +74,40 @@ export default function PortalSettings() {
+ +
+ +
+

+ Diese Basis-URL wird in Mails an Kunden eingesetzt, die ihre + Portal-Zugangsdaten zugeschickt bekommen, sowie bei + Passwort-Reset-Links. Ohne Eintrag wird auf den Server- + Standard zurückgegriffen, was lokal in localhost:5173 + endet – Kunden klicken dann auf einen toten Link. +

+
+ setPortalLoginUrl(e.target.value)} + placeholder="https://crm.deine-domain.de" + className="flex-1" + /> + +
+

+ Beispiel: https://crm.deine-domain.de → + Login-Link in der Mail wird https://crm.deine-domain.de/portal/login. + Trailing-Slash wird automatisch entfernt. +

+
+
+
+