fix(i18n): preserve public auth commands and feedback across language changes

This commit is contained in:
cesnimda
2026-11-04 01:44:39 +01:00
parent 368e90d572
commit fa6bd5d98e
20 changed files with 343 additions and 58 deletions
@@ -7,18 +7,20 @@ import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import AuthPageFrame from "../layout/AuthPageFrame";
export default function ForgotPasswordPage() {
const { toast } = useToast();
const { t } = useI18n();
const currentTranslation = useRef(t); currentTranslation.current = t;
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [emailError, setEmailError] = useState<string | undefined>(undefined);
const [submitError, setSubmitError] = useState<string | null>(null);
const [emailError, setEmailError] = useState<TranslationKey | undefined>(undefined);
const [submitError, setSubmitError] = useState<{ cause: unknown } | null>(null);
const emailRef = useRef<HTMLInputElement>(null);
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -35,12 +37,12 @@ export default function ForgotPasswordPage() {
e.preventDefault();
const trimmed = email.trim();
if (!trimmed) {
setEmailError(t("emailRequired"));
setEmailError("emailRequired");
emailRef.current?.focus();
return;
}
if (!EMAIL_PATTERN.test(trimmed)) {
setEmailError(t("invalidEmail"));
setEmailError("invalidEmail");
emailRef.current?.focus();
return;
}
@@ -52,17 +54,17 @@ export default function ForgotPasswordPage() {
.post("/auth/request-password-reset", { email: email.trim() })
.then(() => {
setSubmitted(true);
toast(t("passwordResetRequestSent"), "success");
toast(currentTranslation.current("passwordResetRequestSent"), "success");
})
.catch((error: any) => {
setSubmitError(getApiErrorMessage(error, t("passwordResetRequestFailed")));
setSubmitError({ cause: error });
})
.finally(() => setLoading(false));
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{submitted ? <Alert severity="success" role="status">{t("passwordResetRequestSent")}</Alert> : null}
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
{submitError ? <Alert severity="error">{getApiErrorMessage(submitError.cause, t("passwordResetRequestFailed"))}</Alert> : null}
<TextField
label={t("profileEmail")}
type="email"
@@ -72,7 +74,7 @@ export default function ForgotPasswordPage() {
autoFocus
inputRef={emailRef}
error={Boolean(emailError)}
helperText={emailError}
helperText={emailError ? t(emailError) : undefined}
fullWidth
/>
+19 -17
View File
@@ -12,6 +12,7 @@ import TwoFactorChallenge from "../components/TwoFactorChallenge";
import TurnstileWidget from "../components/TurnstileWidget";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import AuthPageFrame from "../layout/AuthPageFrame";
type AuthConfig = {
@@ -28,6 +29,7 @@ type AuthConfig = {
export default function LoginPage({ initialMode = "login" }: { initialMode?: "login" | "register" }) {
const { toast } = useToast();
const { t } = useI18n();
const currentTranslation = useRef(t); currentTranslation.current = t;
const navigate = useNavigate();
const location = useLocation() as any;
@@ -44,10 +46,10 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
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 [fieldErrors, setFieldErrors] = useState<{ email?: TranslationKey; password?: TranslationKey; confirmPassword?: TranslationKey }>({});
const [registerMode, setRegisterMode] = useState(initialMode === "register");
const [turnstileToken, setTurnstileToken] = useState("");
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<{ cause: unknown; fallback: TranslationKey } | null>(null);
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const confirmPasswordRef = useRef<HTMLInputElement>(null);
@@ -78,20 +80,20 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
async function completeLogin() {
setAuthPersistencePreference(rememberMe ? "local" : "session");
await api.get("/auth/me");
toast(t("signedIn"), "success");
toast(currentTranslation.current("signedIn"), "success");
navigate(nextPath, nextOptions);
}
function validate(mode: "login" | "register") {
const errors: { email?: string; password?: string; confirmPassword?: string } = {};
if (!email.trim()) errors.email = mode === "login" ? t("usernameOrEmailRequired") : t("emailRequired");
else if (mode === "register" && !EMAIL_PATTERN.test(email.trim())) errors.email = t("invalidEmail");
const errors: { email?: TranslationKey; password?: TranslationKey; confirmPassword?: TranslationKey } = {};
if (!email.trim()) errors.email = mode === "login" ? "usernameOrEmailRequired" : "emailRequired";
else if (mode === "register" && !EMAIL_PATTERN.test(email.trim())) errors.email = "invalidEmail";
if (!password) errors.password = t("passwordRequired");
else if (mode === "register" && password.length < 8) errors.password = t("passwordTooShort");
if (!password) errors.password = "passwordRequired";
else if (mode === "register" && password.length < 8) errors.password = "passwordTooShort";
if (mode === "register" && password && confirmPassword !== password) {
errors.confirmPassword = t("passwordsDoNotMatch");
errors.confirmPassword = "passwordsDoNotMatch";
}
setFieldErrors(errors);
@@ -113,7 +115,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string; verificationRequired?: boolean }>(url, payload);
if (res.data?.verificationRequired) {
setEmailNotVerified(true);
toast(t("registerCheckEmailForVerification"), "info");
toast(currentTranslation.current("registerCheckEmailForVerification"), "info");
return;
}
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
@@ -125,7 +127,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
if (mode === "login" && e?.response?.data?.error === "email_not_verified") {
setEmailNotVerified(true);
} else {
setSubmitError(getApiErrorMessage(e, t("loginFailed")));
setSubmitError({ cause: e, fallback: "loginFailed" });
}
} finally {
setLoading(false);
@@ -138,9 +140,9 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
try {
await api.post("/auth/resend-verification-email", { email });
setVerificationResent(true);
toast(t("verificationEmailResent"), "success");
toast(currentTranslation.current("verificationEmailResent"), "success");
} catch (e: any) {
setSubmitError(getApiErrorMessage(e, t("verifyEmailFailed")));
setSubmitError({ cause: e, fallback: "verifyEmailFailed" });
} finally {
setResendingVerification(false);
}
@@ -169,7 +171,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
{submitError ? <Alert severity="error">{getApiErrorMessage(submitError.cause, t(submitError.fallback))}</Alert> : null}
{registerMode && cfg && !allowReg ? <Alert severity="info">{t("registrationUnavailable")}</Alert> : null}
{cfg?.requireEmailVerification && emailNotVerified && (
<Alert
@@ -192,7 +194,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
autoFocus
inputRef={emailRef}
error={Boolean(fieldErrors.email)}
helperText={fieldErrors.email}
helperText={fieldErrors.email ? t(fieldErrors.email) : undefined}
fullWidth
/>
<TextField
@@ -203,7 +205,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
inputRef={passwordRef}
type="password"
error={Boolean(fieldErrors.password)}
helperText={fieldErrors.password || (registerMode ? t("passwordTooShort") : undefined)}
helperText={fieldErrors.password ? t(fieldErrors.password) : registerMode ? t("passwordTooShort") : undefined}
fullWidth
/>
{registerMode && (
@@ -215,7 +217,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
inputRef={confirmPasswordRef}
type="password"
error={Boolean(fieldErrors.confirmPassword)}
helperText={fieldErrors.confirmPassword}
helperText={fieldErrors.confirmPassword ? t(fieldErrors.confirmPassword) : undefined}
fullWidth
/>
)}
@@ -13,7 +13,7 @@ export default function MicrosoftLegacyRelinkPage() {
const navigate = useNavigate();
const [working, setWorking] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ cause: unknown } | null>(null);
const params = new URLSearchParams(window.location.search);
const userId = params.get("userId") || "";
const tenantId = params.get("tenantId") || "";
@@ -39,7 +39,7 @@ export default function MicrosoftLegacyRelinkPage() {
});
setSuccess(true);
} catch (e: any) {
setError(getApiErrorMessage(e, t("microsoftLegacyRelinkFailed")));
setError({ cause: e });
} finally {
setWorking(false);
}
@@ -48,7 +48,7 @@ export default function MicrosoftLegacyRelinkPage() {
return (
<AuthPageFrame title={t("microsoftLegacyRelinkTitle")} description={t("microsoftLegacyRelinkBody")}>
{missing ? <Alert severity="error" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkMissing")}</Alert> : null}
{error ? <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert> : null}
{error ? <Alert severity="error" sx={{ mt: 2 }}>{getApiErrorMessage(error.cause, t("microsoftLegacyRelinkFailed"))}</Alert> : null}
{success ? <Alert severity="success" role="status" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkSuccess")}</Alert> : null}
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 1, mt: 3, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button onClick={() => navigate("/login")}>{t("backToLogin")}</Button>
+12 -10
View File
@@ -7,11 +7,13 @@ import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import AuthPageFrame from "../layout/AuthPageFrame";
export default function ResetPasswordPage() {
const { toast } = useToast();
const { t } = useI18n();
const currentTranslation = useRef(t); currentTranslation.current = t;
const navigate = useNavigate();
const [email, setEmail] = useState("");
@@ -19,8 +21,8 @@ export default function ResetPasswordPage() {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{ newPassword?: string; confirmPassword?: string }>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<{ newPassword?: TranslationKey; confirmPassword?: TranslationKey }>({});
const [submitError, setSubmitError] = useState<{ cause: unknown } | null>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const confirmPasswordRef = useRef<HTMLInputElement>(null);
@@ -42,9 +44,9 @@ 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");
const errors: { newPassword?: TranslationKey; confirmPassword?: TranslationKey } = {};
if (!newPassword || newPassword.length < 8) errors.newPassword = "passwordTooShort";
if (confirmPassword !== newPassword) errors.confirmPassword = "passwordsDoNotMatch";
setFieldErrors(errors);
if (Object.keys(errors).length > 0) {
(errors.newPassword ? passwordRef : confirmPasswordRef).current?.focus();
@@ -56,18 +58,18 @@ export default function ResetPasswordPage() {
api
.post("/auth/reset-password", { email, token, newPassword })
.then(() => {
toast(t("passwordResetSuccess"), "success");
toast(currentTranslation.current("passwordResetSuccess"), "success");
navigate("/login", { replace: true });
})
.catch((e2: any) => {
setSubmitError(getApiErrorMessage(e2, t("resetFailed")));
setSubmitError({ cause: e2 });
})
.finally(() => setLoading(false));
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{missingResetInfo ? <Alert severity="warning">{t("missingResetLinkInfo")}</Alert> : null}
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
{submitError ? <Alert severity="error">{getApiErrorMessage(submitError.cause, t("resetFailed"))}</Alert> : null}
<TextField label={t("profileEmail")} type="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!missingResetInfo} fullWidth />
<TextField
label={t("profileNewPassword")}
@@ -77,7 +79,7 @@ export default function ResetPasswordPage() {
autoComplete="new-password"
inputRef={passwordRef}
error={Boolean(fieldErrors.newPassword)}
helperText={fieldErrors.newPassword || t("passwordTooShort")}
helperText={t(fieldErrors.newPassword ?? "passwordTooShort")}
fullWidth
/>
<TextField
@@ -88,7 +90,7 @@ export default function ResetPasswordPage() {
autoComplete="new-password"
inputRef={confirmPasswordRef}
error={Boolean(fieldErrors.confirmPassword)}
helperText={fieldErrors.confirmPassword}
helperText={fieldErrors.confirmPassword ? t(fieldErrors.confirmPassword) : undefined}
fullWidth
/>
+14 -7
View File
@@ -15,9 +15,13 @@ export default function VerifyEmailPage({ emailChange = false }: { emailChange?:
const navigate = useNavigate();
const [status, setStatus] = useState<Status>("verifying");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [verificationError, setVerificationError] = useState<unknown>(null);
const [missingLink, setMissingLink] = useState(false);
useEffect(() => {
setStatus("verifying");
setMissingLink(false);
setVerificationError(null);
const params = new URLSearchParams(window.location.search);
const userId = params.get("userId") || "";
const email = params.get("email") || "";
@@ -25,18 +29,21 @@ export default function VerifyEmailPage({ emailChange = false }: { emailChange?:
if (!userId || !token || (emailChange && !email)) {
setStatus("error");
setErrorMessage(t(emailChange ? "missingEmailChangeLinkInfo" : "missingVerifyLinkInfo"));
setMissingLink(true);
return;
}
let active = true;
api
.post(emailChange ? "/auth/email-change/confirm" : "/auth/verify-email", emailChange ? { userId, email, token } : { userId, token })
.then(() => setStatus("success"))
.catch((e: any) => {
.then(() => { if (active) setStatus("success"); })
.catch((cause: unknown) => {
if (!active) return;
setStatus("error");
setErrorMessage(getApiErrorMessage(e, t("verifyEmailFailed")));
setVerificationError(cause);
});
}, [emailChange, t]);
return () => { active = false; };
}, [emailChange]);
return (
<AuthPageFrame title={t(emailChange ? "confirmEmailChangeTitle" : "verifyEmailTitle")}>
@@ -48,7 +55,7 @@ export default function VerifyEmailPage({ emailChange = false }: { emailChange?:
</Box>
)}
{status === "success" && <Alert severity="success" role="status">{t(emailChange ? "confirmEmailChangeSuccess" : "verifyEmailSuccess")}</Alert>}
{status === "error" && <Alert severity="error">{errorMessage}</Alert>}
{status === "error" && <Alert severity="error">{missingLink ? t(emailChange ? "missingEmailChangeLinkInfo" : "missingVerifyLinkInfo") : getApiErrorMessage(verificationError, t("verifyEmailFailed"))}</Alert>}
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "flex-end", mt: 1, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button variant="contained" onClick={() => navigate("/login")}>