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
@@ -20,7 +20,7 @@ export default function TwoFactorChallenge({
const [code, setCode] = useState("");
const [trustDevice, setTrustDevice] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ cause: unknown; rateLimited: boolean } | null>(null);
async function submit() {
setLoading(true);
@@ -29,11 +29,7 @@ export default function TwoFactorChallenge({
const res = await api.post<ChallengeResponse>("/auth/2fa/challenge", { pendingToken, code, trustDevice });
onSuccess(res.data);
} catch (e: any) {
if (e?.response?.status === 429) {
setError(t("twoFactorRateLimited"));
} else {
setError(getApiErrorMessage(e, t("twoFactorInvalidCode")));
}
setError({ cause: e, rateLimited: e?.response?.status === 429 });
} finally {
setLoading(false);
}
@@ -54,7 +50,7 @@ export default function TwoFactorChallenge({
{t("twoFactorHint")}
</Typography>
{error ? <Alert severity="error" role="alert">{error}</Alert> : null}
{error ? <Alert severity="error" role="alert">{error.rateLimited ? t("twoFactorRateLimited") : getApiErrorMessage(error.cause, t("twoFactorInvalidCode"))}</Alert> : null}
<TextField
label={t("twoFactorCodeLabel")}
+24 -1
View File
@@ -7,13 +7,14 @@ import { I18nProvider, useI18n } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
function Probe() {
const { language, setLanguage, hydrateLanguage, t } = useI18n();
const { language, setLanguage, setLocalLanguage, hydrateLanguage, t } = useI18n();
return (
<div>
<output aria-label="language">{language}</output>
<output aria-label="settings-label">{t("settings")}</output>
<button onClick={() => setLanguage("en")}>English</button>
<button onClick={() => setLanguage("nb")}>Norsk</button>
<button onClick={() => setLocalLanguage("nb")}>Local Norsk</button>
<button onClick={() => hydrateLanguage("nb-NO")}>Restore server preference</button>
</div>
);
@@ -46,6 +47,28 @@ test("hydrates an authenticated server preference without writing it back", () =
expect(api.put).not.toHaveBeenCalled();
});
test("a public local-only choice persists without authenticated requests", () => {
render(<I18nProvider><Probe /></I18nProvider>);
fireEvent.click(screen.getByRole("button", { name: "Local Norsk" }));
expect(screen.getByLabelText("language")).toHaveTextContent("nb");
expect(localStorage.getItem("uiLanguage")).toBe("nb");
expect(document.documentElement.lang).toBe("nb-NO");
expect(api.put).not.toHaveBeenCalled();
expect(api.get).not.toHaveBeenCalled();
});
test("a local-only choice invalidates an older same-language account-save retry", async () => {
let rejectOld!: (cause: Error) => void;
(api.put as jest.Mock).mockImplementationOnce(() => new Promise((_, reject) => { rejectOld = reject; }));
render(<ToastProvider><I18nProvider><Probe /></I18nProvider></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: "Norsk" }));
fireEvent.click(screen.getByRole("button", { name: "Local Norsk" }));
await act(async () => rejectOld(new Error("offline")));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(api.put).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText("language")).toHaveTextContent("nb");
});
test.each([
["Norsk", "nb", "nb-NO", "Språket er endret på denne enheten, men kunne ikke lagres på kontoen din.", "Prøv igjen"],
["English", "en", "en", "The language changed on this device, but your account preference could not be saved.", "Retry"],
+9 -3
View File
@@ -8,6 +8,7 @@ type TranslationParams = Record<string, string | number>;
type Ctx = {
language: UiLanguage;
setLanguage: (l: UiLanguage) => void;
setLocalLanguage: (l: UiLanguage) => void;
hydrateLanguage: (language?: string | null) => void;
t: (key: TranslationKey, params?: TranslationParams) => string;
};
@@ -35,9 +36,14 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
document.documentElement.lang = l === "nb" ? "nb-NO" : "en";
}, []);
const setLanguage = (l: UiLanguage) => {
const setLocalLanguage = (l: UiLanguage) => {
++languageSaveSequence.current;
applyLanguage(l);
const requestSequence = ++languageSaveSequence.current;
};
const setLanguage = (l: UiLanguage) => {
setLocalLanguage(l);
const requestSequence = languageSaveSequence.current;
void api.put("/auth/preferences/language", { language: l === "nb" ? "nb-NO" : "en" }).catch(() => {
if (requestSequence !== languageSaveSequence.current || currentLanguage.current !== l) return;
feedback?.toast(translations[l].settingsLanguageSaveFailed, "error", {
@@ -66,7 +72,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
const t = (key: TranslationKey, params?: TranslationParams) => interpolate(translations[language][key] ?? translations.en[key], params);
return <I18nContext.Provider value={{ language, setLanguage, hydrateLanguage, t }}>{children}</I18nContext.Provider>;
return <I18nContext.Provider value={{ language, setLanguage, setLocalLanguage, hydrateLanguage, t }}>{children}</I18nContext.Provider>;
}
export function useI18n() {
+6 -1
View File
@@ -2,6 +2,8 @@ import React, { useId } from "react";
import { Box, Paper } from "@mui/material";
import { PageHeader } from "./PageLayout";
import { StackLanguageToggle } from "./AppShell";
import { useI18n } from "../i18n/I18nProvider";
// Public auth pages own their forms, commands and token lifecycles.
export default function AuthPageFrame({ title, description, children }: {
@@ -10,6 +12,7 @@ export default function AuthPageFrame({ title, description, children }: {
children: React.ReactNode;
}) {
const titleId = useId();
const { language, setLocalLanguage } = useI18n();
return (
<Box component="main" aria-labelledby={titleId} sx={{
minHeight: "100svh", display: "grid", placeItems: "center", boxSizing: "border-box",
@@ -18,8 +21,10 @@ export default function AuthPageFrame({ title, description, children }: {
<Paper elevation={0} sx={{
width: "100%", maxWidth: 520, minWidth: 0, boxSizing: "border-box",
p: "var(--app-card-padding, 16px)", border: "1px solid", borderColor: "divider",
"& > header": { flexWrap: "nowrap", gap: 1 },
"& > header > div:last-child": { flexShrink: 0 },
}}>
<PageHeader title={title} titleId={titleId} description={description} />
<PageHeader title={title} titleId={titleId} description={description} actions={<StackLanguageToggle language={language} setLanguage={setLocalLanguage} />} />
<Box sx={{ mt: "var(--app-section-gap, 24px)", minWidth: 0 }}>{children}</Box>
</Paper>
</Box>
@@ -11,6 +11,7 @@ import LoginPage from './views/LoginPage';
import MicrosoftLegacyRelinkPage from './views/MicrosoftLegacyRelinkPage';
import ResetPasswordPage from './views/ResetPasswordPage';
import VerifyEmailPage from './views/VerifyEmailPage';
import { translations } from './i18n/translations';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -47,6 +48,100 @@ beforeEach(() => {
});
describe('public auth presentation', () => {
it.each([
{ view: <LoginPage />, path: '/login', kind: 'login', messageKey: 'signedIn' as const },
{ view: <ForgotPasswordPage />, path: '/forgot-password', kind: 'request', messageKey: 'passwordResetRequestSent' as const },
{ view: <ResetPasswordPage />, path: '/reset-password?email=synthetic%40example.test&token=synthetic', kind: 'reset', messageKey: 'passwordResetSuccess' as const },
])('announces a pending $kind result in the current UI language without replay', async ({ view, path, kind, messageKey }) => {
const pending = deferred(); mockedApi.post.mockReturnValue(pending.promise);
mount(view, path);
if (kind === 'login') {
await screen.findByText('Authentication is required to use this app.');
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'synthetic@example.test' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Synthetic pending example' } });
} else if (kind === 'request') fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'synthetic@example.test' } });
else {
fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'Synthetic pending example' } });
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'Synthetic pending example' } });
}
submit();
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
await act(async () => pending.resolve({ data: {} }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb[messageKey]);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it.each([
{ view: <LoginPage />, path: '/login', errorKey: 'usernameOrEmailRequired' as const },
{ view: <LoginPage initialMode="register" />, path: '/register', errorKey: 'emailRequired' as const },
{ view: <ForgotPasswordPage />, path: '/forgot-password', errorKey: 'emailRequired' as const },
{ view: <ResetPasswordPage />, path: '/reset-password?email=synthetic%40example.test&token=synthetic', errorKey: 'passwordTooShort' as const },
])('translates existing client validation on $path without submitting again', async ({ view, path, errorKey }) => {
mount(view, path);
if (path === '/login' || path === '/register') await screen.findByText('Authentication is required to use this app.');
submit();
expect(await screen.findByText(translations.en[errorKey])).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
expect(screen.getByText(translations.nb[errorKey])).toBeVisible();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it.each([
{ view: <LoginPage />, path: '/login', kind: 'login', errorKey: 'loginFailed' as const },
{ view: <ForgotPasswordPage />, path: '/forgot-password', kind: 'request', errorKey: 'passwordResetRequestFailed' as const },
{ view: <ResetPasswordPage />, path: '/reset-password?email=synthetic%40example.test&token=synthetic', kind: 'reset', errorKey: 'resetFailed' as const },
])('translates a failed $kind fallback while keeping its submitted draft and request count', async ({ view, path, kind, errorKey }) => {
mockedApi.post.mockRejectedValue({ response: { status: 503, data: '' } });
mount(view, path);
if (kind === 'login') {
await screen.findByText('Authentication is required to use this app.');
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'synthetic@example.test' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Synthetic rejected example' } });
} else if (kind === 'request') fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'synthetic@example.test' } });
else {
fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'Synthetic rejected example' } });
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'Synthetic rejected example' } });
}
submit();
expect(await screen.findByRole('alert')).toHaveTextContent(translations.en[errorKey]);
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb[errorKey]);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText(kind === 'login' ? translations.nb.usernameOrEmail : kind === 'request' ? translations.nb.profileEmail : translations.nb.profileNewPassword)).toHaveValue(kind === 'reset' ? 'Synthetic rejected example' : 'synthetic@example.test');
});
it('translates an MFA rate limit without another challenge or losing the entered code', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { requiresTwoFactor: true, pendingToken: 'synthetic-pending' } } as any).mockRejectedValueOnce({ response: { status: 429 } });
mount(<LoginPage />, '/login');
await screen.findByText('Authentication is required to use this app.');
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'synthetic@example.test' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Synthetic challenge example' } });
submit();
const code = await screen.findByLabelText(translations.en.twoFactorCodeLabel);
fireEvent.change(code, { target: { value: '000000' } });
submit();
expect(await screen.findByRole('alert')).toHaveTextContent(translations.en.twoFactorRateLimited);
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.twoFactorRateLimited);
expect(screen.getByLabelText(translations.nb.twoFactorCodeLabel)).toHaveValue('000000');
expect(mockedApi.post).toHaveBeenCalledTimes(2);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('lets a signed-out user choose a persisted UI language without discarding a sign-in draft', async () => {
mount(<LoginPage />, '/login');
await screen.findByText('Authentication is required to use this app.');
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'synthetic@example.test' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Unsubmitted synthetic example' } });
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
expect(screen.getByRole('heading', { level: 1, name: translations.nb.signInTitle })).toBeVisible();
expect(screen.getByLabelText(translations.nb.usernameOrEmail)).toHaveValue('synthetic@example.test');
expect(screen.getByLabelText(translations.nb.profileCurrentPassword)).toHaveValue('Unsubmitted synthetic example');
expect(localStorage.getItem('uiLanguage')).toBe('nb');
expect(mockedApi.get).toHaveBeenCalledTimes(1);
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
it.each([
{ mode: 'login' as const, label: 'Current password', autocomplete: 'current-password', absent: 'New password' },
{ mode: 'register' as const, label: 'New password', autocomplete: 'new-password', absent: 'Current password' },
+48 -1
View File
@@ -1,11 +1,12 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import VerifyEmailPage from './views/VerifyEmailPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api, getApiErrorMessage } from './api';
import { translations } from './i18n/translations';
const mockedApi = api as jest.Mocked<typeof api>;
// CRA's jest config sets resetMocks: true, which wipes the initial implementation given to
@@ -26,6 +27,7 @@ function renderVerifyEmailPage(search: string, emailChange = false) {
describe('VerifyEmailPage', () => {
beforeEach(() => {
localStorage.clear();
mockedApi.post.mockReset();
mockedGetApiErrorMessage.mockImplementation((e: any, fallback?: string) => {
const data = e?.response?.data;
@@ -65,4 +67,49 @@ describe('VerifyEmailPage', () => {
expect(await screen.findByText('Your email has changed. Sign in again with the new address.')).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith('/auth/email-change/confirm', { userId: 'user-1', email: 'new@example.com', token: 'change-token' });
});
it.each([false, true])('changing UI language never resubmits a pending confirmation: emailChange=%s', async emailChange => {
let finish!: (value: any) => void;
mockedApi.post.mockReturnValue(new Promise(resolve => { finish = resolve; }));
renderVerifyEmailPage('?userId=user-1&email=new%40example.test&token=one-use', emailChange);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
const pendingCalls = mockedApi.post.mock.calls.length;
await act(async () => finish({ data: {} }));
expect(pendingCalls).toBe(1);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(screen.getByRole('status')).toHaveTextContent(translations.nb[emailChange ? 'confirmEmailChangeSuccess' : 'verifyEmailSuccess']);
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'en' })));
expect(screen.getByRole('status')).toHaveTextContent(translations.en[emailChange ? 'confirmEmailChangeSuccess' : 'verifyEmailSuccess']);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it('localizes missing-link errors without a confirmation request', async () => {
renderVerifyEmailPage('');
expect(await screen.findByText(translations.en.missingVerifyLinkInfo)).toBeInTheDocument();
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.missingVerifyLinkInfo);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('localizes a failed confirmation fallback without retrying the one-time command', async () => {
mockedApi.post.mockRejectedValue({ response: { status: 503, data: '' } });
renderVerifyEmailPage('?userId=user-1&token=one-use');
expect(await screen.findByText(translations.en.verifyEmailFailed)).toBeInTheDocument();
await act(async () => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.verifyEmailFailed);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it('does not let an older confirmation replace a different flow missing-link error', async () => {
let finish!: (value: any) => void;
mockedApi.post.mockReturnValue(new Promise(resolve => { finish = resolve; }));
const view = renderVerifyEmailPage('?userId=user-1&token=one-use');
window.history.replaceState({}, '', '/confirm-email-change?userId=user-1&token=one-use');
view.rerender(<MemoryRouter><I18nProvider><VerifyEmailPage emailChange /></I18nProvider></MemoryRouter>);
expect(await screen.findByText(translations.en.missingEmailChangeLinkInfo)).toBeInTheDocument();
await act(async () => finish({ data: {} }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.en.missingEmailChangeLinkInfo);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
});
@@ -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")}>