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:
@@ -28,6 +28,10 @@ export const ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
|
|||||||
'monitoringLastDigestAt',
|
'monitoringLastDigestAt',
|
||||||
'companyName',
|
'companyName',
|
||||||
'defaultEmailDomain',
|
'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 {
|
export function isAllowedSettingKey(key: string): boolean {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { encrypt, decrypt } from '../utils/encryption.js';
|
|||||||
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
||||||
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
||||||
import { getAuthorizedCustomerIds } from './authorization.service.js';
|
import { getAuthorizedCustomerIds } from './authorization.service.js';
|
||||||
|
import * as appSettingService from './appSetting.service.js';
|
||||||
|
|
||||||
// Token-Lifetimes
|
// Token-Lifetimes
|
||||||
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
|
// - 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');
|
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,
|
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loginUrl = `${getPublicUrl()}/portal/login`;
|
const loginUrl = `${await getPublicUrl()}/portal/login`;
|
||||||
const name = params.customer.companyName?.trim()
|
const name = params.customer.companyName?.trim()
|
||||||
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|
||||||
|| 'Kunde';
|
|| 'Kunde';
|
||||||
@@ -709,7 +725,7 @@ export async function requestPasswordReset(email: string, userType: 'admin' | 'p
|
|||||||
if (!recipient) return;
|
if (!recipient) return;
|
||||||
|
|
||||||
// Reset-Link + Email senden
|
// 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();
|
const systemEmail = await getSystemEmailCredentials();
|
||||||
|
|
||||||
if (!systemEmail) {
|
if (!systemEmail) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { appSettingsApi } from '../../services/api';
|
import { appSettingsApi } from '../../services/api';
|
||||||
import Card from '../../components/ui/Card';
|
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() {
|
export default function PortalSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -14,10 +17,12 @@ export default function PortalSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [customerSupportEnabled, setCustomerSupportEnabled] = useState(false);
|
const [customerSupportEnabled, setCustomerSupportEnabled] = useState(false);
|
||||||
|
const [portalLoginUrl, setPortalLoginUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsData?.data) {
|
if (settingsData?.data) {
|
||||||
setCustomerSupportEnabled(settingsData.data.customerSupportTicketsEnabled === 'true');
|
setCustomerSupportEnabled(settingsData.data.customerSupportTicketsEnabled === 'true');
|
||||||
|
setPortalLoginUrl(settingsData.data.portalLoginUrl || '');
|
||||||
}
|
}
|
||||||
}, [settingsData]);
|
}, [settingsData]);
|
||||||
|
|
||||||
@@ -27,6 +32,9 @@ export default function PortalSettings() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['app-settings-public'] });
|
queryClient.invalidateQueries({ queryKey: ['app-settings-public'] });
|
||||||
},
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Speichern fehlgeschlagen');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSupportToggle = (enabled: boolean) => {
|
const handleSupportToggle = (enabled: boolean) => {
|
||||||
@@ -34,6 +42,18 @@ export default function PortalSettings() {
|
|||||||
updateMutation.mutate({ customerSupportTicketsEnabled: enabled ? 'true' : 'false' });
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -54,6 +74,40 @@ export default function PortalSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card title="Portal-Login-URL" className="mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<LinkIcon className="w-5 h-5 text-gray-500 mt-2" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
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 <code>localhost:5173</code>
|
||||||
|
endet – Kunden klicken dann auf einen toten Link.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={portalLoginUrl}
|
||||||
|
onChange={(e) => setPortalLoginUrl(e.target.value)}
|
||||||
|
placeholder="https://crm.deine-domain.de"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSavePortalLoginUrl}
|
||||||
|
disabled={updateMutation.isPending || portalLoginUrl === (settingsData?.data?.portalLoginUrl || '')}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Beispiel: <code>https://crm.deine-domain.de</code> →
|
||||||
|
Login-Link in der Mail wird <code>https://crm.deine-domain.de/portal/login</code>.
|
||||||
|
Trailing-Slash wird automatisch entfernt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Support-Anfragen">
|
<Card title="Support-Anfragen">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user