diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 31ab75811..9502fda6c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -126,7 +126,7 @@ const RATE_LIMIT_MESSAGES: Record = { function translateRateLimit(): string { const fallback = RATE_LIMIT_MESSAGES['en']! try { - const lang = localStorage.getItem('app_language') || 'en' + const lang = localStorage.getItem('app_language') || localStorage.getItem('app_language_server') || 'en' return RATE_LIMIT_MESSAGES[lang] ?? fallback } catch { return fallback diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index f9960e8bc..532f9420a 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -12,6 +12,7 @@ import { useSystemNoticeStore } from './systemNoticeStore.js' import { clearAppearanceSnapshot } from '../theme/applyAppearance' import { clearAllPluginSessions } from './pluginStore' import { forgetStartDestination } from '../utils/startDestination' +import { forgetServerLanguage } from './settingsStore' import { markSignedOut, clearSignedOut } from '../utils/signedOut' interface AuthResponse { @@ -236,6 +237,10 @@ export const useAuthStore = create()( // And the startup-destination mirror, or the next account on this browser // gets bounced into a trip it may not even be able to see. forgetStartDestination() + // Likewise the language mirror: the login page only overrides it when the + // browser language is one TREK ships, so otherwise the next user here stays + // in the previous account's language, launch after launch. + forgetServerLanguage() // 4. Tell server to clear the httpOnly cookie (best-effort). await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) // 5. Clear service worker caches containing sensitive data. diff --git a/client/src/store/settingsStore.test.ts b/client/src/store/settingsStore.test.ts index e25ac3d9f..5e7ef67ba 100644 --- a/client/src/store/settingsStore.test.ts +++ b/client/src/store/settingsStore.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { DEFAULT_SETTINGS, useSettingsStore } from './settingsStore' +import { DEFAULT_SETTINGS, forgetServerLanguage, useSettingsStore } from './settingsStore' import { settingsApi } from '../api/client' import { clearTileCache } from '../sync/tilePrefetcher' @@ -128,4 +128,35 @@ describe('offline language fallback', () => { const fresh = await import('./settingsStore') expect(fresh.DEFAULT_SETTINGS.language).toBe('de') }) + + // The account of the user who just signed in decides, and an account without a + // language of its own sends no language key at all. Leaving the previous + // account's mirror standing is what stranded the next user on a shared browser. + it('SETTINGS-LANG-005: an account with no language of its own clears the mirror', async () => { + localStorage.setItem('app_language_server', 'ja') + vi.mocked(settingsApi.get).mockResolvedValue({ settings: { default_currency: 'EUR' } } as never) + + await useSettingsStore.getState().loadSettings() + + expect(localStorage.getItem('app_language_server')).toBeNull() + }) + + it('SETTINGS-LANG-006: a language TREK does not ship never reaches the mirror', async () => { + localStorage.setItem('app_language_server', 'ja') + vi.mocked(settingsApi.get).mockResolvedValue({ settings: { language: 'kl' } } as never) + + await useSettingsStore.getState().loadSettings() + + expect(localStorage.getItem('app_language_server')).toBeNull() + }) + + it('SETTINGS-LANG-007: forgetServerLanguage drops the mirror and leaves the explicit choice alone', () => { + localStorage.setItem('app_language', 'de') + localStorage.setItem('app_language_server', 'ja') + + forgetServerLanguage() + + expect(localStorage.getItem('app_language_server')).toBeNull() + expect(localStorage.getItem('app_language')).toBe('de') + }) }) diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index b55997490..1dee91433 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -24,6 +24,33 @@ interface SettingsState { export const hasStoredLanguage = (): boolean => typeof localStorage !== 'undefined' && !!localStorage.getItem('app_language') +const SERVER_LANGUAGE_KEY = 'app_language_server' + +// An account that never picked a language sends no language key at all +// (getUserSettings returns only what was set), so a mirror left over from +// whoever used this browser before has to go rather than survive the load. +function rememberServerLanguage(settings: Partial): void { + try { + const language = settings.language + if (language && SUPPORTED_LANGUAGE_CODES.includes(language)) { + localStorage.setItem(SERVER_LANGUAGE_KEY, language) + } else { + localStorage.removeItem(SERVER_LANGUAGE_KEY) + } + } catch { + // Private mode: the session still has the value in the store. + } +} + +/** On logout, so the next account on this browser starts in its own language. */ +export function forgetServerLanguage(): void { + try { + localStorage.removeItem(SERVER_LANGUAGE_KEY) + } catch { + // Nothing to clean up if storage is unavailable. + } +} + // The effective client-side defaults for a fresh instance. The server sends no value for // a setting an admin hasn't defaulted (see settingsService.getAdminUserDefaults), so these // are what a brand-new user actually sees. Keep them internally consistent — one @@ -39,7 +66,7 @@ export const DEFAULT_SETTINGS: Settings = { // language (written by loadSettings below), then English. Without the mirror // an offline cold start boots in English: the account's language lives on the // server, and the fetch that would apply it cannot happen. - language: localStorage.getItem('app_language') || localStorage.getItem('app_language_server') || 'en', + language: localStorage.getItem('app_language') || localStorage.getItem(SERVER_LANGUAGE_KEY) || 'en', temperature_unit: 'celsius', distance_unit: 'metric', time_format: '24h', @@ -110,9 +137,7 @@ export const useSettingsStore = create((set, get) => ({ // key rather than 'app_language': that one means an explicit in-app // choice, and the login page's detection chain must keep running for // users who never made one. - try { - if (incoming.language) localStorage.setItem('app_language_server', incoming.language) - } catch { /* private mode — the session still has the value in the store */ } + rememberServerLanguage(incoming) } catch (err: unknown) { // Leave isLoaded false so a transient failure — offline at launch, or a // (docker) server cold-start racing the first request — is retried on diff --git a/client/tests/unit/stores/authStore.test.ts b/client/tests/unit/stores/authStore.test.ts index 9b81d1083..1b556f86b 100644 --- a/client/tests/unit/stores/authStore.test.ts +++ b/client/tests/unit/stores/authStore.test.ts @@ -759,4 +759,20 @@ describe('authStore', () => { expect(syncTriggers.registerSyncTriggers).toHaveBeenCalled(); }); }); + + // The mirror of the account's server-side language is a per-device copy like the + // appearance snapshot and the startup destination, and gets dropped with them. + // 'app_language' is not a mirror but this device's own choice, so it stays. + describe('FE-STORE-AUTH-034: logout drops the language mirror', () => { + it('clears the account language but keeps an explicit in-app choice', async () => { + localStorage.setItem('app_language_server', 'ja'); + localStorage.setItem('app_language', 'de'); + useAuthStore.setState({ user: buildUser(), isAuthenticated: true }); + + await useAuthStore.getState().logout(); + + expect(localStorage.getItem('app_language_server')).toBeNull(); + expect(localStorage.getItem('app_language')).toBe('de'); + }); + }); });