fix(settings): keep the language and save settings one after the other

This commit is contained in:
lobo committed 2026-09-29 13:27:31 +02:00
1 parent 14dd59f9e0
commit f605917874
25 files changed
+423 -98

No files matched your search

+6
View File
@@ -67,6 +67,8 @@ To Be Played is split into three repositories:
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs. ("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
mode stays per device.
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night; to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night;
a repaired cover comes back as `imageUrl`. a repaired cover comes back as `imageUrl`.
@@ -130,6 +132,10 @@ flowchart LR
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game - `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
detail page) to Core. detail page) to Core.
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
- `PUT /api/users/settings` replaces dark mode, language, display name and public profile at once, so every save
goes through `saveSettings` in `src/utils/settings.js`: one after the other, each on a fresh `GET /api/users/me`.
- Pages keep a load error as its i18n key and translate it when rendering, so a language switch loads nothing
again.
## Project structure ## Project structure
+1 -1
View File
@@ -134,10 +134,10 @@
"EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_REQUIRED": "Email ist erforderlich.",
"EMAIL_INVALID": "Gib eine gültige Email-Adresse ein.", "EMAIL_INVALID": "Gib eine gültige Email-Adresse ein.",
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.", "PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.", "PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.", "PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.", "INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"LANGUAGE_INVALID": "Nicht unterstützte Sprache.",
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)." "DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
}, },
"INFO": { "INFO": {
+1 -1
View File
@@ -134,10 +134,10 @@
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
"EMAIL_INVALID": "Enter a valid email address.", "EMAIL_INVALID": "Enter a valid email address.",
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.", "PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.", "PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
"PASSWORD_MISMATCH": "Passwords do not match.", "PASSWORD_MISMATCH": "Passwords do not match.",
"INVITE_CODE_REQUIRED": "Invite code is required.", "INVITE_CODE_REQUIRED": "Invite code is required.",
"LANGUAGE_INVALID": "Unsupported language.",
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)." "DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
}, },
"INFO": { "INFO": {
+3 -3
View File
@@ -28,7 +28,7 @@ import {logout} from './utils/api.js';
function App() { function App() {
const {t} = useTranslation(); const {t} = useTranslation();
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const {appearance, syncAppearance, resetAppearance} = usePreferences(); const {appearance, syncPreferences, resetAppearance} = usePreferences();
// Hooks for routing // Hooks for routing
const navigate = useNavigate(); const navigate = useNavigate();
@@ -42,8 +42,8 @@ function App() {
// On login and on page load; the cached look shows until Auth answers. // On login and on page load; the cached look shows until Auth answers.
useEffect(() => { useEffect(() => {
if (isAuthenticated) syncAppearance(); if (isAuthenticated) syncPreferences();
}, [isAuthenticated, syncAppearance]); }, [isAuthenticated, syncPreferences]);
// Waits for Auth: a late logout answer would clear the cookies of a login that came after it. // Waits for Auth: a late logout answer would clear the cookies of a login that came after it.
const handleLogout = async () => { const handleLogout = async () => {
+10 -1
View File
@@ -71,7 +71,16 @@ export default function AppHeader({isAuthenticated, onLogout}) {
toast.error(t('NAV.VIEW_AS.ERROR')); toast.error(t('NAV.VIEW_AS.ERROR'));
}; };
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de'); // Unsaved, the next login would switch back to Auth's language.
const toggleLanguage = async () => {
try {
const response = await setLanguage(i18n.language === 'de' ? 'en' : 'de');
if (response && !response.ok) toast.error(t('SETTINGS.ERROR_SAVE'));
} catch (err) {
console.error('Language save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
const toggleTheme = () => setDarkMode(!isDarkMode); const toggleTheme = () => setDarkMode(!isDarkMode);
const ThemeIcon = isDarkMode ? Sun : Moon; const ThemeIcon = isDarkMode ? Sun : Moon;
+17
View File
@@ -2,14 +2,18 @@
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import {useTranslation} from 'react-i18next';
import AppHeader from './AppHeader.jsx'; import AppHeader from './AppHeader.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
localStorage.clear(); localStorage.clear();
useTranslation().i18n.language = 'de';
}); });
const loggedInAs = (me) => { const loggedInAs = (me) => {
@@ -27,6 +31,19 @@ const loggedInAs = (me) => {
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'}); const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
describe('AppHeader', () => { describe('AppHeader', () => {
it('says so when Auth refuses the language switch', async () => {
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}});
fetch.mockImplementation(async (url, init) => (init?.method === 'PUT'
? {ok: false, status: 500, json: async () => ({})}
: {ok: true, status: 200, json: async () => ({role: 'USER', activeRole: 'USER', settings: {language: 'de'}})}));
await screen.findByRole('button', {name: 'NAV.MENU'});
openMenu();
fireEvent.click(await screen.findByRole('menuitem', {name: /NAV.LANGUAGE/}));
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.ERROR_SAVE'));
});
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does. // jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
it('keeps a name on the home link when a narrow phone hides the app name', () => { it('keeps a name on the home link when a narrow phone hides the app name', () => {
render( render(
+30 -10
View File
@@ -4,11 +4,13 @@ import {
applyAppearance, applyAppearance,
cacheAppearance, cacheAppearance,
DEFAULT_APPEARANCE, DEFAULT_APPEARANCE,
loadAppearance, loadPreferences,
normalizeAppearance, normalizeAppearance,
readCachedAppearance, readCachedAppearance,
saveAppearance, saveAppearance,
} from '../utils/appearance.js'; } from '../utils/appearance.js';
import {hasSession} from '../utils/auth.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
// The --background of style.css; the installed app paints its title bar in this color. // The --background of style.css; the installed app paints its title bar in this color.
@@ -51,21 +53,29 @@ export function PreferencesProvider({children}) {
cacheAppearance(appearance); cacheAppearance(appearance);
}, [appearance]); }, [appearance]);
const syncAppearance = useCallback(() => { // Grows on every language pick, so a load that started before does not undo it.
const languagePicks = useRef(0);
const syncPreferences = useCallback(() => {
const started = generation.current; const started = generation.current;
const picksBefore = languagePicks.current;
syncing.current = (async () => { syncing.current = (async () => {
try { try {
const loaded = await loadAppearance(); const loaded = await loadPreferences();
if (loaded && generation.current === started) { if (loaded?.appearance && generation.current === started) {
savedAppearance.current = loaded; savedAppearance.current = loaded.appearance;
setAppearanceState(normalizeAppearance({...loaded, ...pendingChanges.current})); setAppearanceState(normalizeAppearance({...loaded.appearance, ...pendingChanges.current}));
}
if (LANGUAGES.includes(loaded?.language) && languagePicks.current === picksBefore
&& loaded.language !== i18n.language) {
i18n.changeLanguage(loaded.language);
} }
} catch (err) { } catch (err) {
console.error('Appearance load error:', err); console.error('Preferences load error:', err);
} }
})(); })();
return syncing.current; return syncing.current;
}, []); }, [i18n]);
/** /**
* Applies the changed fields at once, then saves them to Auth after a running load and after the saves before * Applies the changed fields at once, then saves them to Auth after a running load and after the saves before
@@ -114,7 +124,17 @@ export function PreferencesProvider({children}) {
setAppearanceState(DEFAULT_APPEARANCE); setAppearanceState(DEFAULT_APPEARANCE);
}, []); }, []);
const setLanguage = (lang) => i18n.changeLanguage(lang); /**
* Applies the language at once and saves it to Auth while logged in.
*
* @return the fetch response, or null when logged out; rejects on a network error
*/
const setLanguage = (lang) => {
languagePicks.current += 1;
i18n.changeLanguage(lang);
if (!hasSession()) return Promise.resolve(null);
return saveSettings({language: lang});
};
return ( return (
<PreferencesContext.Provider <PreferencesContext.Provider
@@ -125,7 +145,7 @@ export function PreferencesProvider({children}) {
setLanguage, setLanguage,
appearance, appearance,
changeAppearance, changeAppearance,
syncAppearance, syncPreferences,
resetAppearance, resetAppearance,
}} }}
> >
+61 -1
View File
@@ -3,7 +3,11 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react'; import {act, cleanup, render} from '@testing-library/react';
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx'; import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); const i18n = vi.hoisted(() => ({language: 'de'}));
vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js');
return {...mock, useTranslation: () => ({t: mock.useTranslation().t, i18n})};
});
let preferences; let preferences;
function Spy() { function Spy() {
@@ -17,11 +21,67 @@ beforeEach(() => {
document.head.innerHTML = '<meta name="theme-color" content="#09090b">'; document.head.innerHTML = '<meta name="theme-color" content="#09090b">';
}); });
beforeEach(() => {
i18n.language = 'de';
i18n.changeLanguage = vi.fn((lng) => { i18n.language = lng; });
});
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
localStorage.clear(); localStorage.clear();
}); });
const answerMe = (settings) => {
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
? {ok: true, status: 204}
: {ok: true, status: 200, json: async () => ({displayName: 'Lobo', settings})}));
};
describe('PreferencesProvider language', () => {
it('takes the language saved in Auth', async () => {
answerMe({language: 'en', useDarkTheme: true, publicProfile: false});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
await act(() => preferences.syncPreferences());
expect(i18n.changeLanguage).toHaveBeenCalledWith('en');
});
it('keeps its own language while Auth has none', async () => {
answerMe({language: '', useDarkTheme: true, publicProfile: false});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
await act(() => preferences.syncPreferences());
expect(i18n.changeLanguage).not.toHaveBeenCalled();
});
it('keeps a language picked while Auth was still answering', async () => {
answerMe({language: 'de', useDarkTheme: true, publicProfile: false});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
await act(async () => {
const sync = preferences.syncPreferences();
preferences.setLanguage('en');
await sync;
});
expect(i18n.changeLanguage.mock.calls).toEqual([['en']]);
});
it('saves a picked language to Auth with the other settings unchanged', async () => {
localStorage.setItem('tbp_session', '1');
answerMe({language: 'de', useDarkTheme: true, publicProfile: true});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
await act(() => preferences.setLanguage('en'));
const put = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
expect(put[0]).toMatch(/\/api\/users\/settings$/);
expect(JSON.parse(put[1].body)).toEqual({useDarkTheme: true, language: 'en', displayName: 'Lobo', publicProfile: true});
});
});
describe('PreferencesProvider theme color', () => { describe('PreferencesProvider theme color', () => {
it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => { it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => {
localStorage.setItem('tbp_dark_mode', 'false'); localStorage.setItem('tbp_dark_mode', 'false');
+3 -3
View File
@@ -121,7 +121,7 @@ describe('useSaveAppearance', () => {
let sync; let sync;
let save; let save;
act(() => { act(() => {
sync = result.current.preferences.syncAppearance(); sync = result.current.preferences.syncPreferences();
}); });
act(() => { act(() => {
save = result.current.save({corners: 'round'}); save = result.current.save({corners: 'round'});
@@ -149,7 +149,7 @@ describe('useSaveAppearance', () => {
let sync; let sync;
act(() => { act(() => {
sync = result.current.preferences.syncAppearance(); sync = result.current.preferences.syncPreferences();
}); });
act(() => result.current.preferences.resetAppearance()); act(() => result.current.preferences.resetAppearance());
await act(async () => { await act(async () => {
@@ -209,7 +209,7 @@ describe('useSaveAppearance', () => {
const {result} = renderSave(); const {result} = renderSave();
act(() => { act(() => {
result.current.preferences.syncAppearance(); result.current.preferences.syncPreferences();
}); });
let save; let save;
act(() => { act(() => {
+4 -1
View File
@@ -1,12 +1,13 @@
import i18n from 'i18next'; import i18n from 'i18next';
import {initReactI18next} from 'react-i18next'; import {initReactI18next} from 'react-i18next';
import HttpBackend from 'i18next-http-backend'; import HttpBackend from 'i18next-http-backend';
import {cacheLanguage, readCachedLanguage} from './utils/settings.js';
i18n.use(HttpBackend) i18n.use(HttpBackend)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
lng: 'de', lng: readCachedLanguage() ?? 'de',
fallbackLng: 'en', fallbackLng: 'en',
backend: { backend: {
// A browser kept an old file without its Cache-Control and showed raw keys; a new URL per version avoids that. // A browser kept an old file without its Cache-Control and showed raw keys; a new URL per version avoids that.
@@ -17,4 +18,6 @@ i18n.use(HttpBackend)
} }
}); });
i18n.on('languageChanged', cacheLanguage);
export default i18n; export default i18n;
+4 -4
View File
@@ -213,18 +213,18 @@ const Dashboard = () => {
} else if (profileResponse.status === 401) { } else if (profileResponse.status === 401) {
expireSession(); expireSession();
} else { } else {
setError(t('INFO.ERROR.LOAD_PROFILE')); setError('INFO.ERROR.LOAD_PROFILE');
} }
} catch (err) { } catch (err) {
console.error('Dashboard fetch error:', err); console.error('Dashboard fetch error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE')); setError('INFO.ERROR.NETWORK_FAILURE');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchUserData(); fetchUserData();
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); }, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0; const showLibrary = hasSteamId || steamData.count > 0;
@@ -248,7 +248,7 @@ const Dashboard = () => {
if (error) { if (error) {
return ( return (
<div className="py-24 text-center text-destructive"> <div className="py-24 text-center text-destructive">
{error} {t(error)}
</div> </div>
); );
} }
+15 -7
View File
@@ -89,6 +89,14 @@ export default function GameDetail() {
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)}; shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
}, [data]); }, [data]);
// Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit
// cards keep what is typed.
const [dataAppid, setDataAppid] = useState(appid);
if (dataAppid !== appid) {
setDataAppid(appid);
setData(null);
}
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (!hasSession()) { if (!hasSession()) {
@@ -111,11 +119,11 @@ export default function GameDetail() {
return; return;
} }
if (response.status === 404) { if (response.status === 404) {
setError(t('GAME_DETAIL.NOT_FOUND')); setError('GAME_DETAIL.NOT_FOUND');
return; return;
} }
if (!response.ok) { if (!response.ok) {
setError(t('GAME_DETAIL.ERROR_LOAD')); setError('GAME_DETAIL.ERROR_LOAD');
return; return;
} }
const json = await response.json(); const json = await response.json();
@@ -123,7 +131,7 @@ export default function GameDetail() {
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error('GameDetail load error:', err); console.error('GameDetail load error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE')); setError('INFO.ERROR.NETWORK_FAILURE');
} }
} finally { } finally {
if (!cancelled) setIsLoading(false); if (!cancelled) setIsLoading(false);
@@ -132,7 +140,7 @@ export default function GameDetail() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [appid, navigate, expireSession, t, reloads]); }, [appid, navigate, expireSession, reloads]);
const sortedAchievements = useMemo(() => { const sortedAchievements = useMemo(() => {
if (!data?.achievements) return []; if (!data?.achievements) return [];
@@ -188,7 +196,7 @@ export default function GameDetail() {
<ArrowLeft/>{t('GAME_DETAIL.BACK')} <ArrowLeft/>{t('GAME_DETAIL.BACK')}
</Button> </Button>
{isLoading && ( {isLoading && !data && (
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-52 rounded-xl"/> <Skeleton className="h-52 rounded-xl"/>
<Skeleton className="h-40 rounded-xl"/> <Skeleton className="h-40 rounded-xl"/>
@@ -197,12 +205,12 @@ export default function GameDetail() {
{error && !isLoading && ( {error && !isLoading && (
<div className="py-12 text-center text-destructive space-y-4"> <div className="py-12 text-center text-destructive space-y-4">
<p>{error}</p> <p>{t(error)}</p>
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button> <Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
</div> </div>
)} )}
{!isLoading && !error && data && ( {!error && data && (
<> <>
{data.hidden && ( {data.hidden && (
<div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground"> <div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
+44
View File
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// Stands in for a saved correction, which loads the game again.
vi.mock('../components/GameCorrectionDialog.jsx', () => ({
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>,
}));
const game = {
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 90, status: 'BACKLOG',
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
};
const gameLoads = () => fetch.mock.calls.filter(([url]) => url.endsWith('/api/library/games/220')).length;
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
json: async () => (url.includes('/api/users/me') ? {role: 'AUDITOR', activeRole: 'AUDITOR'} : game)}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('keeps unsaved notes while the game loads again after a correction', async () => {
render(
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
</PreferencesProvider></MemoryRouter>,
);
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
fireEvent.click(await screen.findByRole('button', {name: 'correction saved'}));
await waitFor(() => expect(gameLoads()).toBe(2));
await waitFor(() => expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft'));
});
+4 -4
View File
@@ -89,7 +89,7 @@ export default function GameLibrary() {
} }
if (!response.ok) { if (!response.ok) {
setError(t('LIBRARY.ERROR_LOAD')); setError('LIBRARY.ERROR_LOAD');
return; return;
} }
@@ -97,11 +97,11 @@ export default function GameLibrary() {
setData(json); setData(json);
} catch (err) { } catch (err) {
console.error('Library load error:', err); console.error('Library load error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE')); setError('INFO.ERROR.NETWORK_FAILURE');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, t]); }, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -287,7 +287,7 @@ export default function GameLibrary() {
</Card> </Card>
)} )}
{error && <div className="py-12 text-center text-destructive">{error}</div>} {error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
{ready && data.content.length === 0 && ( {ready && data.content.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
+6 -6
View File
@@ -51,11 +51,11 @@ export default function PublicProfilePage() {
`${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`); `${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`);
if (cancelled) return; if (cancelled) return;
if (profileResponse.status === 404) { if (profileResponse.status === 404) {
setError(t('PROFILE.NOT_FOUND')); setError('PROFILE.NOT_FOUND');
return; return;
} }
if (!profileResponse.ok) { if (!profileResponse.ok) {
setError(t('PROFILE.ERROR_LOAD')); setError('PROFILE.ERROR_LOAD');
return; return;
} }
const found = await profileResponse.json(); const found = await profileResponse.json();
@@ -64,7 +64,7 @@ export default function PublicProfilePage() {
}); });
if (cancelled) return; if (cancelled) return;
if (!libraryResponse.ok) { if (!libraryResponse.ok) {
setError(t('PROFILE.ERROR_LOAD')); setError('PROFILE.ERROR_LOAD');
return; return;
} }
const data = await libraryResponse.json(); const data = await libraryResponse.json();
@@ -74,20 +74,20 @@ export default function PublicProfilePage() {
} }
} catch (err) { } catch (err) {
console.error('Public profile load error:', err); console.error('Public profile load error:', err);
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE')); if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE');
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [name, t, reloads]); }, [name, reloads]);
if (error) { if (error) {
return ( return (
<PageContainer className="p-4 md:p-8"> <PageContainer className="p-4 md:p-8">
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
<UserRoundX className="size-10"/> <UserRoundX className="size-10"/>
<p className="max-w-sm text-sm">{error}</p> <p className="max-w-sm text-sm">{t(error)}</p>
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link> <Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>
</Card> </Card>
</PageContainer> </PageContainer>
+15 -38
View File
@@ -31,6 +31,7 @@ import {coverUrl} from '../utils/games.js';
import {apiFetch, refreshSession} from '../utils/api.js'; import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js'; import {hasSession, markSession} from '../utils/auth.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js'; import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE', DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
@@ -44,7 +45,6 @@ const STEAM_LINK_TOASTS = {
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'}, failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
}; };
const SUPPORTED_LANGUAGES = ['en', 'de'];
// Radix Select has no empty value. // Radix Select has no empty value.
const NO_FAVORITE = 'NONE'; const NO_FAVORITE = 'NONE';
@@ -81,7 +81,7 @@ export default function SettingsPage() {
formState: {errors, isSubmitting}, formState: {errors, isSubmitting},
} = useForm({ } = useForm({
resolver: zodResolver(settingsSchema), resolver: zodResolver(settingsSchema),
defaultValues: {useDarkTheme: true, language: 'en', displayName: ''}, defaultValues: {displayName: ''},
}); });
// A language change gives a new `t` and runs the effect again; the toast shows once. // A language change gives a new `t` and runs the effect again; the toast shows once.
@@ -123,42 +123,32 @@ export default function SettingsPage() {
} }
if (!response.ok) { if (!response.ok) {
setLoadError(t('INFO.ERROR.LOAD_PROFILE')); setLoadError('INFO.ERROR.LOAD_PROFILE');
return; return;
} }
const data = await response.json(); const data = await response.json();
setProfile(data); setProfile(data);
reset({ reset({displayName: data.displayName ?? ''});
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en',
displayName: data.displayName ?? '',
});
} catch (err) { } catch (err) {
console.error('Settings load error:', err); console.error('Settings load error:', err);
setLoadError(t('INFO.ERROR.NETWORK_FAILURE')); setLoadError('INFO.ERROR.NETWORK_FAILURE');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
load(); load();
}, [navigate, expireSession, reset, t, steamOutcome]); }, [navigate, expireSession, reset, steamOutcome]);
const persistSettings = async (payload) => { const persistSettings = async (send) => {
if (!hasSession()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return {ok: false}; return {ok: false};
} }
try { try {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, { const response = await send();
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.status === 401) { if (response.status === 401) {
expireSession(); expireSession();
@@ -181,23 +171,14 @@ export default function SettingsPage() {
} }
}; };
// The backend replaces all settings, so every save sends the saved values of the fields it does not change.
const savedSettings = () => ({
useDarkTheme: isDarkMode,
language: i18n.language,
displayName: profile?.displayName ?? null,
publicProfile: !!profile?.settings?.publicProfile,
});
const handleDarkThemeToggle = async () => { const handleDarkThemeToggle = async () => {
const next = !isDarkMode; const next = !isDarkMode;
setDarkMode(next); // apply on the fly setDarkMode(next); // apply on the fly
await persistSettings({...savedSettings(), useDarkTheme: next}); await persistSettings(() => saveSettings({useDarkTheme: next}));
}; };
const handleLanguageChange = async (next) => { const handleLanguageChange = async (next) => {
setLanguage(next); // apply on the fly await persistSettings(() => setLanguage(next));
await persistSettings({...savedSettings(), language: next});
}; };
const handleFavoritePlatformChange = async (value) => { const handleFavoritePlatformChange = async (value) => {
@@ -210,7 +191,7 @@ export default function SettingsPage() {
const handlePublicProfileToggle = async () => { const handlePublicProfileToggle = async () => {
const next = !profile?.settings?.publicProfile; const next = !profile?.settings?.publicProfile;
const result = await persistSettings({...savedSettings(), publicProfile: next}); const result = await persistSettings(() => saveSettings({publicProfile: next}));
if (result.ok) { if (result.ok) {
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}})); setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
} }
@@ -237,12 +218,8 @@ export default function SettingsPage() {
const onSubmitProfile = async (values) => { const onSubmitProfile = async (values) => {
// Without a name the profile has no link, so removing the name also makes it private. // Without a name the profile has no link, so removing the name also makes it private.
const publicProfile = savedSettings().publicProfile && !!values.displayName; const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
const result = await persistSettings({ const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
...savedSettings(),
displayName: values.displayName,
publicProfile,
});
if (result.ok) { if (result.ok) {
setProfile((p) => (p ? { setProfile((p) => (p ? {
...p, displayName: values.displayName, settings: {...p.settings, publicProfile}, ...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
@@ -262,7 +239,7 @@ export default function SettingsPage() {
} }
if (loadError) { if (loadError) {
return <div className="py-24 text-center text-destructive">{loadError}</div>; return <div className="py-24 text-center text-destructive">{t(loadError)}</div>;
} }
const publicProfile = !!profile?.settings?.publicProfile; const publicProfile = !!profile?.settings?.publicProfile;
@@ -313,7 +290,7 @@ export default function SettingsPage() {
<SelectValue/> <SelectValue/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SUPPORTED_LANGUAGES.map((lang) => ( {LANGUAGES.map((lang) => (
<SelectItem key={lang} value={lang}> <SelectItem key={lang} value={lang}>
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)} {t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
</SelectItem> </SelectItem>
+33 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import SettingsPage from './SettingsPage.jsx'; import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -29,6 +29,24 @@ function renderPage(me) {
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>); render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
} }
/**
* Auth with PUT /api/users/settings answered one by one through answer(); GET /api/users/me shows what was saved.
*/
function fakeAuth(me) {
const saved = structuredClone(me);
const open = [];
globalThis.fetch = vi.fn((url, init) => {
if (init?.method !== 'PUT') return Promise.resolve({ok: true, status: 200, json: async () => structuredClone(saved)});
return new Promise((resolve) => open.push(() => {
const body = JSON.parse(init.body);
saved.displayName = body.displayName;
Object.assign(saved.settings, {useDarkTheme: body.useDarkTheme, language: body.language, publicProfile: body.publicProfile});
resolve({ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }});
}));
});
return {answer: () => open.shift()()};
}
describe('SettingsPage public profile', () => { describe('SettingsPage public profile', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1')); beforeEach(() => localStorage.setItem('tbp_session', '1'));
@@ -46,6 +64,20 @@ describe('SettingsPage public profile', () => {
expect(puts()[0].publicProfile).toBe(true); expect(puts()[0].publicProfile).toBe(true);
}); });
it('builds a save on the one before it, also while that one is still open', async () => {
const auth = fakeAuth(profile());
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await act(async () => auth.answer());
await waitFor(() => expect(puts()).toHaveLength(2));
expect(puts()[1]).toMatchObject({publicProfile: false, useDarkTheme: false, displayName: 'Lobo'});
await act(async () => auth.answer());
});
it('turns the public profile off and shows the link while it is on', async () => { it('turns the public profile off and shows the link while it is on', async () => {
renderPage(profile()); renderPage(profile());
+4 -4
View File
@@ -40,20 +40,20 @@ export default function WishlistPage() {
return; return;
} }
if (!response.ok) { if (!response.ok) {
setError(t('WISHLIST.ERROR_LOAD')); setError('WISHLIST.ERROR_LOAD');
return; return;
} }
const json = await response.json(); const json = await response.json();
if (!cancelled) setGames(json.games ?? []); if (!cancelled) setGames(json.games ?? []);
} catch (err) { } catch (err) {
console.error('Wishlist load error:', err); console.error('Wishlist load error:', err);
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE')); if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE');
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [navigate, expireSession, t]); }, [navigate, expireSession]);
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0); const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length; const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
@@ -91,7 +91,7 @@ export default function WishlistPage() {
</Card> </Card>
)} )}
{error && <div className="py-12 text-center text-destructive">{error}</div>} {error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
{games !== null && shown.length === 0 && ( {games !== null && shown.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
+54
View File
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {switchLanguage} from '../test/i18n-mock.js';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import Dashboard from './Dashboard.jsx';
import GameDetail from './GameDetail.jsx';
import GameLibrary from './GameLibrary.jsx';
import PublicProfilePage from './PublicProfilePage.jsx';
import SettingsPage from './SettingsPage.jsx';
import WishlistPage from './WishlistPage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const pages = [
['Dashboard', '/', '/', <Dashboard/>, 'INFO.ERROR.LOAD_PROFILE'],
['GameDetail', '/games/:appid', '/games/220', <GameDetail/>, 'GAME_DETAIL.ERROR_LOAD'],
['GameLibrary', '/library', '/library', <GameLibrary/>, 'LIBRARY.ERROR_LOAD'],
['PublicProfilePage', '/u/:name', '/u/Lobo', <PublicProfilePage/>, 'PROFILE.ERROR_LOAD'],
['SettingsPage', '/settings', '/settings', <SettingsPage/>, 'INFO.ERROR.LOAD_PROFILE'],
['WishlistPage', '/wishlist', '/wishlist', <WishlistPage/>, 'WISHLIST.ERROR_LOAD'],
];
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
switchLanguage('de');
globalThis.fetch = vi.fn(async () => ({ok: false, status: 500, json: async () => ({})}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('a language change', () => {
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, page, errorKey) => {
const tree = () => (
<MemoryRouter initialEntries={[url]}>
<PreferencesProvider><Routes><Route path={path} element={page}/></Routes></PreferencesProvider>
</MemoryRouter>
);
const {rerender} = render(tree());
await screen.findByText(`de:${errorKey}`);
const requests = fetch.mock.calls.length;
switchLanguage('en');
rerender(tree());
expect(await screen.findByText(`en:${errorKey}`)).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetch.mock.calls.length).toBe(requests);
});
});
+11 -2
View File
@@ -1,6 +1,15 @@
// Module mock for react-i18next: t() returns the key (plus ":<count>" for plurals), so tests do not depend on the texts. // Module mock for react-i18next: t() returns the key (plus ":<count>" for plurals), so tests do not depend on the texts.
// Use in a test file: vi.mock('react-i18next', () => import('../test/i18n-mock.js')); // Use in a test file: vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const t = (key, options) => (options?.count === undefined ? key : `${key}:${options.count}`); const translate = (key, options) => (options?.count === undefined ? key : `${key}:${options.count}`);
const i18n = {language: 'de'}; let t = translate;
const i18n = {language: 'de', changeLanguage: (language) => { i18n.language = language; }};
export const useTranslation = () => ({t, i18n}); export const useTranslation = () => ({t, i18n});
/**
* A new `t` that puts `language:` before each key, like react-i18next gives a new `t` on a language change. Rerender
* to see it.
*/
export const switchLanguage = (language) => {
t = (key, options) => `${language}:${translate(key, options)}`;
};
+10 -8
View File
@@ -1,4 +1,5 @@
import {apiFetch} from './api.js'; import {apiFetch} from './api.js';
import {fetchMe, REQUEST_TIMEOUT_MS} from './settings.js';
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each. // Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red']; export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
@@ -12,7 +13,6 @@ export const DEFAULT_APPEARANCE = {
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null); export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
const STORAGE_KEY = 'tbp_appearance'; const STORAGE_KEY = 'tbp_appearance';
const REQUEST_TIMEOUT_MS = 10000;
export const normalizeAppearance = (value) => ({ export const normalizeAppearance = (value) => ({
accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent, accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent,
@@ -60,14 +60,16 @@ export const saveAppearance = (appearance) =>
}); });
/** /**
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use. * GET /api/users/me on Auth, for its appearance (null when none is saved) and its language ('' when none is chosen).
* Null when there is no answer to use.
*/ */
export const loadAppearance = async () => { export const loadPreferences = async () => {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { // Saves wait for this load, so it has a timeout.
// Saves wait for this load. const response = await fetchMe();
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
return data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null; return {
appearance: data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null,
language: data.settings?.language ?? '',
};
}; };
+60
View File
@@ -0,0 +1,60 @@
import {apiFetch} from './api.js';
export const LANGUAGES = ['en', 'de'];
const LANGUAGE_STORAGE_KEY = 'tbp_language';
export const REQUEST_TIMEOUT_MS = 10000;
export const readCachedLanguage = () => {
try {
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
return LANGUAGES.includes(stored) ? stored : null;
} catch {
return null;
}
};
export const cacheLanguage = (language) => {
try {
localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
} catch {
// Private mode or a full storage: the language is only not cached.
}
};
/**
* GET /api/users/me on Auth. The caller handles the response status.
*/
export const fetchMe = () => apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
let queue = Promise.resolve();
/**
* PUT /api/users/settings on Auth with `changes`. That endpoint replaces all four fields, so each save first loads
* the saved ones; saves run one after another, from every page. The caller handles the response status.
*/
export const saveSettings = (changes) => {
const save = async () => {
const me = await fetchMe();
if (!me.ok) return me;
const data = await me.json();
return apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language ?? '',
displayName: data.displayName ?? null,
publicProfile: !!data.settings?.publicProfile,
...changes,
}),
// A hung request must not hold back the next saves.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
};
const result = queue.then(save, save);
queue = result.catch(() => {});
return result;
};
+15
View File
@@ -0,0 +1,15 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cacheLanguage, readCachedLanguage} from './settings.js';
afterEach(() => localStorage.clear());
describe('language cache', () => {
it('gives back the cached language, and nothing for an unknown one', () => {
expect(readCachedLanguage()).toBeNull();
cacheLanguage('en');
expect(readCachedLanguage()).toBe('en');
localStorage.setItem('tbp_language', 'fr');
expect(readCachedLanguage()).toBeNull();
});
});
+3 -3
View File
@@ -7,9 +7,11 @@ const emailField = z
.min(1, 'VALIDATION.EMAIL_REQUIRED') .min(1, 'VALIDATION.EMAIL_REQUIRED')
.email('VALIDATION.EMAIL_INVALID'); .email('VALIDATION.EMAIL_INVALID');
// Auth hashes with BCrypt, which takes at most 72 bytes.
const passwordField = z const passwordField = z
.string() .string()
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT'); .min(8, 'VALIDATION.PASSWORD_TOO_SHORT')
.refine((value) => new TextEncoder().encode(value).length <= 72, 'VALIDATION.PASSWORD_TOO_LONG');
const inviteCodeField = z const inviteCodeField = z
.string() .string()
@@ -40,7 +42,5 @@ export const registrationSchema = z
}); });
export const settingsSchema = z.object({ export const settingsSchema = z.object({
useDarkTheme: z.boolean(),
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
displayName: displayNameField, displayName: displayNameField,
}); });
+9
View File
@@ -29,3 +29,12 @@ describe('displayName validation', () => {
}); });
}); });
describe('registration password', () => {
it('rejects a password over 72 UTF-8 bytes, as Auth does', () => {
const password = 'é'.repeat(37);
expect(registrationSchema.safeParse({
email: 'a@b.de', password, passwordConfirmation: password, inviteCode: 'code',
}).success).toBe(false);
});
});