From b8b7987c588fe0d65e34812f015f2ba3e0d17688 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 08:34:17 +0200 Subject: [PATCH] fix(auth): polish login/register/reset pages for consistency and accessibility - LoginPage: add client-side email/password validation (inline error + helperText, matching the 2FA components' established pattern), and a proper register-mode toggle with a "Confirm password" field. The brief asked for confirm-password on registration but the page only had one shared password field; a toggle (mirroring the existing Tabs-for-mode pattern already used for Google/Microsoft) keeps this from cluttering the login form for returning users. - Fix a real bug in ResetPasswordPage: it didn't use the app's getApiErrorMessage helper, so a non-string error response body would render as "[object Object]" in the toast. Also add a confirm-password field and matching client-side validation for parity with register. - ForgotPasswordPage: add proper email format validation instead of only checking for non-empty. - Add matching i18n keys (en/no) for every new validation message. Verified live end-to-end against a running backend: register-mode toggle, confirm-password mismatch blocking submission client-side, and a full registration completing and landing on the dashboard. --- job-tracker-ui/src/i18n/translations.ts | 12 ++ .../src/views/ForgotPasswordPage.tsx | 24 +++- job-tracker-ui/src/views/LoginPage.tsx | 123 ++++++++++++++---- .../src/views/ResetPasswordPage.tsx | 36 ++++- 4 files changed, 160 insertions(+), 35 deletions(-) diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 295af21..6268bcf 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -768,6 +768,12 @@ export const translations = { passwordResetRequestSent: "If that account exists, a reset link has been sent.", passwordResetRequestFailed: "Could not send the reset link.", loginFailed: "Login failed.", + emailRequired: "Enter your email address.", + invalidEmail: "Enter a valid email address.", + passwordRequired: "Enter your password.", + passwordTooShort: "Password must be at least 8 characters, with a number and a lowercase letter.", + confirmPassword: "Confirm password", + passwordsDoNotMatch: "Passwords do not match.", resetPasswordTitle: "Reset password", resetPasswordBody: "Set a new password for your account.", missingResetLinkInfo: "Missing email/token in link.", @@ -1816,6 +1822,12 @@ export const translations = { passwordResetRequestSent: "Hvis kontoen finnes, er en nullstillingslenke sendt.", passwordResetRequestFailed: "Kunne ikke sende nullstillingslenken.", loginFailed: "Innlogging mislyktes.", + emailRequired: "Skriv inn e-postadressen din.", + invalidEmail: "Skriv inn en gyldig e-postadresse.", + passwordRequired: "Skriv inn passordet ditt.", + passwordTooShort: "Passordet må være minst 8 tegn, med et tall og en liten bokstav.", + confirmPassword: "Bekreft passord", + passwordsDoNotMatch: "Passordene stemmer ikke overens.", resetPasswordTitle: "Tilbakestill passord", resetPasswordBody: "Sett et nytt passord for kontoen din.", missingResetLinkInfo: "Mangler e-post/token i lenken.", diff --git a/job-tracker-ui/src/views/ForgotPasswordPage.tsx b/job-tracker-ui/src/views/ForgotPasswordPage.tsx index 239dfa9..f9a63e9 100644 --- a/job-tracker-ui/src/views/ForgotPasswordPage.tsx +++ b/job-tracker-ui/src/views/ForgotPasswordPage.tsx @@ -16,6 +16,8 @@ export default function ForgotPasswordPage() { const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [submitted, setSubmitted] = useState(false); + const [emailError, setEmailError] = useState(undefined); + const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -46,10 +48,16 @@ export default function ForgotPasswordPage() { component="form" onSubmit={(e) => { e.preventDefault(); - if (!email.trim()) { - toast(t("passwordResetEnterEmail"), "info"); + const trimmed = email.trim(); + if (!trimmed) { + setEmailError(t("emailRequired")); return; } + if (!EMAIL_PATTERN.test(trimmed)) { + setEmailError(t("invalidEmail")); + return; + } + setEmailError(undefined); setLoading(true); api .post("/auth/request-password-reset", { email: email.trim() }) @@ -65,7 +73,17 @@ export default function ForgotPasswordPage() { sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} > {submitted ? {t("passwordResetRequestSent")} : null} - setEmail(e.target.value)} autoComplete="email" fullWidth /> + { setEmail(e.target.value); setEmailError(undefined); }} + autoComplete="email" + autoFocus + error={Boolean(emailError)} + helperText={emailError} + fullWidth + /> - + )} - - {rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")} - + {!registerMode && ( + + setRememberMe(e.target.checked)} />} + label={t("rememberMe")} + /> + + + )} - + {!registerMode && ( + + {rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")} + + )} + + {allowReg && ( - )} - diff --git a/job-tracker-ui/src/views/ResetPasswordPage.tsx b/job-tracker-ui/src/views/ResetPasswordPage.tsx index 5526a5e..53be715 100644 --- a/job-tracker-ui/src/views/ResetPasswordPage.tsx +++ b/job-tracker-ui/src/views/ResetPasswordPage.tsx @@ -4,7 +4,7 @@ import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material" import { useNavigate } from "react-router-dom"; -import { api } from "../api"; +import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -16,7 +16,9 @@ export default function ResetPasswordPage() { const [email, setEmail] = useState(""); const [token, setToken] = useState(""); const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); + const [fieldErrors, setFieldErrors] = useState<{ newPassword?: string; confirmPassword?: string }>({}); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -54,6 +56,12 @@ export default function ResetPasswordPage() { toast(t("missingResetLinkInfo"), "error"); return; } + const errors: { newPassword?: string; confirmPassword?: string } = {}; + if (!newPassword || newPassword.length < 8) errors.newPassword = t("passwordTooShort"); + if (confirmPassword !== newPassword) errors.confirmPassword = t("passwordsDoNotMatch"); + setFieldErrors(errors); + if (Object.keys(errors).length > 0) return; + setLoading(true); api .post("/auth/reset-password", { email, token, newPassword }) @@ -62,16 +70,34 @@ export default function ResetPasswordPage() { navigate("/login", { replace: true }); }) .catch((e2: any) => { - const msg = e2?.response?.data || e2?.message || t("resetFailed"); - toast(String(msg), "error"); + toast(getApiErrorMessage(e2, t("resetFailed")), "error"); }) .finally(() => setLoading(false)); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} > {missingResetInfo ? {t("missingResetLinkInfo")} : null} - setEmail(e.target.value)} disabled={!missingResetInfo} fullWidth /> - setNewPassword(e.target.value)} fullWidth /> + setEmail(e.target.value)} disabled={!missingResetInfo} fullWidth /> + { setNewPassword(e.target.value); setFieldErrors((f) => ({ ...f, newPassword: undefined, confirmPassword: undefined })); }} + autoComplete="new-password" + error={Boolean(fieldErrors.newPassword)} + helperText={fieldErrors.newPassword || t("passwordTooShort")} + fullWidth + /> + { setConfirmPassword(e.target.value); setFieldErrors((f) => ({ ...f, confirmPassword: undefined })); }} + autoComplete="new-password" + error={Boolean(fieldErrors.confirmPassword)} + helperText={fieldErrors.confirmPassword} + fullWidth + />