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.
This commit is contained in:
@@ -768,6 +768,12 @@ export const translations = {
|
|||||||
passwordResetRequestSent: "If that account exists, a reset link has been sent.",
|
passwordResetRequestSent: "If that account exists, a reset link has been sent.",
|
||||||
passwordResetRequestFailed: "Could not send the reset link.",
|
passwordResetRequestFailed: "Could not send the reset link.",
|
||||||
loginFailed: "Login failed.",
|
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",
|
resetPasswordTitle: "Reset password",
|
||||||
resetPasswordBody: "Set a new password for your account.",
|
resetPasswordBody: "Set a new password for your account.",
|
||||||
missingResetLinkInfo: "Missing email/token in link.",
|
missingResetLinkInfo: "Missing email/token in link.",
|
||||||
@@ -1816,6 +1822,12 @@ export const translations = {
|
|||||||
passwordResetRequestSent: "Hvis kontoen finnes, er en nullstillingslenke sendt.",
|
passwordResetRequestSent: "Hvis kontoen finnes, er en nullstillingslenke sendt.",
|
||||||
passwordResetRequestFailed: "Kunne ikke sende nullstillingslenken.",
|
passwordResetRequestFailed: "Kunne ikke sende nullstillingslenken.",
|
||||||
loginFailed: "Innlogging mislyktes.",
|
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",
|
resetPasswordTitle: "Tilbakestill passord",
|
||||||
resetPasswordBody: "Sett et nytt passord for kontoen din.",
|
resetPasswordBody: "Sett et nytt passord for kontoen din.",
|
||||||
missingResetLinkInfo: "Mangler e-post/token i lenken.",
|
missingResetLinkInfo: "Mangler e-post/token i lenken.",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default function ForgotPasswordPage() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [emailError, setEmailError] = useState<string | undefined>(undefined);
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -46,10 +48,16 @@ export default function ForgotPasswordPage() {
|
|||||||
component="form"
|
component="form"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!email.trim()) {
|
const trimmed = email.trim();
|
||||||
toast(t("passwordResetEnterEmail"), "info");
|
if (!trimmed) {
|
||||||
|
setEmailError(t("emailRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!EMAIL_PATTERN.test(trimmed)) {
|
||||||
|
setEmailError(t("invalidEmail"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmailError(undefined);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api
|
api
|
||||||
.post("/auth/request-password-reset", { email: email.trim() })
|
.post("/auth/request-password-reset", { email: email.trim() })
|
||||||
@@ -65,7 +73,17 @@ export default function ForgotPasswordPage() {
|
|||||||
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
||||||
>
|
>
|
||||||
{submitted ? <Alert severity="success">{t("passwordResetRequestSent")}</Alert> : null}
|
{submitted ? <Alert severity="success">{t("passwordResetRequestSent")}</Alert> : null}
|
||||||
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
|
<TextField
|
||||||
|
label={t("profileEmail")}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); setEmailError(undefined); }}
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
error={Boolean(emailError)}
|
||||||
|
helperText={emailError}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1 }}>
|
||||||
<Button type="button" variant="outlined" onClick={() => navigate("/login")} disabled={loading}>
|
<Button type="button" variant="outlined" onClick={() => navigate("/login")} disabled={loading}>
|
||||||
|
|||||||
@@ -32,12 +32,17 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pendingToken, setPendingToken] = useState<string | null>(null);
|
const [pendingToken, setPendingToken] = useState<string | null>(null);
|
||||||
const [emailNotVerified, setEmailNotVerified] = useState(false);
|
const [emailNotVerified, setEmailNotVerified] = useState(false);
|
||||||
const [resendingVerification, setResendingVerification] = useState(false);
|
const [resendingVerification, setResendingVerification] = useState(false);
|
||||||
const [verificationResent, setVerificationResent] = useState(false);
|
const [verificationResent, setVerificationResent] = useState(false);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
|
||||||
|
const [registerMode, setRegisterMode] = useState(false);
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
|
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
|
||||||
|
|
||||||
@@ -55,7 +60,24 @@ export default function LoginPage() {
|
|||||||
navigate(nextPath, { replace: true });
|
navigate(nextPath, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validate(mode: "login" | "register") {
|
||||||
|
const errors: { email?: string; password?: string; confirmPassword?: string } = {};
|
||||||
|
if (!email.trim()) errors.email = t("emailRequired");
|
||||||
|
else if (!EMAIL_PATTERN.test(email.trim())) errors.email = t("invalidEmail");
|
||||||
|
|
||||||
|
if (!password) errors.password = t("passwordRequired");
|
||||||
|
else if (mode === "register" && password.length < 8) errors.password = t("passwordTooShort");
|
||||||
|
|
||||||
|
if (mode === "register" && password && confirmPassword !== password) {
|
||||||
|
errors.confirmPassword = t("passwordsDoNotMatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
async function submit(mode: "login" | "register") {
|
async function submit(mode: "login" | "register") {
|
||||||
|
if (!validate(mode)) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setEmailNotVerified(false);
|
setEmailNotVerified(false);
|
||||||
setVerificationResent(false);
|
setVerificationResent(false);
|
||||||
@@ -131,7 +153,11 @@ export default function LoginPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{tab === 0 && (
|
{tab === 0 && (
|
||||||
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
|
||||||
|
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
||||||
|
>
|
||||||
{cfg?.requireEmailVerification && emailNotVerified && (
|
{cfg?.requireEmailVerification && emailNotVerified && (
|
||||||
<Alert
|
<Alert
|
||||||
severity="warning"
|
severity="warning"
|
||||||
@@ -144,38 +170,81 @@ export default function LoginPage() {
|
|||||||
{t("emailNotVerified")}
|
{t("emailNotVerified")}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
|
<TextField
|
||||||
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
|
label={t("profileEmail")}
|
||||||
|
type="email"
|
||||||
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
value={email}
|
||||||
<FormControlLabel
|
onChange={(e) => { setEmail(e.target.value); setFieldErrors((f) => ({ ...f, email: undefined })); }}
|
||||||
control={<Checkbox disableRipple checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
|
autoComplete="email"
|
||||||
label={t("rememberMe")}
|
autoFocus
|
||||||
|
error={Boolean(fieldErrors.email)}
|
||||||
|
helperText={fieldErrors.email}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t("profileCurrentPassword")}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => { setPassword(e.target.value); setFieldErrors((f) => ({ ...f, password: undefined, confirmPassword: undefined })); }}
|
||||||
|
autoComplete={registerMode ? "new-password" : "current-password"}
|
||||||
|
type="password"
|
||||||
|
error={Boolean(fieldErrors.password)}
|
||||||
|
helperText={fieldErrors.password || (registerMode ? t("passwordTooShort") : undefined)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
{registerMode && (
|
||||||
|
<TextField
|
||||||
|
label={t("confirmPassword")}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => { setConfirmPassword(e.target.value); setFieldErrors((f) => ({ ...f, confirmPassword: undefined })); }}
|
||||||
|
autoComplete="new-password"
|
||||||
|
type="password"
|
||||||
|
error={Boolean(fieldErrors.confirmPassword)}
|
||||||
|
helperText={fieldErrors.confirmPassword}
|
||||||
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<Button
|
)}
|
||||||
type="button"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
disableRipple
|
|
||||||
onClick={() => navigate(`/forgot-password${email.trim() ? `?email=${encodeURIComponent(email.trim())}` : ""}`)}
|
|
||||||
sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }}
|
|
||||||
>
|
|
||||||
{t("forgotPassword")}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: -0.5 }}>
|
{!registerMode && (
|
||||||
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
|
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||||
</Typography>
|
<FormControlLabel
|
||||||
|
control={<Checkbox disableRipple checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
|
||||||
|
label={t("rememberMe")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
disableRipple
|
||||||
|
onClick={() => navigate(`/forgot-password${email.trim() ? `?email=${encodeURIComponent(email.trim())}` : ""}`)}
|
||||||
|
sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }}
|
||||||
|
>
|
||||||
|
{t("forgotPassword")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
|
{!registerMode && (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: -0.5 }}>
|
||||||
|
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||||
{allowReg && (
|
{allowReg && (
|
||||||
<Button type="button" variant="outlined" disableRipple disabled={loading} onClick={() => void submit("register")}>
|
<Button
|
||||||
{t("createAccount")}
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
disableRipple
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => { setRegisterMode((v) => !v); setFieldErrors({}); }}
|
||||||
|
sx={{ px: 0, minWidth: 0, fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{registerMode ? t("backToLogin") : t("createAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" variant="contained" disableRipple disabled={loading}>
|
<Button type="submit" variant="contained" disableRipple disabled={loading} sx={{ ml: "auto" }}>
|
||||||
{t("signInTitle")}
|
{registerMode ? t("createAccount") : t("signInTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material"
|
|||||||
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ export default function ResetPasswordPage() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<{ newPassword?: string; confirmPassword?: string }>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -54,6 +56,12 @@ export default function ResetPasswordPage() {
|
|||||||
toast(t("missingResetLinkInfo"), "error");
|
toast(t("missingResetLinkInfo"), "error");
|
||||||
return;
|
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);
|
setLoading(true);
|
||||||
api
|
api
|
||||||
.post("/auth/reset-password", { email, token, newPassword })
|
.post("/auth/reset-password", { email, token, newPassword })
|
||||||
@@ -62,16 +70,34 @@ export default function ResetPasswordPage() {
|
|||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
})
|
})
|
||||||
.catch((e2: any) => {
|
.catch((e2: any) => {
|
||||||
const msg = e2?.response?.data || e2?.message || t("resetFailed");
|
toast(getApiErrorMessage(e2, t("resetFailed")), "error");
|
||||||
toast(String(msg), "error");
|
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}}
|
}}
|
||||||
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
||||||
>
|
>
|
||||||
{missingResetInfo ? <Alert severity="warning">{t("missingResetLinkInfo")}</Alert> : null}
|
{missingResetInfo ? <Alert severity="warning">{t("missingResetLinkInfo")}</Alert> : null}
|
||||||
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} disabled={!missingResetInfo} fullWidth />
|
<TextField label={t("profileEmail")} type="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!missingResetInfo} fullWidth />
|
||||||
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} fullWidth />
|
<TextField
|
||||||
|
label={t("profileNewPassword")}
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => { setNewPassword(e.target.value); setFieldErrors((f) => ({ ...f, newPassword: undefined, confirmPassword: undefined })); }}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={Boolean(fieldErrors.newPassword)}
|
||||||
|
helperText={fieldErrors.newPassword || t("passwordTooShort")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t("confirmPassword")}
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => { setConfirmPassword(e.target.value); setFieldErrors((f) => ({ ...f, confirmPassword: undefined })); }}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={Boolean(fieldErrors.confirmPassword)}
|
||||||
|
helperText={fieldErrors.confirmPassword}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1 }}>
|
||||||
<Button type="button" variant="outlined" onClick={() => navigate("/login")} disabled={loading}>
|
<Button type="button" variant="outlined" onClick={() => navigate("/login")} disabled={loading}>
|
||||||
|
|||||||
Reference in New Issue
Block a user