fix(settings): the offline language mirror does not outlive the account (#2146)

The mirror of the account language stayed in localStorage past logout, so the next account on the device booted in the previous user's language forever. It is cleared on logout and rewritten on login, and the rate-limit message follows the same order the boot language does.
This commit is contained in:
Maurice
2026-09-02 19:00:49 +02:00
committed by Maurice
parent 7c857173cf
commit a5d87d295a
5 changed files with 83 additions and 6 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
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
+5
View File
@@ -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<AuthState>()(
// 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.
+32 -1
View File
@@ -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')
})
})
+29 -4
View File
@@ -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<Settings>): 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<SettingsState>((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
@@ -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');
});
});
});