fix(i18n): preserve public auth commands and feedback across language changes
This commit is contained in:
@@ -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
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
Reference in New Issue
Block a user