From 9f949ee9df65d5d24af1b00eac26e59c1be0cc22 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 28 Mar 2026 14:17:12 +0100 Subject: [PATCH] Harden password reset and email send flows --- .../AuthAndSystemControllerTests.cs | 33 +++++++- .../JobApplicationsApplicationPackageTests.cs | 2 +- .../JobApplicationsEndpointBehaviorTests.cs | 3 +- .../JobApplicationsFollowUpDraftTests.cs | 2 +- .../JobApplicationsMariaDraftTests.cs | 3 +- .../JobApplicationsWorkflowSignalsTests.cs | 2 +- JobTrackerApi/Controllers/AuthController.cs | 29 +++++-- .../Controllers/JobApplicationsController.cs | 14 +++- JobTrackerApi/Controllers/UsersController.cs | 44 +++++++--- job-tracker-ui/src/App.tsx | 2 + job-tracker-ui/src/api.ts | 2 + job-tracker-ui/src/i18n/translations.ts | 6 ++ job-tracker-ui/src/login-page.test.tsx | 12 +-- .../src/pages/ForgotPasswordPage.tsx | 82 +++++++++++++++++++ job-tracker-ui/src/pages/LoginPage.tsx | 26 +----- 15 files changed, 205 insertions(+), 57 deletions(-) create mode 100644 job-tracker-ui/src/pages/ForgotPasswordPage.tsx diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 4071154..29b3ef2 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -22,7 +22,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); - var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of()); + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null)); @@ -34,6 +34,37 @@ public sealed class AuthAndSystemControllerTests Assert.Equal("Ada L.", user.DisplayName); } + [Fact] + public async Task Request_password_reset_returns_service_unavailable_when_email_send_fails() + { + var user = new ApplicationUser { Email = "person@example.com", UserName = "person@example.com" }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user); + userManager.Setup(x => x.GeneratePasswordResetTokenAsync(user)).ReturnsAsync("reset-token"); + + var emailSender = new Mock(); + emailSender + .Setup(x => x.SendAsync(user.Email!, It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SMTP unavailable")); + + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + controller.Request.Scheme = "https"; + controller.Request.Host = new HostString("jobtracker.test"); + + var result = await controller.RequestPasswordReset(new AuthController.RequestPasswordResetRequest("person@example.com"), CancellationToken.None); + + var problem = Assert.IsType(result); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.StatusCode); + var details = Assert.IsType(problem.Value); + Assert.Equal("Email delivery unavailable", details.Title); + } + [Fact] public void Me_result_includes_google_link_details_for_local_users() { diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index 55553e7..1f31b2d 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -146,7 +146,7 @@ public sealed class JobApplicationsApplicationPackageTests private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId) { - var controller = new JobApplicationsController(db, summarizer, Mock.Of(), CreateUserManager().Object); + var controller = new JobApplicationsController(db, summarizer, Mock.Of(), CreateUserManager().Object, NullLogger.Instance); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index ca84ef5..ea5447d 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -6,6 +6,7 @@ using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -60,7 +61,7 @@ public sealed class JobApplicationsEndpointBehaviorTests summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("generated text"); var users = CreateUserManager(); - var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of(), users.Object); + var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of(), users.Object, NullLogger.Instance); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs index ecf54fb..fafc75a 100644 --- a/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs @@ -106,7 +106,7 @@ public sealed class JobApplicationsFollowUpDraftTests private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId) { - var controller = new JobApplicationsController(db, summarizer, Mock.Of(), CreateUserManager().Object); + var controller = new JobApplicationsController(db, summarizer, Mock.Of(), CreateUserManager().Object, NullLogger.Instance); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs index cf3cbef..6b5ba0a 100644 --- a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs @@ -6,6 +6,7 @@ using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -39,7 +40,7 @@ public sealed class JobApplicationsMariaDraftTests summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("generated text"); var users = CreateUserManager(); - var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of(), users.Object); + var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of(), users.Object, NullLogger.Instance); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs b/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs index ad8786c..732ba93 100644 --- a/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs @@ -110,7 +110,7 @@ public sealed class JobApplicationsWorkflowSignalsTests private static JobApplicationsController CreateController(JobTrackerContext db, string userId) { - var controller = new JobApplicationsController(db, Mock.Of(), Mock.Of(), CreateUserManager().Object); + var controller = new JobApplicationsController(db, Mock.Of(), Mock.Of(), CreateUserManager().Object, NullLogger.Instance); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 073b5a5..e429f32 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -18,14 +18,16 @@ public sealed class AuthController : ControllerBase private readonly ITokenService _tokens; private readonly IAppEmailSender _email; private readonly IGoogleTokenValidator _googleTokens; + private readonly ILogger _logger; - public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens) + public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger logger) { _cfg = cfg; _users = users; _tokens = tokens; _email = email; _googleTokens = googleTokens; + _logger = logger; } [HttpGet("config")] @@ -395,12 +397,20 @@ public sealed class AuthController : ControllerBase var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}"; - await _email.SendAsync( - user.Email, - "Password reset", - $"You requested a password reset for Jobbjakt.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.", - cancellationToken - ); + try + { + await _email.SendAsync( + user.Email, + "Password reset", + $"You requested a password reset for Jobbjakt.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.", + cancellationToken + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send password reset email to {Email}", user.Email); + return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Password reset email could not be sent right now. Please try again later."); + } return NoContent(); } @@ -429,6 +439,11 @@ public sealed class AuthController : ControllerBase return NoContent(); } + private IActionResult EmailDeliveryUnavailable(string detail) + { + return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail); + } + private static string? TrimOrNull(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 1b01a7d..6129517 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -19,13 +19,15 @@ namespace JobTrackerApi.Controllers private readonly ISummarizerService _summarizer; private readonly IAppEmailSender _email; private readonly UserManager _users; + private readonly ILogger _logger; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger) { _db = db; _summarizer = summarizer; _email = email; _users = users; + _logger = logger; } private string? CurrentUserId => @@ -2492,7 +2494,15 @@ Job description: var toEmail = (request.ToEmail ?? job.Company?.RecruiterEmail ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required."); - await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken); + try + { + await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send follow-up email for job {JobId} to {Email}", id, toEmail); + return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Follow-up email could not be sent right now. Please try again later."); + } _db.Correspondences.Add(new Correspondence { diff --git a/JobTrackerApi/Controllers/UsersController.cs b/JobTrackerApi/Controllers/UsersController.cs index 20bc74f..a041e4e 100644 --- a/JobTrackerApi/Controllers/UsersController.cs +++ b/JobTrackerApi/Controllers/UsersController.cs @@ -17,12 +17,14 @@ public sealed class UsersController : ControllerBase private readonly RoleManager _roles; private readonly IAppEmailSender _email; private readonly IConfiguration _cfg; - public UsersController(UserManager users, RoleManager roles, IAppEmailSender email, IConfiguration cfg) + private readonly ILogger _logger; + public UsersController(UserManager users, RoleManager roles, IAppEmailSender email, IConfiguration cfg, ILogger logger) { _users = users; _roles = roles; _email = email; _cfg = cfg; + _logger = logger; } public sealed record UserDto( @@ -150,12 +152,20 @@ public sealed class UsersController : ControllerBase var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(u.Email)}&token={Uri.EscapeDataString(token)}"; - await _email.SendAsync( - u.Email, - "Password reset", - $"An admin initiated a password reset for your Jobbjakt account.\n\nReset link:\n{link}\n", - cancellationToken - ); + try + { + await _email.SendAsync( + u.Email, + "Password reset", + $"An admin initiated a password reset for your Jobbjakt account.\n\nReset link:\n{link}\n", + cancellationToken + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send admin-initiated password reset email to {Email}", u.Email); + return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Password reset email could not be sent right now. Please try again later."); + } return NoContent(); } @@ -176,12 +186,20 @@ public sealed class UsersController : ControllerBase ? "This is a test email from the Jobbjakt admin panel.\n\nIf you received this, the SMTP configuration is working." : request!.Message!.Trim(); - await _email.SendAsync( - toEmail, - subject, - $"{message}\n\nSent at: {DateTimeOffset.UtcNow:u}", - cancellationToken - ); + try + { + await _email.SendAsync( + toEmail, + subject, + $"{message}\n\nSent at: {DateTimeOffset.UtcNow:u}", + cancellationToken + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send test email to {Email}", toEmail); + return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Test email could not be sent right now. Please try again later."); + } return NoContent(); } diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index a37a9d4..90962c4 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -27,6 +27,7 @@ import JobTable from "./components/JobTable"; import type { JobTableColumns } from "./components/JobTable"; import { I18nProvider, useI18n } from "./i18n/I18nProvider"; import LoginPage from "./pages/LoginPage"; +import ForgotPasswordPage from "./pages/ForgotPasswordPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; import RouteErrorPage from "./pages/RouteErrorPage"; import { api } from "./api"; @@ -251,6 +252,7 @@ export default function App() { const router = useMemo(() => createBrowserRouter([ { path: "/login", element: , errorElement: }, + { path: "/forgot-password", element: , errorElement: }, { path: "/reset-password", element: , errorElement: }, { path: "/*", element: , errorElement: }, ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]); diff --git a/job-tracker-ui/src/api.ts b/job-tracker-ui/src/api.ts index 235929e..4e3a6fb 100644 --- a/job-tracker-ui/src/api.ts +++ b/job-tracker-ui/src/api.ts @@ -6,6 +6,8 @@ export function getApiErrorMessage(error: any, fallback = "Request failed.") { const data = error?.response?.data; if (typeof data === "string" && data.trim()) return data.trim(); if (typeof data?.message === "string" && data.message.trim()) return data.message.trim(); + if (typeof data?.detail === "string" && data.detail.trim()) return data.detail.trim(); + if (typeof data?.title === "string" && data.title.trim()) return data.title.trim(); if (Array.isArray(data?.errors)) { const first = data.errors.find((value: unknown) => typeof value === "string" && value.trim()); if (first) return first; diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 07f9c8a..b055701 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -581,6 +581,9 @@ export const translations = { rememberMeHelpPersistent: "Keeps you signed in on this device until you sign out.", rememberMeHelpSession: "Keeps you signed in only for this browser session.", forgotPassword: "Forgot password?", + forgotPasswordTitle: "Request a password reset", + forgotPasswordBody: "Enter your email address and we’ll send you a reset link if the account exists.", + forgotPasswordSubmit: "Send reset link", passwordResetEnterEmail: "Enter your email first, then request a reset link.", passwordResetRequestSending: "Sending reset link...", passwordResetRequestSent: "If that account exists, a reset link has been sent.", @@ -1413,6 +1416,9 @@ export const translations = { rememberMeHelpPersistent: "Holder deg innlogget på denne enheten til du logger ut.", rememberMeHelpSession: "Holder deg innlogget bare i denne nettleserøkten.", forgotPassword: "Glemt passord?", + forgotPasswordTitle: "Be om tilbakestilling av passord", + forgotPasswordBody: "Skriv inn e-postadressen din, så sender vi en nullstillingslenke hvis kontoen finnes.", + forgotPasswordSubmit: "Send nullstillingslenke", passwordResetEnterEmail: "Skriv inn e-post først, og be deretter om en nullstillingslenke.", passwordResetRequestSending: "Sender nullstillingslenke...", passwordResetRequestSent: "Hvis kontoen finnes, er en nullstillingslenke sendt.", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 494c341..ca4e126 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -8,9 +8,11 @@ import { ToastProvider } from './toast'; import { I18nProvider } from './i18n/I18nProvider'; import { api } from './api'; +const mockNavigate = jest.fn(); + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useNavigate: () => jest.fn(), + useNavigate: () => mockNavigate, })); const mockedApi = api as jest.Mocked; @@ -43,6 +45,7 @@ describe('LoginPage', () => { window.localStorage.clear(); window.sessionStorage.clear(); mockedApi.post.mockReset(); + mockNavigate.mockReset(); }); afterEach(() => { @@ -67,16 +70,13 @@ describe('LoginPage', () => { expect(window.localStorage.getItem('authTokenPersistence')).toBe('session'); }); - it('requests a password reset link for the entered email', async () => { - mockedApi.post.mockResolvedValueOnce({ data: {} } as any); - + it('opens the separate forgot-password page with the typed email prefilled', async () => { renderLoginPage(); await screen.findByLabelText('Email'); await userEvent.type(screen.getByLabelText('Email'), 'person@example.com'); await userEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); - await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/request-password-reset', { email: 'person@example.com' })); - expect(await screen.findByText('If that account exists, a reset link has been sent.')).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith('/forgot-password?email=person%40example.com'); }); }); diff --git a/job-tracker-ui/src/pages/ForgotPasswordPage.tsx b/job-tracker-ui/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..239dfa9 --- /dev/null +++ b/job-tracker-ui/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from "react"; + +import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material"; + +import { useNavigate } from "react-router-dom"; + +import { api, getApiErrorMessage } from "../api"; +import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; + +export default function ForgotPasswordPage() { + const { toast } = useToast(); + const { t } = useI18n(); + const navigate = useNavigate(); + + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + setEmail(params.get("email") || ""); + }, []); + + return ( + + + + {t("forgotPasswordTitle")} + + + {t("forgotPasswordBody")} + + + { + e.preventDefault(); + if (!email.trim()) { + toast(t("passwordResetEnterEmail"), "info"); + return; + } + setLoading(true); + api + .post("/auth/request-password-reset", { email: email.trim() }) + .then(() => { + setSubmitted(true); + toast(t("passwordResetRequestSent"), "success"); + }) + .catch((error: any) => { + toast(getApiErrorMessage(error, t("passwordResetRequestFailed")), "error"); + }) + .finally(() => setLoading(false)); + }} + sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} + > + {submitted ? {t("passwordResetRequestSent")} : null} + setEmail(e.target.value)} autoComplete="email" fullWidth /> + + + + + + + + + ); +} diff --git a/job-tracker-ui/src/pages/LoginPage.tsx b/job-tracker-ui/src/pages/LoginPage.tsx index d24fe9e..188fe8d 100644 --- a/job-tracker-ui/src/pages/LoginPage.tsx +++ b/job-tracker-ui/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; @@ -30,10 +30,8 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [rememberMe, setRememberMe] = useState(() => getRememberMePref()); const [loading, setLoading] = useState(false); - const [resetLoading, setResetLoading] = useState(false); const nextPath = (location?.state?.from as string | undefined) ?? "/jobs"; - const canRequestPasswordReset = useMemo(() => email.trim().length > 0, [email]); useEffect(() => { api @@ -58,23 +56,6 @@ export default function LoginPage() { } } - async function requestPasswordReset() { - if (!canRequestPasswordReset) { - toast(t("passwordResetEnterEmail"), "info"); - return; - } - - setResetLoading(true); - try { - await api.post("/auth/request-password-reset", { email: email.trim() }); - toast(t("passwordResetRequestSent"), "success"); - } catch (e: any) { - toast(getApiErrorMessage(e, t("passwordResetRequestFailed")), "error"); - } finally { - setResetLoading(false); - } - } - const allowReg = cfg?.allowRegistration ?? false; return ( @@ -116,11 +97,10 @@ export default function LoginPage() { type="button" variant="text" size="small" - onClick={() => void requestPasswordReset()} - disabled={resetLoading} + onClick={() => navigate(`/forgot-password${email.trim() ? `?email=${encodeURIComponent(email.trim())}` : ""}`)} sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }} > - {resetLoading ? t("passwordResetRequestSending") : t("forgotPassword")} + {t("forgotPassword")}