fix(i18n): preserve public auth commands and feedback across language changes
This commit is contained in:
@@ -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")}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
|
||||
@@ -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