From 51387b0af12a7d55b8ef6489132f1659f1114321 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 20 Apr 2026 14:06:42 +0200 Subject: [PATCH] feat(auth): add email-based password reset with MFA + session invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /auth/forgot-password and /auth/reset-password endpoints plus two new client pages. When SMTP is configured the user receives a branded, i18n-aware reset email; when it isn't the reset link is logged to the server console in a clearly-fenced block so self-hosters can relay it manually. Security properties: - 256-bit cryptographically-random tokens, only SHA-256 hashes stored in DB - 60 min expiry, single-use, prior unconsumed tokens auto-invalidated - Enumeration-safe: /forgot-password always responds {ok:true} with a minimum latency pad so timing doesn't leak account existence - Per-IP rate limit (3/15min on forgot, 5/15min on reset) + per-email throttle - If the user has MFA enabled, a valid TOTP or backup code is required at reset-complete time — a compromised mailbox alone cannot take over a 2FA-protected account - New users.password_version column + JWT "pv" claim: bumping it on reset invalidates every live session immediately - Full audit-log coverage (user.password_reset_request/_success/_fail) - Forgot-page shows a visible hint when SMTP is unconfigured Migration 115 adds users.password_version and password_reset_tokens (user_id, token_hash UNIQUE, expires_at, consumed_at, created_ip). --- client/src/App.tsx | 9 +- client/src/api/client.ts | 2 + client/src/i18n/translations/ar.ts | 22 +++ client/src/i18n/translations/br.ts | 22 +++ client/src/i18n/translations/cs.ts | 22 +++ client/src/i18n/translations/de.ts | 22 +++ client/src/i18n/translations/en.ts | 22 +++ client/src/i18n/translations/es.ts | 22 +++ client/src/i18n/translations/fr.ts | 22 +++ client/src/i18n/translations/hu.ts | 22 +++ client/src/i18n/translations/id.ts | 22 +++ client/src/i18n/translations/it.ts | 22 +++ client/src/i18n/translations/nl.ts | 22 +++ client/src/i18n/translations/pl.ts | 22 +++ client/src/i18n/translations/ru.ts | 22 +++ client/src/i18n/translations/zh.ts | 22 +++ client/src/i18n/translations/zhTw.ts | 22 +++ client/src/pages/ForgotPasswordPage.tsx | 151 +++++++++++++++++ client/src/pages/LoginPage.tsx | 11 ++ client/src/pages/ResetPasswordPage.tsx | 205 +++++++++++++++++++++++ server/src/db/migrations.ts | 20 +++ server/src/db/schema.ts | 13 ++ server/src/middleware/auth.ts | 30 ++-- server/src/routes/auth.ts | 78 +++++++++ server/src/services/authService.ts | 211 +++++++++++++++++++++++- server/src/services/notifications.ts | 97 +++++++++++ 26 files changed, 1140 insertions(+), 17 deletions(-) create mode 100644 client/src/pages/ForgotPasswordPage.tsx create mode 100644 client/src/pages/ResetPasswordPage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 941492c2..5e0f5ed2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,6 +4,8 @@ import { useAuthStore } from './store/authStore' import { useSettingsStore } from './store/settingsStore' import { useAddonStore } from './store/addonStore' import LoginPage from './pages/LoginPage' +import ForgotPasswordPage from './pages/ForgotPasswordPage' +import ResetPasswordPage from './pages/ResetPasswordPage' import DashboardPage from './pages/DashboardPage' import TripPlannerPage from './pages/TripPlannerPage' import FilesPage from './pages/FilesPage' @@ -197,7 +199,10 @@ export default function App() { applyDark(mode === true || mode === 'dark') }, [settings.dark_mode, isSharedPage]) - const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register') + const isAuthPage = location.pathname.startsWith('/login') + || location.pathname.startsWith('/register') + || location.pathname.startsWith('/forgot-password') + || location.pathname.startsWith('/reset-password') return ( @@ -210,6 +215,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} } /> apiClient.get('/auth/validate-keys').then(r => r.data), travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data), changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data), + forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }), + resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }), deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data), demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data), mcpTokens: { diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 2cafd31e..19f0a57a 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -463,6 +463,28 @@ const ar: Record = { 'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC', 'login.usernameRequired': 'اسم المستخدم مطلوب', 'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل', + 'login.forgotPassword': 'نسيت كلمة المرور؟', + 'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور', + 'login.forgotPasswordBody': 'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.', + 'login.forgotPasswordSubmit': 'إرسال الرابط', + 'login.forgotPasswordSentTitle': 'تحقق من بريدك', + 'login.forgotPasswordSentBody': 'إذا كان هناك حساب مرتبط بهذا البريد، فإن الرابط في الطريق. تنتهي صلاحيته خلال 60 دقيقة.', + 'login.forgotPasswordSmtpHintOff': 'ملاحظة: لم يقم المسؤول بتكوين SMTP، لذا سيتم كتابة رابط إعادة التعيين في وحدة تحكم الخادم بدلاً من إرساله عبر البريد الإلكتروني.', + 'login.backToLogin': 'العودة إلى تسجيل الدخول', + 'login.newPassword': 'كلمة المرور الجديدة', + 'login.confirmPassword': 'تأكيد كلمة المرور الجديدة', + 'login.passwordsDontMatch': 'كلمتا المرور غير متطابقتين', + 'login.mfaCode': 'رمز 2FA', + 'login.resetPasswordTitle': 'ضبط كلمة مرور جديدة', + 'login.resetPasswordBody': 'اختر كلمة مرور قوية لم تستخدمها هنا من قبل. 8 أحرف على الأقل.', + 'login.resetPasswordMfaBody': 'أدخل رمز 2FA أو رمز النسخ الاحتياطي لإتمام إعادة التعيين.', + 'login.resetPasswordSubmit': 'إعادة تعيين كلمة المرور', + 'login.resetPasswordVerify': 'تحقق وأعد التعيين', + 'login.resetPasswordSuccessTitle': 'تم تحديث كلمة المرور', + 'login.resetPasswordSuccessBody': 'يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.', + 'login.resetPasswordInvalidLink': 'رابط إعادة تعيين غير صالح', + 'login.resetPasswordInvalidLinkBody': 'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.', + 'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.', // Register 'register.passwordMismatch': 'كلمتا المرور غير متطابقتين', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0375068c..4219f9d6 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -458,6 +458,28 @@ const br: Record = { 'login.oidcFailed': 'Falha no login OIDC', 'login.usernameRequired': 'Nome de usuário é obrigatório', 'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres', + 'login.forgotPassword': 'Esqueceu a senha?', + 'login.forgotPasswordTitle': 'Redefinir sua senha', + 'login.forgotPasswordBody': 'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.', + 'login.forgotPasswordSubmit': 'Enviar link', + 'login.forgotPasswordSentTitle': 'Verifique seu e-mail', + 'login.forgotPasswordSentBody': 'Se houver uma conta para esse e-mail, o link está a caminho. Ele expira em 60 minutos.', + 'login.forgotPasswordSmtpHintOff': 'Observação: seu administrador não configurou SMTP, então o link de redefinição será gravado no console do servidor em vez de ser enviado por e-mail.', + 'login.backToLogin': 'Voltar ao login', + 'login.newPassword': 'Nova senha', + 'login.confirmPassword': 'Confirmar nova senha', + 'login.passwordsDontMatch': 'As senhas não coincidem', + 'login.mfaCode': 'Código 2FA', + 'login.resetPasswordTitle': 'Definir uma nova senha', + 'login.resetPasswordBody': 'Escolha uma senha forte que você ainda não tenha usado aqui. Mínimo de 8 caracteres.', + 'login.resetPasswordMfaBody': 'Digite seu código 2FA ou um código de backup para concluir a redefinição.', + 'login.resetPasswordSubmit': 'Redefinir senha', + 'login.resetPasswordVerify': 'Verificar e redefinir', + 'login.resetPasswordSuccessTitle': 'Senha atualizada', + 'login.resetPasswordSuccessBody': 'Agora você pode entrar com sua nova senha.', + 'login.resetPasswordInvalidLink': 'Link de redefinição inválido', + 'login.resetPasswordInvalidLinkBody': 'Este link está ausente ou corrompido. Solicite um novo para continuar.', + 'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.', // Register 'register.passwordMismatch': 'As senhas não coincidem', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 2e65e451..24086d27 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -458,6 +458,28 @@ const cs: Record = { 'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo', 'login.usernameRequired': 'Uživatelské jméno je povinné', 'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků', + 'login.forgotPassword': 'Zapomenuté heslo?', + 'login.forgotPasswordTitle': 'Obnovení hesla', + 'login.forgotPasswordBody': 'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.', + 'login.forgotPasswordSubmit': 'Odeslat odkaz', + 'login.forgotPasswordSentTitle': 'Zkontroluj e-mail', + 'login.forgotPasswordSentBody': 'Pokud k tomuto e-mailu existuje účet, odkaz je na cestě. Platnost vyprší za 60 minut.', + 'login.forgotPasswordSmtpHintOff': 'Upozornění: správce nemá nakonfigurovaný SMTP, takže se odkaz pro obnovení zapíše do konzole serveru místo odeslání e-mailem.', + 'login.backToLogin': 'Zpět na přihlášení', + 'login.newPassword': 'Nové heslo', + 'login.confirmPassword': 'Potvrď nové heslo', + 'login.passwordsDontMatch': 'Hesla se neshodují', + 'login.mfaCode': 'Kód 2FA', + 'login.resetPasswordTitle': 'Nastavit nové heslo', + 'login.resetPasswordBody': 'Vyber silné heslo, které jsi tu ještě nepoužil. Minimálně 8 znaků.', + 'login.resetPasswordMfaBody': 'Zadej 2FA kód nebo záložní kód pro dokončení obnovení.', + 'login.resetPasswordSubmit': 'Obnovit heslo', + 'login.resetPasswordVerify': 'Ověřit a obnovit', + 'login.resetPasswordSuccessTitle': 'Heslo aktualizováno', + 'login.resetPasswordSuccessBody': 'Nyní se můžeš přihlásit novým heslem.', + 'login.resetPasswordInvalidLink': 'Neplatný odkaz', + 'login.resetPasswordInvalidLinkBody': 'Odkaz chybí nebo je poškozený. Pro pokračování si vyžádej nový.', + 'login.resetPasswordFailed': 'Obnovení se nezdařilo. Odkaz mohl vypršet.', // Registrace (Register) 'register.passwordMismatch': 'Hesla se neshodují', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 7319c2b4..6b79672d 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -463,6 +463,28 @@ const de: Record = { 'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen', 'login.usernameRequired': 'Benutzername ist erforderlich', 'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein', + 'login.forgotPassword': 'Passwort vergessen?', + 'login.forgotPasswordTitle': 'Passwort zurücksetzen', + 'login.forgotPasswordBody': 'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.', + 'login.forgotPasswordSubmit': 'Reset-Link senden', + 'login.forgotPasswordSentTitle': 'Prüfe deine E-Mails', + 'login.forgotPasswordSentBody': 'Falls ein Konto mit dieser Adresse existiert, ist ein Reset-Link unterwegs. Er läuft in 60 Minuten ab.', + 'login.forgotPasswordSmtpHintOff': 'Hinweis: Der Administrator hat SMTP nicht konfiguriert. Der Reset-Link wird statt per E-Mail in die Server-Konsole geschrieben.', + 'login.backToLogin': 'Zurück zur Anmeldung', + 'login.newPassword': 'Neues Passwort', + 'login.confirmPassword': 'Neues Passwort bestätigen', + 'login.passwordsDontMatch': 'Passwörter stimmen nicht überein', + 'login.mfaCode': '2FA-Code', + 'login.resetPasswordTitle': 'Neues Passwort festlegen', + 'login.resetPasswordBody': 'Wähle ein starkes Passwort, das du hier noch nicht verwendet hast. Mindestens 8 Zeichen.', + 'login.resetPasswordMfaBody': 'Gib deinen 2FA-Code oder einen Backup-Code ein, um den Reset abzuschließen.', + 'login.resetPasswordSubmit': 'Passwort zurücksetzen', + 'login.resetPasswordVerify': 'Prüfen & zurücksetzen', + 'login.resetPasswordSuccessTitle': 'Passwort aktualisiert', + 'login.resetPasswordSuccessBody': 'Du kannst dich jetzt mit deinem neuen Passwort anmelden.', + 'login.resetPasswordInvalidLink': 'Ungültiger Reset-Link', + 'login.resetPasswordInvalidLinkBody': 'Dieser Link fehlt oder ist beschädigt. Fordere einen neuen an, um fortzufahren.', + 'login.resetPasswordFailed': 'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.', // Register 'register.passwordMismatch': 'Passwörter stimmen nicht überein', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 106f8b06..be712440 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -522,6 +522,28 @@ const en: Record = { 'login.oidcFailed': 'OIDC login failed', 'login.usernameRequired': 'Username is required', 'login.passwordMinLength': 'Password must be at least 8 characters', + 'login.forgotPassword': 'Forgot password?', + 'login.forgotPasswordTitle': 'Reset your password', + 'login.forgotPasswordBody': 'Enter the email address you signed up with. If an account exists, we\'ll send a reset link.', + 'login.forgotPasswordSubmit': 'Send reset link', + 'login.forgotPasswordSentTitle': 'Check your email', + 'login.forgotPasswordSentBody': 'If an account exists for that email, a reset link is on its way. It expires in 60 minutes.', + 'login.forgotPasswordSmtpHintOff': 'Heads up: your administrator hasn\'t configured SMTP, so the reset link will be written to the server console instead of being emailed.', + 'login.backToLogin': 'Back to sign in', + 'login.newPassword': 'New password', + 'login.confirmPassword': 'Confirm new password', + 'login.passwordsDontMatch': 'Passwords don\'t match', + 'login.mfaCode': '2FA code', + 'login.resetPasswordTitle': 'Set a new password', + 'login.resetPasswordBody': 'Pick a strong password you haven’t used here before. Minimum 8 characters.', + 'login.resetPasswordMfaBody': 'Enter your 2FA code or a backup code to complete the reset.', + 'login.resetPasswordSubmit': 'Reset password', + 'login.resetPasswordVerify': 'Verify & reset', + 'login.resetPasswordSuccessTitle': 'Password updated', + 'login.resetPasswordSuccessBody': 'You can now sign in with your new password.', + 'login.resetPasswordInvalidLink': 'Invalid reset link', + 'login.resetPasswordInvalidLinkBody': 'This link is missing or broken. Request a new one to continue.', + 'login.resetPasswordFailed': 'Reset failed. The link may have expired.', // Register 'register.passwordMismatch': 'Passwords do not match', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index c615d44c..36904a6f 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -450,6 +450,28 @@ const es: Record = { 'login.oidcFailed': 'Error de inicio de sesión OIDC', 'login.usernameRequired': 'El nombre de usuario es obligatorio', 'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres', + 'login.forgotPassword': '¿Olvidaste tu contraseña?', + 'login.forgotPasswordTitle': 'Restablecer tu contraseña', + 'login.forgotPasswordBody': 'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.', + 'login.forgotPasswordSubmit': 'Enviar enlace', + 'login.forgotPasswordSentTitle': 'Revisa tu correo', + 'login.forgotPasswordSentBody': 'Si existe una cuenta con ese correo, el enlace de restablecimiento está en camino. Caduca en 60 minutos.', + 'login.forgotPasswordSmtpHintOff': 'Nota: tu administrador no ha configurado SMTP, así que el enlace de restablecimiento se escribirá en la consola del servidor en lugar de enviarse por correo.', + 'login.backToLogin': 'Volver al inicio de sesión', + 'login.newPassword': 'Nueva contraseña', + 'login.confirmPassword': 'Confirmar nueva contraseña', + 'login.passwordsDontMatch': 'Las contraseñas no coinciden', + 'login.mfaCode': 'Código 2FA', + 'login.resetPasswordTitle': 'Establecer una nueva contraseña', + 'login.resetPasswordBody': 'Elige una contraseña segura que no hayas usado aquí antes. Mínimo 8 caracteres.', + 'login.resetPasswordMfaBody': 'Introduce tu código 2FA o un código de respaldo para completar el restablecimiento.', + 'login.resetPasswordSubmit': 'Restablecer contraseña', + 'login.resetPasswordVerify': 'Verificar y restablecer', + 'login.resetPasswordSuccessTitle': 'Contraseña actualizada', + 'login.resetPasswordSuccessBody': 'Ya puedes iniciar sesión con tu nueva contraseña.', + 'login.resetPasswordInvalidLink': 'Enlace de restablecimiento no válido', + 'login.resetPasswordInvalidLinkBody': 'Este enlace falta o está roto. Solicita uno nuevo para continuar.', + 'login.resetPasswordFailed': 'Restablecimiento fallido. El enlace puede haber caducado.', 'login.oidc.tokenFailed': 'La autenticación falló.', 'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.', 'login.demoFailed': 'Falló el acceso a la demo', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 230a7512..fda73908 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -451,6 +451,28 @@ const fr: Record = { 'login.oidcFailed': 'Échec de connexion OIDC', 'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire', 'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères', + 'login.forgotPassword': 'Mot de passe oublié ?', + 'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe', + 'login.forgotPasswordBody': 'Entrez l\'adresse e-mail associée à votre compte. Si un compte existe, nous enverrons un lien de réinitialisation.', + 'login.forgotPasswordSubmit': 'Envoyer le lien', + 'login.forgotPasswordSentTitle': 'Vérifiez vos e-mails', + 'login.forgotPasswordSentBody': 'Si un compte existe pour cette adresse, un lien de réinitialisation est en route. Il expire dans 60 minutes.', + 'login.forgotPasswordSmtpHintOff': 'Remarque : votre administrateur n\'a pas configuré SMTP. Le lien de réinitialisation sera écrit dans la console du serveur au lieu d\'être envoyé par e-mail.', + 'login.backToLogin': 'Retour à la connexion', + 'login.newPassword': 'Nouveau mot de passe', + 'login.confirmPassword': 'Confirmer le nouveau mot de passe', + 'login.passwordsDontMatch': 'Les mots de passe ne correspondent pas', + 'login.mfaCode': 'Code 2FA', + 'login.resetPasswordTitle': 'Définir un nouveau mot de passe', + 'login.resetPasswordBody': 'Choisissez un mot de passe fort que vous n\'avez pas encore utilisé ici. 8 caractères minimum.', + 'login.resetPasswordMfaBody': 'Entrez votre code 2FA ou un code de secours pour finaliser la réinitialisation.', + 'login.resetPasswordSubmit': 'Réinitialiser', + 'login.resetPasswordVerify': 'Vérifier et réinitialiser', + 'login.resetPasswordSuccessTitle': 'Mot de passe mis à jour', + 'login.resetPasswordSuccessBody': 'Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.', + 'login.resetPasswordInvalidLink': 'Lien de réinitialisation invalide', + 'login.resetPasswordInvalidLinkBody': 'Ce lien est manquant ou invalide. Demandez-en un nouveau pour continuer.', + 'login.resetPasswordFailed': 'Échec de la réinitialisation. Le lien a peut-être expiré.', 'login.oidc.tokenFailed': 'L\'authentification a échoué.', 'login.oidc.invalidState': 'Session invalide. Veuillez réessayer.', 'login.demoFailed': 'Échec de la connexion démo', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 6500783b..21e9ce52 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -458,6 +458,28 @@ const hu: Record = { 'login.oidcFailed': 'OIDC bejelentkezés sikertelen', 'login.usernameRequired': 'A felhasználónév kötelező', 'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', + 'login.forgotPassword': 'Elfelejtetted a jelszavad?', + 'login.forgotPasswordTitle': 'Jelszó visszaállítása', + 'login.forgotPasswordBody': 'Írd be a regisztrációnál használt e-mail-címet. Ha létezik fiók, küldünk egy visszaállítási linket.', + 'login.forgotPasswordSubmit': 'Link küldése', + 'login.forgotPasswordSentTitle': 'Nézd meg az e-mailjeidet', + 'login.forgotPasswordSentBody': 'Ha létezik fiók ehhez az e-mailhez, a visszaállítási link úton van. 60 perc után lejár.', + 'login.forgotPasswordSmtpHintOff': 'Megjegyzés: a rendszergazda nem konfigurálta az SMTP-t, ezért a visszaállítási link e-mail helyett a szerverkonzolba kerül.', + 'login.backToLogin': 'Vissza a bejelentkezéshez', + 'login.newPassword': 'Új jelszó', + 'login.confirmPassword': 'Új jelszó megerősítése', + 'login.passwordsDontMatch': 'A jelszavak nem egyeznek', + 'login.mfaCode': '2FA-kód', + 'login.resetPasswordTitle': 'Új jelszó beállítása', + 'login.resetPasswordBody': 'Válassz erős jelszót, amit itt még nem használtál. Minimum 8 karakter.', + 'login.resetPasswordMfaBody': 'Add meg a 2FA-kódodat vagy egy tartalék kódot a visszaállítás befejezéséhez.', + 'login.resetPasswordSubmit': 'Jelszó visszaállítása', + 'login.resetPasswordVerify': 'Ellenőrzés és visszaállítás', + 'login.resetPasswordSuccessTitle': 'Jelszó frissítve', + 'login.resetPasswordSuccessBody': 'Mostantól bejelentkezhetsz az új jelszavaddal.', + 'login.resetPasswordInvalidLink': 'Érvénytelen visszaállítási link', + 'login.resetPasswordInvalidLinkBody': 'A link hiányzik vagy sérült. A folytatáshoz kérj egy újat.', + 'login.resetPasswordFailed': 'A visszaállítás nem sikerült. A link lehet, hogy lejárt.', // Regisztráció 'register.passwordMismatch': 'A jelszavak nem egyeznek', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 20417ac6..ab52258d 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -520,6 +520,28 @@ const id: Record = { 'login.oidcFailed': 'Login OIDC gagal', 'login.usernameRequired': 'Nama pengguna wajib diisi', 'login.passwordMinLength': 'Kata sandi minimal 8 karakter', + 'login.forgotPassword': 'Lupa kata sandi?', + 'login.forgotPasswordTitle': 'Setel ulang kata sandi', + 'login.forgotPasswordBody': 'Masukkan alamat email akunmu. Jika akun ada, kami akan mengirim tautan reset.', + 'login.forgotPasswordSubmit': 'Kirim tautan', + 'login.forgotPasswordSentTitle': 'Periksa email kamu', + 'login.forgotPasswordSentBody': 'Jika ada akun dengan email tersebut, tautannya sedang dikirim. Berlaku 60 menit.', + 'login.forgotPasswordSmtpHintOff': 'Catatan: administrator belum mengonfigurasi SMTP, jadi tautan reset akan ditulis ke konsol server alih-alih dikirim lewat email.', + 'login.backToLogin': 'Kembali ke login', + 'login.newPassword': 'Kata sandi baru', + 'login.confirmPassword': 'Konfirmasi kata sandi baru', + 'login.passwordsDontMatch': 'Kata sandi tidak cocok', + 'login.mfaCode': 'Kode 2FA', + 'login.resetPasswordTitle': 'Tetapkan kata sandi baru', + 'login.resetPasswordBody': 'Pilih kata sandi kuat yang belum pernah kamu pakai di sini. Minimal 8 karakter.', + 'login.resetPasswordMfaBody': 'Masukkan kode 2FA atau kode cadangan untuk menyelesaikan reset.', + 'login.resetPasswordSubmit': 'Setel ulang kata sandi', + 'login.resetPasswordVerify': 'Verifikasi & setel ulang', + 'login.resetPasswordSuccessTitle': 'Kata sandi diperbarui', + 'login.resetPasswordSuccessBody': 'Sekarang kamu bisa login dengan kata sandi baru.', + 'login.resetPasswordInvalidLink': 'Tautan tidak valid', + 'login.resetPasswordInvalidLinkBody': 'Tautan hilang atau rusak. Minta tautan baru untuk melanjutkan.', + 'login.resetPasswordFailed': 'Reset gagal. Tautan mungkin sudah kedaluwarsa.', // Register 'register.passwordMismatch': 'Kata sandi tidak cocok', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index dd3480a8..8c5c95c4 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -458,6 +458,28 @@ const it: Record = { 'login.oidcFailed': 'Accesso OIDC non riuscito', 'login.usernameRequired': 'Il nome utente è obbligatorio', 'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri', + 'login.forgotPassword': 'Password dimenticata?', + 'login.forgotPasswordTitle': 'Reimposta la password', + 'login.forgotPasswordBody': 'Inserisci l’indirizzo email del tuo account. Se esiste un account, invieremo un link per reimpostarla.', + 'login.forgotPasswordSubmit': 'Invia link', + 'login.forgotPasswordSentTitle': 'Controlla la tua email', + 'login.forgotPasswordSentBody': 'Se esiste un account con questa email, il link è in arrivo. Scade tra 60 minuti.', + 'login.forgotPasswordSmtpHintOff': 'Nota: il tuo amministratore non ha configurato SMTP, quindi il link di reset verrà scritto nella console del server invece di essere inviato via email.', + 'login.backToLogin': 'Torna all’accesso', + 'login.newPassword': 'Nuova password', + 'login.confirmPassword': 'Conferma nuova password', + 'login.passwordsDontMatch': 'Le password non corrispondono', + 'login.mfaCode': 'Codice 2FA', + 'login.resetPasswordTitle': 'Imposta una nuova password', + 'login.resetPasswordBody': 'Scegli una password robusta che non hai già usato qui. Minimo 8 caratteri.', + 'login.resetPasswordMfaBody': 'Inserisci il codice 2FA o un codice di backup per completare il reset.', + 'login.resetPasswordSubmit': 'Reimposta password', + 'login.resetPasswordVerify': 'Verifica e reimposta', + 'login.resetPasswordSuccessTitle': 'Password aggiornata', + 'login.resetPasswordSuccessBody': 'Ora puoi accedere con la nuova password.', + 'login.resetPasswordInvalidLink': 'Link di reset non valido', + 'login.resetPasswordInvalidLinkBody': 'Il link è mancante o danneggiato. Richiedine uno nuovo per continuare.', + 'login.resetPasswordFailed': 'Reset non riuscito. Il link potrebbe essere scaduto.', // Register 'register.passwordMismatch': 'Le password non corrispondono', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 8c116b00..485d801b 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -451,6 +451,28 @@ const nl: Record = { 'login.oidcFailed': 'OIDC-aanmelding mislukt', 'login.usernameRequired': 'Gebruikersnaam is vereist', 'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten', + 'login.forgotPassword': 'Wachtwoord vergeten?', + 'login.forgotPasswordTitle': 'Wachtwoord resetten', + 'login.forgotPasswordBody': 'Voer het e-mailadres van je account in. Als er een account bestaat, sturen we een resetlink.', + 'login.forgotPasswordSubmit': 'Resetlink verzenden', + 'login.forgotPasswordSentTitle': 'Controleer je e-mail', + 'login.forgotPasswordSentBody': 'Als er een account bestaat met dit adres, is de resetlink onderweg. Hij verloopt over 60 minuten.', + 'login.forgotPasswordSmtpHintOff': 'Let op: de beheerder heeft SMTP niet ingesteld. De resetlink wordt naar de serverconsole geschreven in plaats van via e-mail verzonden.', + 'login.backToLogin': 'Terug naar inloggen', + 'login.newPassword': 'Nieuw wachtwoord', + 'login.confirmPassword': 'Nieuw wachtwoord bevestigen', + 'login.passwordsDontMatch': 'Wachtwoorden komen niet overeen', + 'login.mfaCode': '2FA-code', + 'login.resetPasswordTitle': 'Nieuw wachtwoord instellen', + 'login.resetPasswordBody': 'Kies een sterk wachtwoord dat je hier nog niet hebt gebruikt. Minimaal 8 tekens.', + 'login.resetPasswordMfaBody': 'Voer je 2FA-code of een back-upcode in om de reset te voltooien.', + 'login.resetPasswordSubmit': 'Wachtwoord resetten', + 'login.resetPasswordVerify': 'Verifiëren en resetten', + 'login.resetPasswordSuccessTitle': 'Wachtwoord bijgewerkt', + 'login.resetPasswordSuccessBody': 'Je kunt nu inloggen met je nieuwe wachtwoord.', + 'login.resetPasswordInvalidLink': 'Ongeldige resetlink', + 'login.resetPasswordInvalidLinkBody': 'Deze link ontbreekt of is ongeldig. Vraag een nieuwe aan om door te gaan.', + 'login.resetPasswordFailed': 'Resetten mislukt. De link is mogelijk verlopen.', 'login.oidc.tokenFailed': 'Authenticatie mislukt.', 'login.oidc.invalidState': 'Ongeldige sessie. Probeer het opnieuw.', 'login.demoFailed': 'Demo-login mislukt', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index de065544..585f5e61 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -425,6 +425,28 @@ const pl: Record = { 'login.oidcFailed': 'Logowanie OIDC nie powiodło się', 'login.usernameRequired': 'Nazwa użytkownika jest wymagana', 'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków', + 'login.forgotPassword': 'Nie pamiętasz hasła?', + 'login.forgotPasswordTitle': 'Zresetuj hasło', + 'login.forgotPasswordBody': 'Wpisz adres e-mail użyty przy rejestracji. Jeśli konto istnieje, wyślemy link do resetu.', + 'login.forgotPasswordSubmit': 'Wyślij link', + 'login.forgotPasswordSentTitle': 'Sprawdź swoją pocztę', + 'login.forgotPasswordSentBody': 'Jeśli istnieje konto dla tego adresu, link jest już w drodze. Wygaśnie za 60 minut.', + 'login.forgotPasswordSmtpHintOff': 'Uwaga: administrator nie skonfigurował SMTP, więc link resetujący zostanie zapisany w konsoli serwera zamiast wysłania e-mailem.', + 'login.backToLogin': 'Wróć do logowania', + 'login.newPassword': 'Nowe hasło', + 'login.confirmPassword': 'Potwierdź nowe hasło', + 'login.passwordsDontMatch': 'Hasła nie są zgodne', + 'login.mfaCode': 'Kod 2FA', + 'login.resetPasswordTitle': 'Ustaw nowe hasło', + 'login.resetPasswordBody': 'Wybierz silne hasło, którego tu jeszcze nie używałeś. Minimum 8 znaków.', + 'login.resetPasswordMfaBody': 'Wpisz kod 2FA lub kod zapasowy, aby zakończyć reset.', + 'login.resetPasswordSubmit': 'Zresetuj hasło', + 'login.resetPasswordVerify': 'Zweryfikuj i zresetuj', + 'login.resetPasswordSuccessTitle': 'Hasło zaktualizowane', + 'login.resetPasswordSuccessBody': 'Możesz się teraz zalogować nowym hasłem.', + 'login.resetPasswordInvalidLink': 'Nieprawidłowy link', + 'login.resetPasswordInvalidLinkBody': 'Brakuje linku lub jest uszkodzony. Poproś o nowy, aby kontynuować.', + 'login.resetPasswordFailed': 'Reset nie powiódł się. Link mógł wygasnąć.', // Register 'register.passwordMismatch': 'Hasła nie są identyczne', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index d5c16fae..a0042c8b 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -451,6 +451,28 @@ const ru: Record = { 'login.oidcFailed': 'Ошибка входа через OIDC', 'login.usernameRequired': 'Имя пользователя обязательно', 'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов', + 'login.forgotPassword': 'Забыли пароль?', + 'login.forgotPasswordTitle': 'Сброс пароля', + 'login.forgotPasswordBody': 'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.', + 'login.forgotPasswordSubmit': 'Отправить ссылку', + 'login.forgotPasswordSentTitle': 'Проверьте почту', + 'login.forgotPasswordSentBody': 'Если аккаунт существует, ссылка для сброса уже летит к вам. Она действительна 60 минут.', + 'login.forgotPasswordSmtpHintOff': 'Обратите внимание: администратор не настроил SMTP, поэтому ссылка для сброса будет записана в консоль сервера, а не отправлена по почте.', + 'login.backToLogin': 'Вернуться ко входу', + 'login.newPassword': 'Новый пароль', + 'login.confirmPassword': 'Подтвердите новый пароль', + 'login.passwordsDontMatch': 'Пароли не совпадают', + 'login.mfaCode': 'Код 2FA', + 'login.resetPasswordTitle': 'Задайте новый пароль', + 'login.resetPasswordBody': 'Выберите надёжный пароль, который вы здесь ещё не использовали. Минимум 8 символов.', + 'login.resetPasswordMfaBody': 'Введите код 2FA или резервный код, чтобы завершить сброс.', + 'login.resetPasswordSubmit': 'Сбросить пароль', + 'login.resetPasswordVerify': 'Проверить и сбросить', + 'login.resetPasswordSuccessTitle': 'Пароль обновлён', + 'login.resetPasswordSuccessBody': 'Теперь вы можете войти с новым паролем.', + 'login.resetPasswordInvalidLink': 'Неверная ссылка сброса', + 'login.resetPasswordInvalidLinkBody': 'Ссылка отсутствует или повреждена. Запросите новую, чтобы продолжить.', + 'login.resetPasswordFailed': 'Сброс не удался. Возможно, срок действия ссылки истёк.', 'login.oidc.tokenFailed': 'Аутентификация не удалась.', 'login.oidc.invalidState': 'Недействительная сессия. Попробуйте снова.', 'login.demoFailed': 'Ошибка демо-входа', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 7d8b6ba6..f0a9fd93 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -451,6 +451,28 @@ const zh: Record = { 'login.oidcFailed': 'OIDC 登录失败', 'login.usernameRequired': '用户名为必填项', 'login.passwordMinLength': '密码至少需要8个字符', + 'login.forgotPassword': '忘记密码?', + 'login.forgotPasswordTitle': '重置密码', + 'login.forgotPasswordBody': '输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。', + 'login.forgotPasswordSubmit': '发送重置链接', + 'login.forgotPasswordSentTitle': '请查看邮箱', + 'login.forgotPasswordSentBody': '若该邮箱存在账户,重置链接正在发送中。链接将在 60 分钟后失效。', + 'login.forgotPasswordSmtpHintOff': '提示:管理员未配置 SMTP,重置链接将被写入服务器控制台,而不是通过电子邮件发送。', + 'login.backToLogin': '返回登录', + 'login.newPassword': '新密码', + 'login.confirmPassword': '确认新密码', + 'login.passwordsDontMatch': '两次输入的密码不一致', + 'login.mfaCode': '二步验证码', + 'login.resetPasswordTitle': '设置新密码', + 'login.resetPasswordBody': '请选择您在此处未使用过的强密码。至少 8 位。', + 'login.resetPasswordMfaBody': '输入您的二步验证码或备用代码以完成重置。', + 'login.resetPasswordSubmit': '重置密码', + 'login.resetPasswordVerify': '验证并重置', + 'login.resetPasswordSuccessTitle': '密码已更新', + 'login.resetPasswordSuccessBody': '您现在可以使用新密码登录了。', + 'login.resetPasswordInvalidLink': '无效的重置链接', + 'login.resetPasswordInvalidLinkBody': '此链接已丢失或损坏。请重新申请以继续。', + 'login.resetPasswordFailed': '重置失败。链接可能已过期。', 'login.oidc.tokenFailed': '认证失败。', 'login.oidc.invalidState': '会话无效,请重试。', 'login.demoFailed': '演示登录失败', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 27b4036f..5e2fa776 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -510,6 +510,28 @@ const zhTw: Record = { 'login.oidcFailed': 'OIDC 登入失敗', 'login.usernameRequired': '使用者名稱為必填', 'login.passwordMinLength': '密碼至少需要8個字元', + 'login.forgotPassword': '忘記密碼?', + 'login.forgotPasswordTitle': '重設密碼', + 'login.forgotPasswordBody': '請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。', + 'login.forgotPasswordSubmit': '傳送重設連結', + 'login.forgotPasswordSentTitle': '請查看您的電子郵件', + 'login.forgotPasswordSentBody': '若此電子郵件存在帳號,重設連結正在傳送中。連結將於 60 分鐘後失效。', + 'login.forgotPasswordSmtpHintOff': '提醒:管理員尚未設定 SMTP,重設連結將寫入伺服器控制台,而非透過電子郵件寄送。', + 'login.backToLogin': '返回登入', + 'login.newPassword': '新密碼', + 'login.confirmPassword': '確認新密碼', + 'login.passwordsDontMatch': '兩次輸入的密碼不一致', + 'login.mfaCode': '2FA 驗證碼', + 'login.resetPasswordTitle': '設定新密碼', + 'login.resetPasswordBody': '請選擇您在此處尚未使用過的強密碼。至少 8 個字元。', + 'login.resetPasswordMfaBody': '請輸入您的 2FA 驗證碼或備用代碼以完成重設。', + 'login.resetPasswordSubmit': '重設密碼', + 'login.resetPasswordVerify': '驗證並重設', + 'login.resetPasswordSuccessTitle': '密碼已更新', + 'login.resetPasswordSuccessBody': '您現在可以使用新密碼登入。', + 'login.resetPasswordInvalidLink': '無效的重設連結', + 'login.resetPasswordInvalidLinkBody': '此連結遺失或已損壞。請重新申請以繼續。', + 'login.resetPasswordFailed': '重設失敗。連結可能已過期。', 'login.oidc.tokenFailed': '認證失敗。', 'login.oidc.invalidState': '會話無效,請重試。', 'login.demoFailed': '演示登入失敗', diff --git a/client/src/pages/ForgotPasswordPage.tsx b/client/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 00000000..e05ea8bf --- /dev/null +++ b/client/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { Mail, ArrowLeft, CheckCircle2, Terminal } from 'lucide-react' +import { useTranslation } from '../i18n' +import { authApi } from '../api/client' + +const inputBase: React.CSSProperties = { + width: '100%', padding: '11px 12px 11px 38px', borderRadius: 12, + border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit', + outline: 'none', transition: 'border-color 120ms', + background: 'white', color: '#111827', +} + +const ForgotPasswordPage: React.FC = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const [email, setEmail] = useState('') + const [submitted, setSubmitted] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [smtpConfigured, setSmtpConfigured] = useState(null) + + useEffect(() => { + // Probe whether SMTP is configured so we can warn the user up-front + // that the link will land in the server console instead of their + // inbox. Null while pending — hint is hidden until we know. + authApi.getAppConfig?.() + .then((cfg: any) => { + const hasEmail = !!cfg?.available_channels?.email + setSmtpConfigured(hasEmail) + }) + .catch(() => setSmtpConfigured(null)) + }, []) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (isLoading) return + setIsLoading(true) + try { + await authApi.forgotPassword({ email: email.trim() }) + } catch { + // Enumeration-safe: success UX regardless of server outcome. + } + setSubmitted(true) + setIsLoading(false) + } + + return ( +
+
+ + + {submitted ? ( +
+
+ +
+

+ {t('login.forgotPasswordSentTitle')} +

+

+ {t('login.forgotPasswordSentBody')} +

+ {smtpConfigured === false && ( +
+ +

+ {t('login.forgotPasswordSmtpHintOff')} +

+
+ )} + +
+ ) : ( + <> +

+ {t('login.forgotPasswordTitle')} +

+

+ {t('login.forgotPasswordBody')} +

+ {smtpConfigured === false && ( +
+ +

+ {t('login.forgotPasswordSmtpHintOff')} +

+
+ )} +
+
+ +
+ + ) => setEmail(e.target.value)} + required placeholder={t('login.emailPlaceholder')} style={inputBase} + onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }} + /> +
+
+ +
+ + )} +
+
+ ) +} + +export default ForgotPasswordPage diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index c9a3668d..d4646635 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -781,6 +781,17 @@ export default function LoginPage(): React.ReactElement { }} /> + {mode === 'login' && ( +
+ +
+ )} )} diff --git a/client/src/pages/ResetPasswordPage.tsx b/client/src/pages/ResetPasswordPage.tsx new file mode 100644 index 00000000..33c7c21b --- /dev/null +++ b/client/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { Lock, KeyRound, CheckCircle2, AlertTriangle, Eye, EyeOff } from 'lucide-react' +import { useTranslation } from '../i18n' +import { authApi } from '../api/client' +import { getApiErrorMessage } from '../types' + +const inputBase: React.CSSProperties = { + width: '100%', padding: '11px 44px 11px 38px', borderRadius: 12, + border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit', + outline: 'none', transition: 'border-color 120ms', + background: 'white', color: '#111827', +} + +const ResetPasswordPage: React.FC = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const [params] = useSearchParams() + const token = params.get('token') || '' + + const [pw, setPw] = useState('') + const [pw2, setPw2] = useState('') + const [showPw, setShowPw] = useState(false) + const [mfaCode, setMfaCode] = useState('') + const [mfaRequired, setMfaRequired] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!token) setError(t('login.resetPasswordInvalidLink')) + }, [token, t]) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (isLoading) return + setError('') + if (!token) return + if (pw.length < 8) { setError(t('login.passwordMinLength')); return } + if (pw !== pw2) { setError(t('login.passwordsDontMatch')); return } + setIsLoading(true) + try { + const res = await authApi.resetPassword({ + token, + new_password: pw, + ...(mfaRequired && mfaCode ? { mfa_code: mfaCode.trim() } : {}), + }) + if (res.mfa_required) { + setMfaRequired(true) + setIsLoading(false) + return + } + if (res.success) { + setSuccess(true) + } + } catch (err) { + setError(getApiErrorMessage(err, t('login.resetPasswordFailed'))) + } + setIsLoading(false) + } + + const shell = (inner: React.ReactNode) => ( +
+
{inner}
+
+ ) + + if (success) { + return shell( +
+
+

+ {t('login.resetPasswordSuccessTitle')} +

+

+ {t('login.resetPasswordSuccessBody')} +

+ +
+ ) + } + + if (!token) { + return shell( +
+
+

+ {t('login.resetPasswordInvalidLink')} +

+

+ {t('login.resetPasswordInvalidLinkBody')} +

+ +
+ ) + } + + return shell( + <> +

+ {t('login.resetPasswordTitle')} +

+

+ {mfaRequired ? t('login.resetPasswordMfaBody') : t('login.resetPasswordBody')} +

+ {error && ( +
{error}
+ )} +
+ {!mfaRequired && ( + <> +
+ +
+ + ) => setPw(e.target.value)} + required placeholder="••••••••" style={inputBase} + onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }} + /> + +
+
+
+ +
+ + ) => setPw2(e.target.value)} + required placeholder="••••••••" style={inputBase} + onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }} + /> +
+
+ + )} + {mfaRequired && ( +
+ +
+ + ) => setMfaCode(e.target.value)} + required placeholder="123456 or backup-code" style={{ ...inputBase, paddingRight: 12 }} + autoFocus + onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }} + /> +
+
+ )} + +
+ + ) +} + +export default ResetPasswordPage diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f2b476ab..af0f4c35 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1772,6 +1772,26 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE oauth_tokens ADD COLUMN audience TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, + // Migration: password reset — add password_version for session + // invalidation, and a token table keyed by SHA-256 hash (raw tokens + // never hit the DB). + () => { + try { db.exec('ALTER TABLE users ADD COLUMN password_version INTEGER NOT NULL DEFAULT 0'); } + catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + db.exec(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + consumed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7fad6e6b..289e1851 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -25,10 +25,23 @@ function createTables(db: Database.Database): void { synology_password TEXT, synology_sid TEXT, must_change_password INTEGER DEFAULT 0, + password_version INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + consumed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash); + CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index d2dddf39..31f573ba 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -15,11 +15,21 @@ export function extractToken(req: Request): string | null { function verifyJwtAndLoadUser(token: string): User | null { try { - const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; - const user = db.prepare( - 'SELECT id, username, email, role FROM users WHERE id = ?' - ).get(decoded.id) as User | undefined; - return user ?? null; + const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number }; + const row = db.prepare( + 'SELECT id, username, email, role, password_version FROM users WHERE id = ?' + ).get(decoded.id) as (User & { password_version?: number }) | undefined; + if (!row) return null; + // Session invalidation: any token whose embedded password_version + // predates the user's current one is rejected. Tokens issued before + // the `pv` claim existed (decoded.pv === undefined) are treated as + // version 0 so legacy sessions keep working until the user resets. + const tokenPv = typeof decoded.pv === 'number' ? decoded.pv : 0; + const currentPv = typeof row.password_version === 'number' ? row.password_version : 0; + if (tokenPv !== currentPv) return null; + // Don't leak password_version beyond the middleware. + const { password_version: _pv, ...user } = row; + return user as User; } catch { return null; } @@ -68,15 +78,7 @@ const optionalAuth = (req: Request, res: Response, next: NextFunction): void => return next(); } - try { - const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; - const user = db.prepare( - 'SELECT id, username, email, role FROM users WHERE id = ?' - ).get(decoded.id) as User | undefined; - (req as OptionalAuthRequest).user = user || null; - } catch (err: unknown) { - (req as OptionalAuthRequest).user = null; - } + (req as OptionalAuthRequest).user = verifyJwtAndLoadUser(token) || null; next(); }; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index f3846ced..a99ababb 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -36,7 +36,10 @@ import { deleteMcpToken, createWsToken, createResourceToken, + requestPasswordReset, + resetPassword, } from '../services/authService'; +import { sendPasswordResetEmail } from '../services/notifications'; const router = express.Router(); @@ -76,6 +79,8 @@ const RATE_LIMIT_CLEANUP = 5 * 60 * 1000; const loginAttempts = new Map(); const mfaAttempts = new Map(); +const forgotAttempts = new Map(); +const resetAttempts = new Map(); setInterval(() => { const now = Date.now(); for (const [key, record] of loginAttempts) { @@ -84,6 +89,12 @@ setInterval(() => { for (const [key, record] of mfaAttempts) { if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key); } + for (const [key, record] of forgotAttempts) { + if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key); + } + for (const [key, record] of resetAttempts) { + if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key); + } }, RATE_LIMIT_CLEANUP); function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) { @@ -104,6 +115,8 @@ function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempt } const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW); const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts); +const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts); +const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts); // --------------------------------------------------------------------------- // Routes @@ -146,6 +159,71 @@ router.post('/login', authLimiter, (req: Request, res: Response) => { res.json({ token: result.token, user: result.user }); }); +// --------------------------------------------------------------------------- +// Password reset (forgot / complete) +// --------------------------------------------------------------------------- + +// Generic OK response — identical regardless of email existence, to +// prevent enumeration via response body OR status code. +const GENERIC_FORGOT_RESPONSE = { ok: true }; +// Minimum time we spend inside the forgot handler so a "no such user" +// path does not complete noticeably faster than a real reset. +const FORGOT_MIN_LATENCY_MS = 350; + +router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => { + const started = Date.now(); + const rawEmail = typeof req.body?.email === 'string' ? req.body.email : ''; + const ip = getClientIp(req); + + const outcome = requestPasswordReset(rawEmail, ip); + + if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) { + // Build the reset URL from the incoming request origin so dev / + // prod both work without extra config. + const origin = (req.headers['origin'] as string | undefined) + || (req.headers['referer'] ? new URL(req.headers['referer'] as string).origin : undefined) + || `${req.protocol}://${req.get('host')}`; + const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`; + + // Audit the REQUEST always — even for "no user" — so abuse is visible. + writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } }); + + try { + const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId); + writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } }); + } catch (err) { + // Never surface delivery failure to the caller — still respond ok. + writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } }); + } + } else { + writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } }); + } + + // Pad the response so timing doesn't reveal outcome. + const elapsed = Date.now() - started; + if (elapsed < FORGOT_MIN_LATENCY_MS) { + await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed)); + } + res.json(GENERIC_FORGOT_RESPONSE); +}); + +router.post('/reset-password', resetLimiter, (req: Request, res: Response) => { + const ip = getClientIp(req); + const result = resetPassword(req.body); + if (result.error) { + writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } }); + return res.status(result.status!).json({ error: result.error }); + } + if (result.mfa_required) { + return res.status(200).json({ mfa_required: true }); + } + writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip }); + // Purposefully do NOT auto-login — the user just demonstrated they + // have email+password access; asking them to sign in fresh is the + // standard, safer UX. + res.json({ success: true }); +}); + router.get('/me', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const user = getCurrentUser(authReq.user.id); diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index b091bf58..de8a32c3 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -156,9 +156,12 @@ export function isOidcOnlyMode(): boolean { return !resolveAuthToggles().password_login; } -export function generateToken(user: { id: number | bigint }) { +export function generateToken(user: { id: number | bigint; password_version?: number }) { + const pv = typeof user.password_version === 'number' + ? user.password_version + : ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0); return jwt.sign( - { id: user.id }, + { id: user.id, pv }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' } ); @@ -994,6 +997,210 @@ export function verifyMfaLogin(body: { } } +// --------------------------------------------------------------------------- +// Password reset +// --------------------------------------------------------------------------- + +// 60 min; long enough to read the email in a second tab, short enough +// that a leaked link is unlikely to still be valid when someone tries it. +const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000; +const PASSWORD_RESET_TOKEN_BYTES = 32; // 256-bit entropy + +/** + * Returns the SHA-256 hex hash of a reset token. Raw tokens are never + * persisted — we only store and compare their hashes. + */ +function hashResetToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} + +/** + * Shape returned by requestPasswordReset. For enumeration-safety the + * route ALWAYS returns the same response to the client regardless of + * whether a user existed — this struct is only consumed internally by + * the route handler to decide whether to send an email / log a link. + */ +export interface PasswordResetRequestOutcome { + tokenForDelivery: string | null; // raw token — send via email or log, never return to client + userId: number | null; + userEmail: string | null; + reason: 'issued' | 'no_user' | 'oidc_only' | 'throttled_per_email' | 'password_login_disabled'; +} + +// Per-email throttle (defence-in-depth on top of the per-IP limiter). +const perEmailResetAttempts = new Map(); +const PASSWORD_RESET_PER_EMAIL_WINDOW_MS = 15 * 60 * 1000; +const PASSWORD_RESET_PER_EMAIL_MAX = 3; +setInterval(() => { + const now = Date.now(); + for (const [key, record] of perEmailResetAttempts) { + if (now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) perEmailResetAttempts.delete(key); + } +}, 5 * 60 * 1000).unref?.(); + +export function requestPasswordReset(rawEmail: string, createdIp: string | null): PasswordResetRequestOutcome { + const email = String(rawEmail || '').trim().toLowerCase(); + // Basic shape check — a fully empty / malformed email is treated like + // "no user" so we still spend the same time internally. + const looksLikeEmail = email.length > 0 && /.+@.+\..+/.test(email); + + // Global policy check: password login disabled → no reset possible. + const toggles = resolveAuthToggles(); + if (!toggles.password_login) { + return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'password_login_disabled' }; + } + + // Per-email throttle. We check this BEFORE the DB lookup so the timing + // is identical regardless of whether the account exists. + const throttleKey = email || '__noemail__'; + const now = Date.now(); + const record = perEmailResetAttempts.get(throttleKey); + if (record && record.count >= PASSWORD_RESET_PER_EMAIL_MAX && now - record.first < PASSWORD_RESET_PER_EMAIL_WINDOW_MS) { + return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'throttled_per_email' }; + } + if (!record || now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) { + perEmailResetAttempts.set(throttleKey, { count: 1, first: now }); + } else { + record.count++; + } + + if (!looksLikeEmail) { + return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' }; + } + + const user = db.prepare('SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ?').get(email) as + | { id: number; email: string; password_hash: string | null; oidc_sub: string | null } + | undefined; + + if (!user) { + return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' }; + } + // OIDC-only account (no local password) — we can't reset what isn't there. + // The client still gets the generic "if that email exists…" response. + if (!user.password_hash && user.oidc_sub) { + return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' }; + } + + // Invalidate any prior unconsumed tokens for this user so there is + // always at most one live reset link in flight. + db.prepare( + "UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL" + ).run(user.id); + + const raw = randomBytes(PASSWORD_RESET_TOKEN_BYTES).toString('base64url'); + const token_hash = hashResetToken(raw); + const expires_at = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString(); + + db.prepare( + 'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_ip) VALUES (?, ?, ?, ?)' + ).run(user.id, token_hash, expires_at, createdIp); + + return { tokenForDelivery: raw, userId: user.id, userEmail: user.email, reason: 'issued' }; +} + +export interface ResetPasswordOutcome { + error?: string; + status?: number; + success?: boolean; + /** When true the client must collect a TOTP/backup code and call again. */ + mfa_required?: boolean; + userId?: number; +} + +/** + * Consume a reset token and set a new password. If the target user has + * MFA enabled, a valid TOTP code or backup code must be supplied — a + * compromised email alone therefore does NOT allow taking over a + * 2FA-protected account. + */ +export function resetPassword(body: { + token?: string; + new_password?: string; + mfa_code?: string; +}): ResetPasswordOutcome { + const { token, new_password, mfa_code } = body; + if (!token || typeof token !== 'string') { + return { error: 'Reset token is required', status: 400 }; + } + if (!new_password || typeof new_password !== 'string') { + return { error: 'New password is required', status: 400 }; + } + // Check the policy BEFORE touching the token so an invalid password + // does not burn the user's one-time link. + const pwCheck = validatePassword(new_password); + if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 }; + + const tokenHash = hashResetToken(token); + const row = db.prepare( + 'SELECT id, user_id, expires_at, consumed_at FROM password_reset_tokens WHERE token_hash = ?' + ).get(tokenHash) as + | { id: number; user_id: number; expires_at: string; consumed_at: string | null } + | undefined; + + if (!row) return { error: 'Invalid or expired reset link', status: 400 }; + if (row.consumed_at) return { error: 'This reset link has already been used', status: 400 }; + if (new Date(row.expires_at).getTime() < Date.now()) { + return { error: 'Reset link has expired. Please request a new one.', status: 400 }; + } + + const user = db.prepare( + 'SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?' + ).get(row.user_id) as + | { id: number; email: string; mfa_enabled: number | boolean; mfa_secret: string | null; mfa_backup_codes: string | null; password_version: number } + | undefined; + + if (!user) return { error: 'Invalid or expired reset link', status: 400 }; + + // MFA gate. If enabled, require a valid TOTP or backup code. + const mfaOn = user.mfa_enabled === 1 || user.mfa_enabled === true; + let backupCodeConsumedIndex: number | null = null; + if (mfaOn) { + if (!user.mfa_secret) { + // Data inconsistency — fail closed. + return { error: 'MFA is enabled but not configured. Contact your administrator.', status: 500 }; + } + const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : ''; + if (!supplied) return { mfa_required: true, status: 200 }; + + const secret = decryptMfaSecret(user.mfa_secret); + const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret }); + if (!okTotp) { + const hashes = parseBackupCodeHashes(user.mfa_backup_codes); + const candidateHash = hashBackupCode(supplied); + const idx = hashes.findIndex(h => h === candidateHash); + if (idx === -1) return { error: 'Invalid MFA code', status: 401 }; + backupCodeConsumedIndex = idx; + } + } + + const newHash = bcrypt.hashSync(new_password, 12); + const newPv = (user.password_version ?? 0) + 1; + + db.transaction(() => { + // Burn the token first to keep it atomic with the password change. + db.prepare('UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id); + // Also burn every OTHER live token for this user — a fresh login + // should not leave a second door open. + db.prepare( + "UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL AND id != ?" + ).run(user.id, row.id); + db.prepare( + 'UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' + ).run(newHash, newPv, user.id); + // Consume backup code if one was used. + if (backupCodeConsumedIndex !== null) { + const hashes = parseBackupCodeHashes(user.mfa_backup_codes); + hashes.splice(backupCodeConsumedIndex, 1); + db.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?').run(JSON.stringify(hashes), user.id); + } + })(); + + // Kick off any MCP/WS session cleanup — same hook the account-delete path uses. + try { revokeUserSessions?.(user.id); } catch { /* best-effort */ } + + return { success: true, userId: user.id }; +} + // --------------------------------------------------------------------------- // MCP tokens // --------------------------------------------------------------------------- diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 0773a279..1f28765d 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -316,6 +316,103 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi // ── Send functions ───────────────────────────────────────────────────────── +// ── Password reset email ─────────────────────────────────────────────────── + +interface PasswordResetStrings { subject: string; greeting: string; body: string; ctaIntro: string; expiry: string; ignore: string } + +const PASSWORD_RESET_I18N: Record = { + en: { subject: 'Reset your password', greeting: 'Hi', body: 'We received a request to reset the password for your TREK account. Click the button below to set a new password.', ctaIntro: 'Reset password', expiry: 'This link expires in 60 minutes.', ignore: "If you didn't request this, you can safely ignore this email — your password won't change." }, + de: { subject: 'Passwort zurücksetzen', greeting: 'Hallo', body: 'Wir haben eine Anfrage erhalten, das Passwort für dein TREK-Konto zurückzusetzen. Klicke auf den Button unten, um ein neues Passwort festzulegen.', ctaIntro: 'Passwort zurücksetzen', expiry: 'Dieser Link ist 60 Minuten gültig.', ignore: 'Wenn du das nicht warst, ignoriere diese E-Mail — dein Passwort bleibt unverändert.' }, + fr: { subject: 'Réinitialisez votre mot de passe', greeting: 'Bonjour', body: 'Nous avons reçu une demande de réinitialisation du mot de passe de votre compte TREK. Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe.', ctaIntro: 'Réinitialiser le mot de passe', expiry: 'Ce lien expire dans 60 minutes.', ignore: "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail — votre mot de passe ne changera pas." }, + es: { subject: 'Restablecer tu contraseña', greeting: 'Hola', body: 'Recibimos una solicitud para restablecer la contraseña de tu cuenta de TREK. Haz clic en el botón de abajo para establecer una nueva contraseña.', ctaIntro: 'Restablecer contraseña', expiry: 'Este enlace caduca en 60 minutos.', ignore: 'Si no solicitaste esto, puedes ignorar este correo — tu contraseña no cambiará.' }, + it: { subject: 'Reimposta la tua password', greeting: 'Ciao', body: 'Abbiamo ricevuto una richiesta di reimpostazione della password per il tuo account TREK. Clicca il pulsante qui sotto per impostare una nuova password.', ctaIntro: 'Reimposta password', expiry: 'Questo link scade tra 60 minuti.', ignore: 'Se non hai richiesto questa operazione, ignora questa email — la tua password non cambierà.' }, + nl: { subject: 'Reset je wachtwoord', greeting: 'Hallo', body: 'We hebben een verzoek ontvangen om het wachtwoord voor je TREK-account te resetten. Klik op de knop hieronder om een nieuw wachtwoord in te stellen.', ctaIntro: 'Wachtwoord resetten', expiry: 'Deze link verloopt over 60 minuten.', ignore: 'Als jij dit niet hebt aangevraagd, kun je deze e-mail negeren — je wachtwoord blijft ongewijzigd.' }, + ru: { subject: 'Сброс пароля', greeting: 'Здравствуйте', body: 'Мы получили запрос на сброс пароля вашего аккаунта TREK. Нажмите кнопку ниже, чтобы установить новый пароль.', ctaIntro: 'Сбросить пароль', expiry: 'Ссылка действительна 60 минут.', ignore: 'Если вы не запрашивали сброс — просто проигнорируйте это письмо, пароль останется прежним.' }, + zh: { subject: '重置您的密码', greeting: '您好', body: '我们收到了重置您的 TREK 账户密码的请求。点击下方按钮设置新密码。', ctaIntro: '重置密码', expiry: '此链接将在 60 分钟后失效。', ignore: '如果这不是您本人的请求,可以忽略本邮件 — 您的密码不会改变。' }, + 'zh-TW': { subject: '重設您的密碼', greeting: '您好', body: '我們收到了重設您 TREK 帳號密碼的請求。點擊下方按鈕以設定新密碼。', ctaIntro: '重設密碼', expiry: '此連結將於 60 分鐘後失效。', ignore: '若非您本人發起的請求,請忽略此郵件 — 您的密碼不會變更。' }, + hu: { subject: 'Jelszó visszaállítása', greeting: 'Szia', body: 'Kérést kaptunk a TREK-fiókod jelszavának visszaállítására. Kattints az alábbi gombra az új jelszó beállításához.', ctaIntro: 'Jelszó visszaállítása', expiry: 'Ez a link 60 perc után lejár.', ignore: 'Ha nem te kérted ezt, nyugodtan hagyd figyelmen kívül ezt az e-mailt — a jelszavad változatlan marad.' }, + ar: { subject: 'إعادة تعيين كلمة المرور', greeting: 'مرحبا', body: 'تلقينا طلبًا لإعادة تعيين كلمة المرور لحسابك في TREK. انقر على الزر أدناه لتعيين كلمة مرور جديدة.', ctaIntro: 'إعادة تعيين كلمة المرور', expiry: 'تنتهي صلاحية هذا الرابط خلال 60 دقيقة.', ignore: 'إذا لم تطلب هذا، يمكنك تجاهل هذه الرسالة — لن تتغير كلمة المرور الخاصة بك.' }, + br: { subject: 'Redefinir sua senha', greeting: 'Olá', body: 'Recebemos um pedido para redefinir a senha da sua conta TREK. Clique no botão abaixo para definir uma nova senha.', ctaIntro: 'Redefinir senha', expiry: 'Este link expira em 60 minutos.', ignore: 'Se você não solicitou isto, pode ignorar este e-mail — sua senha não será alterada.' }, + cs: { subject: 'Obnovení hesla', greeting: 'Ahoj', body: 'Obdrželi jsme žádost o obnovení hesla k tvému účtu TREK. Klikni na tlačítko níže a nastav nové heslo.', ctaIntro: 'Obnovit heslo', expiry: 'Odkaz vyprší za 60 minut.', ignore: 'Pokud jsi o obnovení nežádal/a, tento e-mail ignoruj — heslo zůstane beze změny.' }, + pl: { subject: 'Zresetuj hasło', greeting: 'Cześć', body: 'Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta TREK. Kliknij przycisk poniżej, aby ustawić nowe hasło.', ctaIntro: 'Zresetuj hasło', expiry: 'Link wygaśnie za 60 minut.', ignore: 'Jeśli to nie Ty, zignoruj tę wiadomość — Twoje hasło pozostanie bez zmian.' }, +}; + +function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings, recipient: string, resetUrl: string, lang: string): string { + const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`); + const safeBody = escapeHtml(strings.body); + const safeExpiry = escapeHtml(strings.expiry); + const safeIgnore = escapeHtml(strings.ignore); + const safeCta = escapeHtml(strings.ctaIntro); + const block = ` +

