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

This commit is contained in:
cesnimda
2026-11-04 01:44:39 +01:00
parent 368e90d572
commit fa6bd5d98e
20 changed files with 343 additions and 58 deletions
@@ -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")}
+24 -1
View File
@@ -7,13 +7,14 @@ import { I18nProvider, useI18n } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
function Probe() {
const { language, setLanguage, hydrateLanguage, t } = useI18n();
const { language, setLanguage, setLocalLanguage, hydrateLanguage, t } = useI18n();
return (
<div>
<output aria-label="language">{language}</output>
<output aria-label="settings-label">{t("settings")}</output>
<button onClick={() => setLanguage("en")}>English</button>
<button onClick={() => setLanguage("nb")}>Norsk</button>
<button onClick={() => setLocalLanguage("nb")}>Local Norsk</button>
<button onClick={() => hydrateLanguage("nb-NO")}>Restore server preference</button>
</div>
);
@@ -46,6 +47,28 @@ test("hydrates an authenticated server preference without writing it back", () =
expect(api.put).not.toHaveBeenCalled();
});
test("a public local-only choice persists without authenticated requests", () => {
render(<I18nProvider><Probe /></I18nProvider>);
fireEvent.click(screen.getByRole("button", { name: "Local Norsk" }));
expect(screen.getByLabelText("language")).toHaveTextContent("nb");
expect(localStorage.getItem("uiLanguage")).toBe("nb");
expect(document.documentElement.lang).toBe("nb-NO");
expect(api.put).not.toHaveBeenCalled();
expect(api.get).not.toHaveBeenCalled();
});
test("a local-only choice invalidates an older same-language account-save retry", async () => {
let rejectOld!: (cause: Error) => void;
(api.put as jest.Mock).mockImplementationOnce(() => new Promise((_, reject) => { rejectOld = reject; }));
render(<ToastProvider><I18nProvider><Probe /></I18nProvider></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: "Norsk" }));
fireEvent.click(screen.getByRole("button", { name: "Local Norsk" }));
await act(async () => rejectOld(new Error("offline")));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(api.put).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText("language")).toHaveTextContent("nb");
});
test.each([
["Norsk", "nb", "nb-NO", "Språket er endret på denne enheten, men kunne ikke lagres på kontoen din.", "Prøv igjen"],
["English", "en", "en", "The language changed on this device, but your account preference could not be saved.", "Retry"],
+9 -3
View File
@@ -8,6 +8,7 @@ type TranslationParams = Record<string, string | number>;
type Ctx = {
language: UiLanguage;
setLanguage: (l: UiLanguage) => void;
setLocalLanguage: (l: UiLanguage) => void;
hydrateLanguage: (language?: string | null) => void;
t: (key: TranslationKey, params?: TranslationParams) => string;
};
@@ -35,9 +36,14 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
document.documentElement.lang = l === "nb" ? "nb-NO" : "en";
}, []);
const setLanguage = (l: UiLanguage) => {
const setLocalLanguage = (l: UiLanguage) => {
++languageSaveSequence.current;
applyLanguage(l);
const requestSequence = ++languageSaveSequence.current;
};
const setLanguage = (l: UiLanguage) => {
setLocalLanguage(l);
const requestSequence = languageSaveSequence.current;
void api.put("/auth/preferences/language", { language: l === "nb" ? "nb-NO" : "en" }).catch(() => {
if (requestSequence !== languageSaveSequence.current || currentLanguage.current !== l) return;
feedback?.toast(translations[l].settingsLanguageSaveFailed, "error", {
@@ -66,7 +72,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
const t = (key: TranslationKey, params?: TranslationParams) => interpolate(translations[language][key] ?? translations.en[key], params);
return <I18nContext.Provider value={{ language, setLanguage, hydrateLanguage, t }}>{children}</I18nContext.Provider>;
return <I18nContext.Provider value={{ language, setLanguage, setLocalLanguage, hydrateLanguage, t }}>{children}</I18nContext.Provider>;
}
export function useI18n() {
+6 -1
View File
@@ -2,6 +2,8 @@ import React, { useId } from "react";
import { Box, Paper } from "@mui/material";
import { PageHeader } from "./PageLayout";
import { StackLanguageToggle } from "./AppShell";
import { useI18n } from "../i18n/I18nProvider";
// Public auth pages own their forms, commands and token lifecycles.
export default function AuthPageFrame({ title, description, children }: {
@@ -10,6 +12,7 @@ export default function AuthPageFrame({ title, description, children }: {
children: React.ReactNode;
}) {
const titleId = useId();
const { language, setLocalLanguage } = useI18n();
return (
<Box component="main" aria-labelledby={titleId} sx={{
minHeight: "100svh", display: "grid", placeItems: "center", boxSizing: "border-box",
@@ -18,8 +21,10 @@ export default function AuthPageFrame({ title, description, children }: {
<Paper elevation={0} sx={{
width: "100%", maxWidth: 520, minWidth: 0, boxSizing: "border-box",
p: "var(--app-card-padding, 16px)", border: "1px solid", borderColor: "divider",
"& > header": { flexWrap: "nowrap", gap: 1 },
"& > header > div:last-child": { flexShrink: 0 },
}}>
<PageHeader title={title} titleId={titleId} description={description} />
<PageHeader title={title} titleId={titleId} description={description} actions={<StackLanguageToggle language={language} setLanguage={setLocalLanguage} />} />
<Box sx={{ mt: "var(--app-section-gap, 24px)", minWidth: 0 }}>{children}</Box>
</Paper>
</Box>
@@ -11,6 +11,7 @@ import LoginPage from './views/LoginPage';
import MicrosoftLegacyRelinkPage from './views/MicrosoftLegacyRelinkPage';
import ResetPasswordPage from './views/ResetPasswordPage';
import VerifyEmailPage from './views/VerifyEmailPage';
import { translations } from './i18n/translations';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -47,6 +48,100 @@ beforeEach(() => {
});
describe('public auth presentation', () => {
it.each([
{ view: <LoginPage />, path: '/login', kind: 'login', messageKey: 'signedIn' as const },
{ view: <ForgotPasswordPage />, path: '/forgot-password', kind: 'request', messageKey: 'passwordResetRequestSent' as const },
{ view: <ResetPasswordPage />, path: '/reset-password?email=synthetic%40example.test&token=synthetic', kind: 'reset', messageKey: 'passwordResetSuccess' as const },
])('announces a pending $kind result in the current UI language without replay', async ({ view, path, kind, messageKey }) => {
const pending = deferred(); mockedApi.post.mockReturnValue(pending.promise);
mount(view, path);
if (kind === 'login') {
await screen.findByText('Authentication is required to use this app.');
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'synthetic@example.test' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Synthetic pending example' } });
} else if (kind === 'request') fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'synthetic@example.test' } });
else {
fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'Synthetic pending example' } });
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'Synthetic pending example' } });
}
submit();
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
await act(async () => pending.resolve({ data: {} }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb[messageKey]);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it.each([
{ view: <LoginPage />, path: '/login', errorKey: 'usernameOrEmailRequired' as const },
{ view: <LoginPage initialMode="register" />, path: '/register', errorKey: 'emailRequired' as const },
{ view: <ForgotPasswordPage />, path: '/forgot-password', errorKey: 'emailRequired' as const },
{ view: <ResetPasswordPage />, path: '/reset-password?email=synthetic%40example.test&token=synthetic', errorKey: 'passwordTooShort' as const },
])('translates existing client validation on $path without submitting again', async ({ view, path, errorKey }) => {
mount(view, path);
if (path === '/login' || path === '/register') await screen.findByText('Authentication is required to use this app.');
submit();
expect(await screen.findByText(translations.en[errorKey])).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
expect(screen.getByText(translations.nb[errorKey])).toBeVisible();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it.each([
{ view: <LoginPage />, path: '/login', kind: 'login', errorKey: 'loginFailed' as const },
{ view: <ForgotPasswordPage />, path: '/forgot-password', kind: 'request', errorKey: 'passwordResetRequestFailed' as const },
{ view: <ResetPasswordPage />, path: '/reset-password?email=synthetic%40example.test&token=synthetic', kind: 'reset', errorKey: 'resetFailed' as const },
])('translates a failed $kind fallback while keeping its submitted draft and request count', async ({ view, path, kind, errorKey }) => {
mockedApi.post.mockRejectedValue({ response: { status: 503, data: '' } });
mount(view, path);
if (kind === 'login') {
await screen.findByText('Authentication is required to use this app.');
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'synthetic@example.test' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Synthetic rejected example' } });
} else if (kind === 'request') fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'synthetic@example.test' } });
else {
fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'Synthetic rejected example' } });
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'Synthetic rejected example' } });
}
submit();
expect(await screen.findByRole('alert')).toHaveTextContent(translations.en[errorKey]);
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb[errorKey]);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText(kind === 'login' ? translations.nb.usernameOrEmail : kind === 'request' ? translations.nb.profileEmail : translations.nb.profileNewPassword)).toHaveValue(kind === 'reset' ? 'Synthetic rejected example' : 'synthetic@example.test');
});
it('translates an MFA rate limit without another challenge or losing the entered code', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { requiresTwoFactor: true, pendingToken: 'synthetic-pending' } } as any).mockRejectedValueOnce({ response: { status: 429 } });
mount(<LoginPage />, '/login');
await screen.findByText('Authentication is required to use this app.');
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'synthetic@example.test' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Synthetic challenge example' } });
submit();
const code = await screen.findByLabelText(translations.en.twoFactorCodeLabel);
fireEvent.change(code, { target: { value: '000000' } });
submit();
expect(await screen.findByRole('alert')).toHaveTextContent(translations.en.twoFactorRateLimited);
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.twoFactorRateLimited);
expect(screen.getByLabelText(translations.nb.twoFactorCodeLabel)).toHaveValue('000000');
expect(mockedApi.post).toHaveBeenCalledTimes(2);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('lets a signed-out user choose a persisted UI language without discarding a sign-in draft', async () => {
mount(<LoginPage />, '/login');
await screen.findByText('Authentication is required to use this app.');
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'synthetic@example.test' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Unsubmitted synthetic example' } });
fireEvent.click(screen.getByRole('button', { name: 'Norsk bokmål' }));
expect(screen.getByRole('heading', { level: 1, name: translations.nb.signInTitle })).toBeVisible();
expect(screen.getByLabelText(translations.nb.usernameOrEmail)).toHaveValue('synthetic@example.test');
expect(screen.getByLabelText(translations.nb.profileCurrentPassword)).toHaveValue('Unsubmitted synthetic example');
expect(localStorage.getItem('uiLanguage')).toBe('nb');
expect(mockedApi.get).toHaveBeenCalledTimes(1);
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
it.each([
{ mode: 'login' as const, label: 'Current password', autocomplete: 'current-password', absent: 'New password' },
{ mode: 'register' as const, label: 'New password', autocomplete: 'new-password', absent: 'Current password' },
+48 -1
View File
@@ -1,11 +1,12 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import VerifyEmailPage from './views/VerifyEmailPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api, getApiErrorMessage } from './api';
import { translations } from './i18n/translations';
const mockedApi = api as jest.Mocked<typeof api>;
// CRA's jest config sets resetMocks: true, which wipes the initial implementation given to
@@ -26,6 +27,7 @@ function renderVerifyEmailPage(search: string, emailChange = false) {
describe('VerifyEmailPage', () => {
beforeEach(() => {
localStorage.clear();
mockedApi.post.mockReset();
mockedGetApiErrorMessage.mockImplementation((e: any, fallback?: string) => {
const data = e?.response?.data;
@@ -65,4 +67,49 @@ describe('VerifyEmailPage', () => {
expect(await screen.findByText('Your email has changed. Sign in again with the new address.')).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith('/auth/email-change/confirm', { userId: 'user-1', email: 'new@example.com', token: 'change-token' });
});
it.each([false, true])('changing UI language never resubmits a pending confirmation: emailChange=%s', async emailChange => {
let finish!: (value: any) => void;
mockedApi.post.mockReturnValue(new Promise(resolve => { finish = resolve; }));
renderVerifyEmailPage('?userId=user-1&email=new%40example.test&token=one-use', emailChange);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
const pendingCalls = mockedApi.post.mock.calls.length;
await act(async () => finish({ data: {} }));
expect(pendingCalls).toBe(1);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(screen.getByRole('status')).toHaveTextContent(translations.nb[emailChange ? 'confirmEmailChangeSuccess' : 'verifyEmailSuccess']);
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'en' })));
expect(screen.getByRole('status')).toHaveTextContent(translations.en[emailChange ? 'confirmEmailChangeSuccess' : 'verifyEmailSuccess']);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it('localizes missing-link errors without a confirmation request', async () => {
renderVerifyEmailPage('');
expect(await screen.findByText(translations.en.missingVerifyLinkInfo)).toBeInTheDocument();
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.missingVerifyLinkInfo);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('localizes a failed confirmation fallback without retrying the one-time command', async () => {
mockedApi.post.mockRejectedValue({ response: { status: 503, data: '' } });
renderVerifyEmailPage('?userId=user-1&token=one-use');
expect(await screen.findByText(translations.en.verifyEmailFailed)).toBeInTheDocument();
await act(async () => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.verifyEmailFailed);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it('does not let an older confirmation replace a different flow missing-link error', async () => {
let finish!: (value: any) => void;
mockedApi.post.mockReturnValue(new Promise(resolve => { finish = resolve; }));
const view = renderVerifyEmailPage('?userId=user-1&token=one-use');
window.history.replaceState({}, '', '/confirm-email-change?userId=user-1&token=one-use');
view.rerender(<MemoryRouter><I18nProvider><VerifyEmailPage emailChange /></I18nProvider></MemoryRouter>);
expect(await screen.findByText(translations.en.missingEmailChangeLinkInfo)).toBeInTheDocument();
await act(async () => finish({ data: {} }));
expect(screen.getByRole('alert')).toHaveTextContent(translations.en.missingEmailChangeLinkInfo);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
});
@@ -7,18 +7,20 @@ import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import AuthPageFrame from "../layout/AuthPageFrame";
export default function ForgotPasswordPage() {
const { toast } = useToast();
const { t } = useI18n();
const currentTranslation = useRef(t); currentTranslation.current = t;
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [emailError, setEmailError] = useState<string | undefined>(undefined);
const [submitError, setSubmitError] = useState<string | null>(null);
const [emailError, setEmailError] = useState<TranslationKey | undefined>(undefined);
const [submitError, setSubmitError] = useState<{ cause: unknown } | null>(null);
const emailRef = useRef<HTMLInputElement>(null);
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -35,12 +37,12 @@ export default function ForgotPasswordPage() {
e.preventDefault();
const trimmed = email.trim();
if (!trimmed) {
setEmailError(t("emailRequired"));
setEmailError("emailRequired");
emailRef.current?.focus();
return;
}
if (!EMAIL_PATTERN.test(trimmed)) {
setEmailError(t("invalidEmail"));
setEmailError("invalidEmail");
emailRef.current?.focus();
return;
}
@@ -52,17 +54,17 @@ export default function ForgotPasswordPage() {
.post("/auth/request-password-reset", { email: email.trim() })
.then(() => {
setSubmitted(true);
toast(t("passwordResetRequestSent"), "success");
toast(currentTranslation.current("passwordResetRequestSent"), "success");
})
.catch((error: any) => {
setSubmitError(getApiErrorMessage(error, t("passwordResetRequestFailed")));
setSubmitError({ cause: error });
})
.finally(() => setLoading(false));
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{submitted ? <Alert severity="success" role="status">{t("passwordResetRequestSent")}</Alert> : null}
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
{submitError ? <Alert severity="error">{getApiErrorMessage(submitError.cause, t("passwordResetRequestFailed"))}</Alert> : null}
<TextField
label={t("profileEmail")}
type="email"
@@ -72,7 +74,7 @@ export default function ForgotPasswordPage() {
autoFocus
inputRef={emailRef}
error={Boolean(emailError)}
helperText={emailError}
helperText={emailError ? t(emailError) : undefined}
fullWidth
/>
+19 -17
View File
@@ -12,6 +12,7 @@ import TwoFactorChallenge from "../components/TwoFactorChallenge";
import TurnstileWidget from "../components/TurnstileWidget";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import AuthPageFrame from "../layout/AuthPageFrame";
type AuthConfig = {
@@ -28,6 +29,7 @@ type AuthConfig = {
export default function LoginPage({ initialMode = "login" }: { initialMode?: "login" | "register" }) {
const { toast } = useToast();
const { t } = useI18n();
const currentTranslation = useRef(t); currentTranslation.current = t;
const navigate = useNavigate();
const location = useLocation() as any;
@@ -44,10 +46,10 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
const [emailNotVerified, setEmailNotVerified] = useState(false);
const [resendingVerification, setResendingVerification] = useState(false);
const [verificationResent, setVerificationResent] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
const [fieldErrors, setFieldErrors] = useState<{ email?: TranslationKey; password?: TranslationKey; confirmPassword?: TranslationKey }>({});
const [registerMode, setRegisterMode] = useState(initialMode === "register");
const [turnstileToken, setTurnstileToken] = useState("");
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<{ cause: unknown; fallback: TranslationKey } | null>(null);
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const confirmPasswordRef = useRef<HTMLInputElement>(null);
@@ -78,20 +80,20 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
async function completeLogin() {
setAuthPersistencePreference(rememberMe ? "local" : "session");
await api.get("/auth/me");
toast(t("signedIn"), "success");
toast(currentTranslation.current("signedIn"), "success");
navigate(nextPath, nextOptions);
}
function validate(mode: "login" | "register") {
const errors: { email?: string; password?: string; confirmPassword?: string } = {};
if (!email.trim()) errors.email = mode === "login" ? t("usernameOrEmailRequired") : t("emailRequired");
else if (mode === "register" && !EMAIL_PATTERN.test(email.trim())) errors.email = t("invalidEmail");
const errors: { email?: TranslationKey; password?: TranslationKey; confirmPassword?: TranslationKey } = {};
if (!email.trim()) errors.email = mode === "login" ? "usernameOrEmailRequired" : "emailRequired";
else if (mode === "register" && !EMAIL_PATTERN.test(email.trim())) errors.email = "invalidEmail";
if (!password) errors.password = t("passwordRequired");
else if (mode === "register" && password.length < 8) errors.password = t("passwordTooShort");
if (!password) errors.password = "passwordRequired";
else if (mode === "register" && password.length < 8) errors.password = "passwordTooShort";
if (mode === "register" && password && confirmPassword !== password) {
errors.confirmPassword = t("passwordsDoNotMatch");
errors.confirmPassword = "passwordsDoNotMatch";
}
setFieldErrors(errors);
@@ -113,7 +115,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string; verificationRequired?: boolean }>(url, payload);
if (res.data?.verificationRequired) {
setEmailNotVerified(true);
toast(t("registerCheckEmailForVerification"), "info");
toast(currentTranslation.current("registerCheckEmailForVerification"), "info");
return;
}
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
@@ -125,7 +127,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
if (mode === "login" && e?.response?.data?.error === "email_not_verified") {
setEmailNotVerified(true);
} else {
setSubmitError(getApiErrorMessage(e, t("loginFailed")));
setSubmitError({ cause: e, fallback: "loginFailed" });
}
} finally {
setLoading(false);
@@ -138,9 +140,9 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
try {
await api.post("/auth/resend-verification-email", { email });
setVerificationResent(true);
toast(t("verificationEmailResent"), "success");
toast(currentTranslation.current("verificationEmailResent"), "success");
} catch (e: any) {
setSubmitError(getApiErrorMessage(e, t("verifyEmailFailed")));
setSubmitError({ cause: e, fallback: "verifyEmailFailed" });
} finally {
setResendingVerification(false);
}
@@ -169,7 +171,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
{submitError ? <Alert severity="error">{getApiErrorMessage(submitError.cause, t(submitError.fallback))}</Alert> : null}
{registerMode && cfg && !allowReg ? <Alert severity="info">{t("registrationUnavailable")}</Alert> : null}
{cfg?.requireEmailVerification && emailNotVerified && (
<Alert
@@ -192,7 +194,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
autoFocus
inputRef={emailRef}
error={Boolean(fieldErrors.email)}
helperText={fieldErrors.email}
helperText={fieldErrors.email ? t(fieldErrors.email) : undefined}
fullWidth
/>
<TextField
@@ -203,7 +205,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
inputRef={passwordRef}
type="password"
error={Boolean(fieldErrors.password)}
helperText={fieldErrors.password || (registerMode ? t("passwordTooShort") : undefined)}
helperText={fieldErrors.password ? t(fieldErrors.password) : registerMode ? t("passwordTooShort") : undefined}
fullWidth
/>
{registerMode && (
@@ -215,7 +217,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
inputRef={confirmPasswordRef}
type="password"
error={Boolean(fieldErrors.confirmPassword)}
helperText={fieldErrors.confirmPassword}
helperText={fieldErrors.confirmPassword ? t(fieldErrors.confirmPassword) : undefined}
fullWidth
/>
)}
@@ -13,7 +13,7 @@ export default function MicrosoftLegacyRelinkPage() {
const navigate = useNavigate();
const [working, setWorking] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ cause: unknown } | null>(null);
const params = new URLSearchParams(window.location.search);
const userId = params.get("userId") || "";
const tenantId = params.get("tenantId") || "";
@@ -39,7 +39,7 @@ export default function MicrosoftLegacyRelinkPage() {
});
setSuccess(true);
} catch (e: any) {
setError(getApiErrorMessage(e, t("microsoftLegacyRelinkFailed")));
setError({ cause: e });
} finally {
setWorking(false);
}
@@ -48,7 +48,7 @@ export default function MicrosoftLegacyRelinkPage() {
return (
<AuthPageFrame title={t("microsoftLegacyRelinkTitle")} description={t("microsoftLegacyRelinkBody")}>
{missing ? <Alert severity="error" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkMissing")}</Alert> : null}
{error ? <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert> : null}
{error ? <Alert severity="error" sx={{ mt: 2 }}>{getApiErrorMessage(error.cause, t("microsoftLegacyRelinkFailed"))}</Alert> : null}
{success ? <Alert severity="success" role="status" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkSuccess")}</Alert> : null}
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 1, mt: 3, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button onClick={() => navigate("/login")}>{t("backToLogin")}</Button>
+12 -10
View File
@@ -7,11 +7,13 @@ import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import AuthPageFrame from "../layout/AuthPageFrame";
export default function ResetPasswordPage() {
const { toast } = useToast();
const { t } = useI18n();
const currentTranslation = useRef(t); currentTranslation.current = t;
const navigate = useNavigate();
const [email, setEmail] = useState("");
@@ -19,8 +21,8 @@ export default function ResetPasswordPage() {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{ newPassword?: string; confirmPassword?: string }>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<{ newPassword?: TranslationKey; confirmPassword?: TranslationKey }>({});
const [submitError, setSubmitError] = useState<{ cause: unknown } | null>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const confirmPasswordRef = useRef<HTMLInputElement>(null);
@@ -42,9 +44,9 @@ export default function ResetPasswordPage() {
toast(t("missingResetLinkInfo"), "error");
return;
}
const errors: { newPassword?: string; confirmPassword?: string } = {};
if (!newPassword || newPassword.length < 8) errors.newPassword = t("passwordTooShort");
if (confirmPassword !== newPassword) errors.confirmPassword = t("passwordsDoNotMatch");
const errors: { newPassword?: TranslationKey; confirmPassword?: TranslationKey } = {};
if (!newPassword || newPassword.length < 8) errors.newPassword = "passwordTooShort";
if (confirmPassword !== newPassword) errors.confirmPassword = "passwordsDoNotMatch";
setFieldErrors(errors);
if (Object.keys(errors).length > 0) {
(errors.newPassword ? passwordRef : confirmPasswordRef).current?.focus();
@@ -56,18 +58,18 @@ export default function ResetPasswordPage() {
api
.post("/auth/reset-password", { email, token, newPassword })
.then(() => {
toast(t("passwordResetSuccess"), "success");
toast(currentTranslation.current("passwordResetSuccess"), "success");
navigate("/login", { replace: true });
})
.catch((e2: any) => {
setSubmitError(getApiErrorMessage(e2, t("resetFailed")));
setSubmitError({ cause: e2 });
})
.finally(() => setLoading(false));
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{missingResetInfo ? <Alert severity="warning">{t("missingResetLinkInfo")}</Alert> : null}
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
{submitError ? <Alert severity="error">{getApiErrorMessage(submitError.cause, t("resetFailed"))}</Alert> : null}
<TextField label={t("profileEmail")} type="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!missingResetInfo} fullWidth />
<TextField
label={t("profileNewPassword")}
@@ -77,7 +79,7 @@ export default function ResetPasswordPage() {
autoComplete="new-password"
inputRef={passwordRef}
error={Boolean(fieldErrors.newPassword)}
helperText={fieldErrors.newPassword || t("passwordTooShort")}
helperText={t(fieldErrors.newPassword ?? "passwordTooShort")}
fullWidth
/>
<TextField
@@ -88,7 +90,7 @@ export default function ResetPasswordPage() {
autoComplete="new-password"
inputRef={confirmPasswordRef}
error={Boolean(fieldErrors.confirmPassword)}
helperText={fieldErrors.confirmPassword}
helperText={fieldErrors.confirmPassword ? t(fieldErrors.confirmPassword) : undefined}
fullWidth
/>
+14 -7
View File
@@ -15,9 +15,13 @@ export default function VerifyEmailPage({ emailChange = false }: { emailChange?:
const navigate = useNavigate();
const [status, setStatus] = useState<Status>("verifying");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [verificationError, setVerificationError] = useState<unknown>(null);
const [missingLink, setMissingLink] = useState(false);
useEffect(() => {
setStatus("verifying");
setMissingLink(false);
setVerificationError(null);
const params = new URLSearchParams(window.location.search);
const userId = params.get("userId") || "";
const email = params.get("email") || "";
@@ -25,18 +29,21 @@ export default function VerifyEmailPage({ emailChange = false }: { emailChange?:
if (!userId || !token || (emailChange && !email)) {
setStatus("error");
setErrorMessage(t(emailChange ? "missingEmailChangeLinkInfo" : "missingVerifyLinkInfo"));
setMissingLink(true);
return;
}
let active = true;
api
.post(emailChange ? "/auth/email-change/confirm" : "/auth/verify-email", emailChange ? { userId, email, token } : { userId, token })
.then(() => setStatus("success"))
.catch((e: any) => {
.then(() => { if (active) setStatus("success"); })
.catch((cause: unknown) => {
if (!active) return;
setStatus("error");
setErrorMessage(getApiErrorMessage(e, t("verifyEmailFailed")));
setVerificationError(cause);
});
}, [emailChange, t]);
return () => { active = false; };
}, [emailChange]);
return (
<AuthPageFrame title={t(emailChange ? "confirmEmailChangeTitle" : "verifyEmailTitle")}>
@@ -48,7 +55,7 @@ export default function VerifyEmailPage({ emailChange = false }: { emailChange?:
</Box>
)}
{status === "success" && <Alert severity="success" role="status">{t(emailChange ? "confirmEmailChangeSuccess" : "verifyEmailSuccess")}</Alert>}
{status === "error" && <Alert severity="error">{errorMessage}</Alert>}
{status === "error" && <Alert severity="error">{missingLink ? t(emailChange ? "missingEmailChangeLinkInfo" : "missingVerifyLinkInfo") : getApiErrorMessage(verificationError, t("verifyEmailFailed"))}</Alert>}
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "flex-end", mt: 1, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button variant="contained" onClick={() => navigate("/login")}>