fix(i18n): preserve public auth commands and feedback across language changes
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
# Public sign-in and recovery language
|
||||
|
||||
4 November 2026. Successor to account/editor receipt `368e90d57206df00204f16ced78d9379706a6878`. Existing AuthPageFrame now exposes the established EN/NB control across login, registration, password recovery/reset, verification, email-change confirmation and Microsoft legacy recovery. Public selection changes browser preference and document language without authenticated preference requests. Authenticated preference saves and server hydration retain their existing owners. No new dependency, server endpoint, token contract or provider is introduced.
|
||||
|
||||
Locale changes preserve drafts. Client validation stores finite translation keys; fallback errors are formatted using the current locale while retaining the original server error. Pending success feedback uses the latest translator. Verification commands depend on their command flow rather than translated function identity, and ignore stale completion after a flow change. This prevents language-triggered duplicate POSTs; it does not promise deduplication across independent remounts or replace server-side one-time-token enforcement.
|
||||
|
||||
PASSED:94 selected frontend cases/eight suites, TypeScript, full lint and static export build. Independent read-only source review found no actionable findings and performed no tests. Retained RED reports reproduce duplicate verification, stale validation/fallback/MFA errors, stale asynchronous feedback and missing local-only save behavior. The320px pilot passed14 accessibility scans/captures and one synthetic journey after correcting an ambiguous verifier alert locator: application alerts are scoped to public main, leaving the Next route announcer intact. Original locator-failure report is retained. Full nine-width EN/NB/light/dark matrix remains pending at this source checkpoint.
|
||||
|
||||
New dynamic translation review (separate from188 historical reviews): Login render keys are assigned only by local validation to `usernameOrEmailRequired`, `emailRequired`, `invalidEmail`, `passwordRequired`, `passwordTooShort`, `passwordsDoNotMatch`; submit fallback is only `loginFailed` or `verifyEmailFailed`. Forgot-password email key is only `emailRequired` or `invalidEmail`; reset-password keys are only `passwordTooShort` or `passwordsDoNotMatch`. Verification error selection is the literal pair `missingEmailChangeLinkInfo` / `missingVerifyLinkInfo`; existing title/progress/success pairs remain finite. No API text or user input is used as a translation key. TranslationKey typing, current-locale regression cases and build validate catalogue membership. Generated-content language is unchanged.
|
||||
|
||||
Limits: synthetic intercepted responses only; no real sign-in, reset, verification, email delivery, MFA enforcement, SSO, provider or model activation. Full route visual acceptance, human/native/device/accessibility, integrated security and release gates remain open. Public homepage claims and final launch appearance are separate.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
|
||||
// Actual public routes; synthetic confirmation/MFA responses, no real identity or provider access.
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import ts from 'typescript';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
|
||||
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
|
||||
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/visual-correction/evidence/public-entry');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { screens: [], journeys: [], confirmations: [], signIns: [], challenges: [], unexpectedWrites: [], unavailableReads: [], blockedExternal: [], errors: [] };
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
|
||||
for (const [width, language, mode] of configurations) {
|
||||
console.log(`Checking ${width}/${language}/${mode}`);
|
||||
const other = language === 'en' ? 'nb' : 'en';
|
||||
const text = (locale, key) => { assert.ok(translations[locale][key], key); return translations[locale][key]; };
|
||||
const t = key => text(language, key);
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
|
||||
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
|
||||
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
||||
let configReads = 0, confirmations = 0, signIns = 0, challenges = 0;
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request(), url = new URL(req.url()), p = url.pathname;
|
||||
if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && p.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); }
|
||||
if (!p.startsWith('/api/')) return route.continue();
|
||||
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
if (req.method() === 'GET' && p === '/api/auth/config') { configReads++; return answer({ requireAuth: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true, googleEnabled: false, microsoftEnabled: false, turnstileEnabled: false }); }
|
||||
if (req.method() === 'POST' && ['/api/auth/verify-email', '/api/auth/email-change/confirm'].includes(p)) {
|
||||
const expected = { userId: 'synthetic-owner', ...(p.includes('email-change') ? { email: 'synthetic@example.test' } : {}), token: 'synthetic-confirmation' };
|
||||
assert.deepEqual(req.postDataJSON(), expected); confirmations++; report.confirmations.push({ width, language, mode, path: p }); return answer({});
|
||||
}
|
||||
if (req.method() === 'POST' && p === '/api/auth/login') {
|
||||
const body = req.postDataJSON(); assert.equal(body.email, 'synthetic@example.test'); assert.equal(body.password, 'Synthetic challenge example'); assert.equal(typeof body.rememberMe, 'boolean'); assert.deepEqual(Object.keys(body).sort(), ['email', 'password', 'rememberMe']);
|
||||
signIns++; report.signIns.push({ width, language, mode }); return answer({ requiresTwoFactor: true, pendingToken: 'synthetic-pending' });
|
||||
}
|
||||
if (req.method() === 'POST' && p === '/api/auth/2fa/challenge') {
|
||||
assert.deepEqual(req.postDataJSON(), { pendingToken: 'synthetic-pending', code: '000000', trustDevice: false }); challenges++; report.challenges.push({ width, language, mode });
|
||||
return route.fulfill({ status: 429, contentType: 'application/problem+json', body: '{}' });
|
||||
}
|
||||
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
|
||||
report.unavailableReads.push(p); return route.fulfill({ status: 503, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
const scan = async state => {
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
|
||||
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
|
||||
report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], state);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
|
||||
};
|
||||
const selectLanguage = locale => page.getByRole('button', { name: locale === 'en' ? 'English' : 'Norsk bokmål', exact: true }).click();
|
||||
const ready = async (url, title, config = false) => { await page.goto(`http://localhost:4338${url}`); await expect(page.getByRole('heading', { level: 1, name: t(title), exact: true })).toBeVisible(); if (config) await expect(page.getByText(t('loading'), { exact: true })).toHaveCount(0); };
|
||||
await ready('/login', 'signInTitle', true); await scan('login');
|
||||
await page.getByLabel(t('usernameOrEmail'), { exact: true }).fill('synthetic@example.test'); await page.getByLabel(t('profileCurrentPassword'), { exact: true }).fill('Synthetic unsubmitted example');
|
||||
const reads = configReads; await selectLanguage(other);
|
||||
await expect(page.getByLabel(text(other, 'usernameOrEmail'), { exact: true })).toHaveValue('synthetic@example.test'); await expect(page.getByLabel(text(other, 'profileCurrentPassword'), { exact: true })).toHaveValue('Synthetic unsubmitted example');
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('uiLanguage')), other); assert.equal(configReads, reads); assert.equal(signIns, 0);
|
||||
await selectLanguage(language); await page.getByLabel(t('usernameOrEmail'), { exact: true }).fill(''); await page.getByLabel(t('profileCurrentPassword'), { exact: true }).fill('');
|
||||
await page.getByRole('button', { name: t('signInTitle'), exact: true }).click(); await expect(page.getByText(t('usernameOrEmailRequired'), { exact: true })).toBeVisible(); await scan('login-validation');
|
||||
await ready('/register', 'createAccount', true); await scan('register'); await page.getByRole('button', { name: t('createAccount'), exact: true }).click(); await expect(page.getByText(t('emailRequired'), { exact: true })).toBeVisible(); await scan('register-validation');
|
||||
await ready('/forgot-password', 'forgotPasswordTitle'); await scan('password-request'); await page.getByRole('button', { name: t('forgotPasswordSubmit'), exact: true }).click(); await expect(page.getByText(t('emailRequired'), { exact: true })).toBeVisible(); await scan('password-request-validation');
|
||||
await ready('/reset-password?email=synthetic%40example.test&token=synthetic', 'resetPasswordTitle'); await expect(page.getByRole('button', { name: t('updatePassword'), exact: true })).toBeEnabled(); await scan('password-reset');
|
||||
await page.getByRole('button', { name: t('updatePassword'), exact: true }).click(); await expect(page.getByLabel(t('profileNewPassword'), { exact: true })).toHaveAttribute('aria-invalid', 'true'); await scan('password-reset-validation');
|
||||
for (const [url, title, errorKey, state] of [['/verify-email', 'verifyEmailTitle', 'missingVerifyLinkInfo', 'verify-missing'], ['/confirm-email-change', 'confirmEmailChangeTitle', 'missingEmailChangeLinkInfo', 'email-change-missing'], ['/microsoft-legacy-relink', 'microsoftLegacyRelinkTitle', 'microsoftLegacyRelinkMissing', 'microsoft-recovery-missing']]) {
|
||||
await ready(url, title); await expect(page.getByRole('main').getByRole('alert')).toContainText(t(errorKey)); if (state === 'microsoft-recovery-missing') await expect(page.getByRole('button', { name: t('continueWithMicrosoft'), exact: true })).toBeDisabled(); await scan(state);
|
||||
}
|
||||
assert.equal(confirmations, 0); assert.equal(signIns, 0); assert.equal(challenges, 0);
|
||||
for (const [url, title, successKey, state] of [['/verify-email?userId=synthetic-owner&token=synthetic-confirmation', 'verifyEmailTitle', 'verifyEmailSuccess', 'verify-success'], ['/confirm-email-change?userId=synthetic-owner&email=synthetic%40example.test&token=synthetic-confirmation', 'confirmEmailChangeTitle', 'confirmEmailChangeSuccess', 'email-change-success']]) {
|
||||
const expected = confirmations + 1; await ready(url, title); await expect(page.getByRole('status')).toContainText(t(successKey)); await selectLanguage(other); await expect(page.getByRole('status')).toContainText(text(other, successKey)); await selectLanguage(language); assert.equal(confirmations, expected); await scan(state);
|
||||
}
|
||||
await ready('/login', 'signInTitle', true); await page.getByLabel(t('usernameOrEmail'), { exact: true }).fill('synthetic@example.test'); await page.getByLabel(t('profileCurrentPassword'), { exact: true }).fill('Synthetic challenge example'); await page.getByRole('button', { name: t('signInTitle'), exact: true }).click();
|
||||
await page.getByLabel(t('twoFactorCodeLabel'), { exact: true }).fill('000000'); await page.getByRole('button', { name: t('twoFactorVerify'), exact: true }).click(); await expect(page.getByRole('main').getByRole('alert')).toContainText(t('twoFactorRateLimited'));
|
||||
await selectLanguage(other); await expect(page.getByRole('main').getByRole('alert')).toContainText(text(other, 'twoFactorRateLimited')); await expect(page.getByLabel(text(other, 'twoFactorCodeLabel'), { exact: true })).toHaveValue('000000'); await selectLanguage(language); await scan('mfa-rate-limit');
|
||||
assert.equal(confirmations, 2); assert.equal(signIns, 1); assert.equal(challenges, 1); report.journeys.push({ width, language, mode, publicLanguageAndDraft: 'PASS', clientValidationWithoutSubmission: 'PASS', confirmationLocaleWithoutReplay: 'PASS', mfaRateLimitAndCodeRetained: 'PASS' }); await context.close();
|
||||
}
|
||||
assert.equal(report.screens.length, configurations.length * 14); assert.equal(report.journeys.length, configurations.length); assert.equal(report.confirmations.length, configurations.length * 2); assert.equal(report.signIns.length, configurations.length); assert.equal(report.challenges.length, configurations.length);
|
||||
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.deepEqual(report.unavailableReads, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
|
||||
console.log(`PASS:${report.journeys.length} public journeys,${report.screens.length} scans/captures; synthetic confirmations and MFA only.`);
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Every API response is intercepted; no real account, password, email delivery, verification, MFA or provider activation.', 'Synthetic confirmation tokens and challenge values cannot qualify server-side token, rate-limit or authentication enforcement.', 'No native/human, integrated security, release or production qualification.'] }, null, 2)); await browser.close();
|
||||
}
|
||||
@@ -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