${safeGreeting},

+

${safeBody}

+

+ ${safeCta} +

+

${safeExpiry}

+

${safeIgnore}

+ `; + return buildEmailHtml(subject, block, lang); +} + +/** + * Delivers a password-reset link. When SMTP is configured the user + * receives an email. When it isn't, the link is logged to stdout in a + * clearly-fenced block so the self-hosting admin can hand it off by + * other means. In both cases the caller always gets a boolean that + * indicates only whether the caller should treat delivery as + * best-effort done — the API response to the user must NOT leak it. + */ +export async function sendPasswordResetEmail( + to: string, + resetUrl: string, + userId: number | null, +): Promise<{ delivered: 'email' | 'log' | 'failed' }> { + const lang = userId ? getUserLanguage(userId) : 'en'; + const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en; + const smtpCfg = getSmtpConfig(); + + if (!smtpCfg) { + // No SMTP configured — log the link in a visually distinct block so + // the admin can relay it. Never log the associated user id/email + // content at a lower level, only what's needed. + // eslint-disable-next-line no-console + console.log( + `\n===== PASSWORD RESET LINK =====\n` + + `to: ${to}\n` + + `url: ${resetUrl}\n` + + `expires: 60 minutes\n` + + `(SMTP is not configured — deliver this link to the user manually.)\n` + + `================================\n`, + ); + logInfo(`Password reset link issued (no SMTP) for=${to}`); + return { delivered: 'log' }; + } + + try { + const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true'; + const transporter = nodemailer.createTransport({ + host: smtpCfg.host, + port: smtpCfg.port, + secure: smtpCfg.secure, + auth: smtpCfg.user ? { user: smtpCfg.user, pass: smtpCfg.pass } : undefined, + ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}), + }); + await transporter.sendMail({ + from: smtpCfg.from, + to, + subject: `TREK — ${strings.subject}`, + text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`, + html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang), + }); + logInfo(`Password reset email sent to=${to}`); + return { delivered: 'email' }; + } catch (err) { + logError(`Password reset email failed to=${to}: ${err instanceof Error ? err.message : err}`); + return { delivered: 'failed' }; + } +} + export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise { const config = getSmtpConfig(); if (!config) return false;