fix(auth): polish login/register/reset pages for consistency and accessibility
CI and Deploy / test (push) Successful in 2m9s
CI and Deploy / deploy (push) Successful in 39s

- 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:
cesnimda
2026-07-13 08:34:17 +02:00
parent 706b3ec699
commit b8b7987c58
4 changed files with 160 additions and 35 deletions
+96 -27
View File
@@ -32,12 +32,17 @@ export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
const [loading, setLoading] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const [emailNotVerified, setEmailNotVerified] = useState(false);
const [resendingVerification, setResendingVerification] = 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";
@@ -55,7 +60,24 @@ export default function LoginPage() {
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") {
if (!validate(mode)) return;
setLoading(true);
setEmailNotVerified(false);
setVerificationResent(false);
@@ -131,7 +153,11 @@ export default function LoginPage() {
</Tabs>
{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 && (
<Alert
severity="warning"
@@ -144,38 +170,81 @@ export default function LoginPage() {
{t("emailNotVerified")}
</Alert>
)}
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<FormControlLabel
control={<Checkbox disableRipple checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
label={t("rememberMe")}
<TextField
label={t("profileEmail")}
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setFieldErrors((f) => ({ ...f, email: undefined })); }}
autoComplete="email"
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 }}>
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
</Typography>
{!registerMode && (
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<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 && (
<Button type="button" variant="outlined" disableRipple disabled={loading} onClick={() => void submit("register")}>
{t("createAccount")}
<Button
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 type="submit" variant="contained" disableRipple disabled={loading}>
{t("signInTitle")}
<Button type="submit" variant="contained" disableRipple disabled={loading} sx={{ ml: "auto" }}>
{registerMode ? t("createAccount") : t("signInTitle")}
</Button>
</Box>
</Box>