Merge pull request #659 from mauriceboe/feat/login-language-detection-dropdown

feat(login): add language dropdown, browser auto-detection and configurable default
This commit is contained in:
Julien G.
2026-04-15 03:16:20 +02:00
committed by GitHub
16 changed files with 293 additions and 47 deletions
+2
View File
@@ -161,6 +161,7 @@ services:
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs). - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended. # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
@@ -309,6 +310,7 @@ trek.yourdomain.com {
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | | `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
+4
View File
@@ -19,6 +19,10 @@ env:
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin). # Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info" # LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details. # "info" = concise user actions, "debug" = verbose details.
# DEFAULT_LANGUAGE: "en"
# Default language on the login page for users with no saved preference.
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# ALLOWED_ORIGINS: "" # ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com" # APP_URL: "https://trek.example.com"
+5
View File
@@ -395,6 +395,11 @@ export const weatherApi = {
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
} }
export const configApi = {
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
apiClient.get('/config').then(r => r.data),
}
export const settingsApi = { export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data), get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
+40 -18
View File
@@ -14,28 +14,22 @@ import ar from './translations/ar'
import br from './translations/br' import br from './translations/br'
import cs from './translations/cs' import cs from './translations/cs'
import pl from './translations/pl' import pl from './translations/pl'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES }
type TranslationStrings = Record<string, string | { name: string; category: string }[]> type TranslationStrings = Record<string, string | { name: string; category: string }[]>
export const SUPPORTED_LANGUAGES = [ // Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
{ value: 'de', label: 'Deutsch' }, const translations: Record<SupportedLanguageCode, TranslationStrings> = {
{ value: 'en', label: 'English' }, de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl,
{ value: 'es', label: 'Español' }, }
{ value: 'fr', label: 'Français' },
{ value: 'hu', label: 'Magyar' }, // Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
{ value: 'nl', label: 'Nederlands' }, const LOCALES: Record<string, string> = Object.fromEntries(
{ value: 'br', label: 'Português (Brasil)' }, SUPPORTED_LANGUAGES.map(l => [l.value, l.locale])
{ value: 'cs', label: 'Česky' }, )
{ value: 'pl', label: 'Polski' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'it', label: 'Italiano' },
{ value: 'ar', label: 'العربية' },
] as const
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl }
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', 'zh-TW': 'zh-TW', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ', pl: 'pl-PL' }
const RTL_LANGUAGES = new Set(['ar']) const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string { export function getLocaleForLanguage(language: string): string {
@@ -51,6 +45,34 @@ export function isRtlLanguage(language: string): boolean {
return RTL_LANGUAGES.has(language) return RTL_LANGUAGES.has(language)
} }
// Detects the user's preferred language from the browser/OS settings and maps
// it to one of the supported language codes. Returns null if no match is found.
export function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const browserLangs = navigator.languages?.length
? navigator.languages
: navigator.language ? [navigator.language] : []
const supported = SUPPORTED_LANGUAGES.map(l => l.value)
for (const lang of browserLangs) {
// Exact match (e.g. 'de', 'zh-TW') — case-insensitive
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
if (exactMatch) return exactMatch
// pt-BR has no exact match (our code is 'br', not 'pt-BR'), so map it explicitly.
// pt-PT and bare 'pt' are NOT mapped — they fall through to null and let the
// server default or 'en' fallback apply instead.
if (lang.toLowerCase() === 'pt-br') return 'br'
// Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') — case-insensitive
const prefix = lang.split('-')[0].toLowerCase()
const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
if (prefixMatch) return prefixMatch
}
return null
}
interface TranslationContextValue { interface TranslationContextValue {
t: (key: string, params?: Record<string, string | number>) => string t: (key: string, params?: Record<string, string | number>) => string
language: string language: string
+1
View File
@@ -4,5 +4,6 @@ export {
getLocaleForLanguage, getLocaleForLanguage,
getIntlLanguage, getIntlLanguage,
isRtlLanguage, isRtlLanguage,
detectBrowserLanguage,
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
} from './TranslationContext' } from './TranslationContext'
+20
View File
@@ -0,0 +1,20 @@
export const SUPPORTED_LANGUAGES = [
{ value: 'de', label: 'Deutsch', locale: 'de-DE' },
{ value: 'en', label: 'English', locale: 'en-US' },
{ value: 'es', label: 'Español', locale: 'es-ES' },
{ value: 'fr', label: 'Français', locale: 'fr-FR' },
{ value: 'hu', label: 'Magyar', locale: 'hu-HU' },
{ value: 'nl', label: 'Nederlands', locale: 'nl-NL' },
{ value: 'br', label: 'Português (Brasil)', locale: 'pt-BR' },
{ value: 'cs', label: 'Česky', locale: 'cs-CZ' },
{ value: 'pl', label: 'Polski', locale: 'pl-PL' },
{ value: 'ru', label: 'Русский', locale: 'ru-RU' },
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
export const SUPPORTED_LANGUAGE_CODES: string[] = SUPPORTED_LANGUAGES.map(l => l.value)
+93 -27
View File
@@ -2,10 +2,11 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' import { SUPPORTED_LANGUAGES, useTranslation, detectBrowserLanguage } from '../i18n'
import { authApi } from '../api/client' import { authApi, configApi } from '../api/client'
import { hasStoredLanguage } from '../store/settingsStore'
import { getApiErrorMessage } from '../types' import { getApiErrorMessage } from '../types'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react' import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown } from 'lucide-react'
interface AppConfig { interface AppConfig {
has_users: boolean has_users: boolean
@@ -36,8 +37,10 @@ export default function LoginPage(): React.ReactElement {
const [inviteValid, setInviteValid] = useState<boolean>(false) const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false) const exchangeInitiated = useRef(false)
const [langDropdownOpen, setLangDropdownOpen] = useState<boolean>(false)
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore() const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal } = useSettingsStore() const { setLanguageLocal, setLanguageTransient } = useSettingsStore()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect
@@ -116,6 +119,32 @@ export default function LoginPage(): React.ReactElement {
}) })
}, [navigate, t, noRedirect]) }, [navigate, t, noRedirect])
// Language detection chain (runs once on mount, only if user has no saved preference):
// 1. localStorage → already in store initial state, skip
// 2. Browser/OS language (navigator.languages)
// 3. Server default (DEFAULT_LANGUAGE env var)
// 4. 'en' → hardcoded fallback already in store
useEffect(() => {
if (hasStoredLanguage()) return
const detected = detectBrowserLanguage()
if (detected) {
setLanguageTransient(detected)
return
}
configApi.getPublicConfig()
.then(({ defaultLanguage }) => { if (defaultLanguage) setLanguageTransient(defaultLanguage) })
.catch((err) => console.warn('Failed to fetch default language config:', err))
}, [setLanguageTransient])
useEffect(() => {
if (!langDropdownOpen) return
const close = () => setLangDropdownOpen(false)
document.addEventListener('click', close)
return () => document.removeEventListener('click', close)
}, [langDropdownOpen])
const handleDemoLogin = async (): Promise<void> => { const handleDemoLogin = async (): Promise<void> => {
setError('') setError('')
setIsLoading(true) setIsLoading(true)
@@ -364,29 +393,66 @@ export default function LoginPage(): React.ReactElement {
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}> <div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
{/* Language toggle */} {/* Language dropdown */}
<button <div style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
onClick={() => { <button
const languages = SUPPORTED_LANGUAGES.map(({ value }) => value) onClick={(e) => { e.stopPropagation(); setLangDropdownOpen(o => !o) }}
const currentIndex = languages.findIndex(code => code === language) aria-haspopup="listbox"
const nextLanguage = languages[(currentIndex + 1) % languages.length] aria-expanded={langDropdownOpen}
setLanguageLocal(nextLanguage) aria-label="Change language"
}} style={{
style={{ display: 'flex', alignItems: 'center', gap: 6,
position: 'absolute', top: 16, right: 16, zIndex: 10, padding: '6px 12px', borderRadius: 99,
display: 'flex', alignItems: 'center', gap: 6, background: 'rgba(0,0,0,0.06)', border: 'none',
padding: '6px 12px', borderRadius: 99, fontSize: 13, fontWeight: 500, color: '#374151',
background: 'rgba(0,0,0,0.06)', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500, color: '#374151', transition: 'background 0.15s',
cursor: 'pointer', fontFamily: 'inherit', }}
transition: 'background 0.15s', onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
}} onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'} >
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'} <Globe size={14} />
> {SUPPORTED_LANGUAGES.find(l => l.value === language)?.label ?? language.toUpperCase()}
<Globe size={14} /> <ChevronDown size={12} style={{ transition: 'transform 0.15s', transform: langDropdownOpen ? 'rotate(180deg)' : 'none' }} />
{language.toUpperCase()} </button>
</button>
{langDropdownOpen && (
<div
role="listbox"
aria-label="Select language"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute', top: '100%', right: 0, marginTop: 4,
background: 'white', borderRadius: 12,
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
border: '1px solid rgba(0,0,0,0.08)',
minWidth: 190, maxHeight: 320, overflowY: 'auto',
}}
>
{SUPPORTED_LANGUAGES.map(({ value, label }) => (
<button
key={value}
role="option"
aria-selected={value === language}
onClick={() => { setLanguageLocal(value); setLangDropdownOpen(false) }}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '9px 16px', border: 'none',
background: value === language ? 'rgba(99,102,241,0.08)' : 'transparent',
color: value === language ? '#4f46e5' : '#374151',
fontWeight: value === language ? 600 : 400,
fontSize: 14, cursor: 'pointer', fontFamily: 'inherit',
transition: 'background 0.1s',
}}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (value !== language) e.currentTarget.style.background = 'rgba(0,0,0,0.04)' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { if (value !== language) e.currentTarget.style.background = 'transparent' }}
>
{label}
</button>
))}
</div>
)}
</div>
{/* Left — branding */} {/* Left — branding */}
<div style={{ display: 'none', width: '55%', background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '60px 48px', position: 'relative', overflow: 'hidden' }} <div style={{ display: 'none', width: '55%', background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '60px 48px', position: 'relative', overflow: 'hidden' }}
+15
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { settingsApi } from '../api/client' import { settingsApi } from '../api/client'
import type { Settings } from '../types' import type { Settings } from '../types'
import { getApiErrorMessage } from '../types' import { getApiErrorMessage } from '../types'
import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages'
interface SettingsState { interface SettingsState {
settings: Settings settings: Settings
@@ -10,9 +11,15 @@ interface SettingsState {
loadSettings: () => Promise<void> loadSettings: () => Promise<void>
updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise<void> updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise<void>
setLanguageLocal: (lang: string) => void setLanguageLocal: (lang: string) => void
setLanguageTransient: (lang: string) => void
updateSettings: (settingsObj: Partial<Settings>) => Promise<void> updateSettings: (settingsObj: Partial<Settings>) => Promise<void>
} }
// Returns true when the user has explicitly chosen a language (persisted in localStorage).
// Use this instead of reading localStorage directly so the key stays encapsulated here.
export const hasStoredLanguage = (): boolean =>
typeof localStorage !== 'undefined' && !!localStorage.getItem('app_language')
export const useSettingsStore = create<SettingsState>((set, get) => ({ export const useSettingsStore = create<SettingsState>((set, get) => ({
settings: { settings: {
map_tile_url: '', map_tile_url: '',
@@ -59,6 +66,14 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
set((state) => ({ settings: { ...state.settings, language: lang } })) set((state) => ({ settings: { ...state.settings, language: lang } }))
}, },
// Applies a language for the current session without persisting to localStorage.
// Used for automatic detection (browser/server default) — only explicit user
// choices via the UI should be persisted.
setLanguageTransient: (lang: string) => {
if (!SUPPORTED_LANGUAGE_CODES.includes(lang)) return
set((state) => ({ settings: { ...state.settings, language: lang } }))
},
updateSettings: async (settingsObj: Partial<Settings>) => { updateSettings: async (settingsObj: Partial<Settings>) => {
set((state) => ({ set((state) => ({
settings: { ...state.settings, ...settingsObj }, settings: { ...state.settings, ...settingsObj },
+53 -2
View File
@@ -8,6 +8,7 @@ import {
getIntlLanguage, getIntlLanguage,
isRtlLanguage, isRtlLanguage,
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
detectBrowserLanguage,
} from '../../../src/i18n' } from '../../../src/i18n'
import { resetAllStores, seedStore } from '../../helpers/store' import { resetAllStores, seedStore } from '../../helpers/store'
import { useSettingsStore } from '../../../src/store/settingsStore' import { useSettingsStore } from '../../../src/store/settingsStore'
@@ -91,8 +92,58 @@ describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => { it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true) expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
expect(SUPPORTED_LANGUAGES).toHaveLength(14) expect(SUPPORTED_LANGUAGES).toHaveLength(14)
expect(SUPPORTED_LANGUAGES).toContainEqual({ value: 'en', label: 'English' }) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual({ value: 'ar', label: 'العربية' }) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
})
})
// ── FE-COMP-I18N-016 to 023: detectBrowserLanguage ───────────────────────────
describe('detectBrowserLanguage', () => {
afterEach(() => {
Object.defineProperty(navigator, 'languages', { value: [], configurable: true })
Object.defineProperty(navigator, 'language', { value: '', configurable: true })
})
it('FE-COMP-I18N-016: exact match returns the matched code', () => {
Object.defineProperty(navigator, 'languages', { value: ['de'], configurable: true })
expect(detectBrowserLanguage()).toBe('de')
})
it('FE-COMP-I18N-017: region-tagged exact match (zh-TW) returns zh-TW', () => {
Object.defineProperty(navigator, 'languages', { value: ['zh-TW'], configurable: true })
expect(detectBrowserLanguage()).toBe('zh-TW')
})
it('FE-COMP-I18N-018: prefix match (de-AT → de)', () => {
Object.defineProperty(navigator, 'languages', { value: ['de-AT'], configurable: true })
expect(detectBrowserLanguage()).toBe('de')
})
it('FE-COMP-I18N-019: pt-PT returns null (European Portuguese is a distinct language)', () => {
Object.defineProperty(navigator, 'languages', { value: ['pt-PT'], configurable: true })
expect(detectBrowserLanguage()).toBeNull()
})
it('FE-COMP-I18N-020: pt-BR maps to br', () => {
Object.defineProperty(navigator, 'languages', { value: ['pt-BR'], configurable: true })
expect(detectBrowserLanguage()).toBe('br')
})
it('FE-COMP-I18N-021: first-match-wins across multiple entries', () => {
Object.defineProperty(navigator, 'languages', { value: ['xx-XX', 'fr'], configurable: true })
expect(detectBrowserLanguage()).toBe('fr')
})
it('FE-COMP-I18N-022: unknown language returns null', () => {
Object.defineProperty(navigator, 'languages', { value: ['xx'], configurable: true })
expect(detectBrowserLanguage()).toBeNull()
})
it('FE-COMP-I18N-023: falls back to navigator.language when navigator.languages is empty', () => {
Object.defineProperty(navigator, 'languages', { value: [], configurable: true })
Object.defineProperty(navigator, 'language', { value: 'es', configurable: true })
expect(detectBrowserLanguage()).toBe('es')
}) })
}) })
@@ -170,6 +170,37 @@ describe('settingsStore', () => {
}); });
}); });
describe('FE-STORE-SETTINGS-015: setLanguageTransient updates state without touching localStorage', () => {
it('sets language in state but does not write to localStorage', () => {
localStorage.clear();
useSettingsStore.getState().setLanguageTransient('fr');
expect(useSettingsStore.getState().settings.language).toBe('fr');
expect(localStorage.getItem('app_language')).toBeNull();
});
});
describe('FE-STORE-SETTINGS-016: setLanguageTransient rejects unsupported language code', () => {
it('leaves state unchanged for an unknown code', () => {
const before = useSettingsStore.getState().settings.language;
useSettingsStore.getState().setLanguageTransient('xx');
expect(useSettingsStore.getState().settings.language).toBe(before);
});
});
describe('FE-STORE-SETTINGS-017: setLanguageTransient does not overwrite an explicit localStorage choice', () => {
it('localStorage remains unchanged after a transient set', () => {
localStorage.setItem('app_language', 'de');
useSettingsStore.getState().setLanguageTransient('es');
expect(localStorage.getItem('app_language')).toBe('de');
});
});
describe('FE-STORE-SETTINGS-014: updateSetting API failure leaves optimistic state', () => { describe('FE-STORE-SETTINGS-014: updateSetting API failure leaves optimistic state', () => {
it('throws on API failure but keeps the optimistic state', async () => { it('throws on API failure but keeps the optimistic state', async () => {
server.use( server.use(
+1
View File
@@ -21,6 +21,7 @@ services:
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs). - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended. # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
+3
View File
@@ -6,6 +6,9 @@ NODE_ENV=development # development = development mode; production = production m
# existing encrypted data remains readable, then re-save credentials via the admin panel. # existing encrypted data remains readable, then re-save credentials via the admin panel.
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en)
# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# Note: browser/OS language is detected automatically first; this is the fallback when no match is found.
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
+2
View File
@@ -41,6 +41,7 @@ import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share'; import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey'; import journeyRoutes from './routes/journey';
import journeyPublicRoutes from './routes/journeyPublic'; import journeyPublicRoutes from './routes/journeyPublic';
import publicConfigRoutes from './routes/publicConfig';
import { mcpHandler } from './mcp'; import { mcpHandler } from './mcp';
import { Addon } from './types'; import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService'; import { getPhotoProviderConfig } from './services/memories/helpersService';
@@ -194,6 +195,7 @@ export function createApp(): express.Application {
app.use('/api/trips/:tripId/reservations', reservationsRoutes); app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' })); app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
app.use('/api/config', publicConfigRoutes);
app.use('/api', assignmentsRoutes); app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes); app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes); app.use('/api/categories', categoriesRoutes);
+12
View File
@@ -98,3 +98,15 @@ if (_encryptionKey) {
} }
export const ENCRYPTION_KEY = _encryptionKey; export const ENCRYPTION_KEY = _encryptionKey;
// DEFAULT_LANGUAGE sets the language shown on the login page before the user
// selects one. Only applies when the user has no saved language preference.
// Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source).
// Kept duplicated here because server and client are separate npm packages.
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
const rawDefaultLang = process.env.DEFAULT_LANGUAGE || 'en';
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
}
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
+10
View File
@@ -0,0 +1,10 @@
import express, { Request, Response } from 'express';
import { DEFAULT_LANGUAGE } from '../config';
const router = express.Router();
router.get('/', (_req: Request, res: Response) => {
res.json({ defaultLanguage: DEFAULT_LANGUAGE });
});
export default router;
+1
View File
@@ -33,6 +33,7 @@
<Config Name="NODE_ENV" Target="NODE_ENV" Default="production" Mode="" Description="Node environment (production / development)." Type="Variable" Display="advanced" Required="false" Mask="false">production</Config> <Config Name="NODE_ENV" Target="NODE_ENV" Default="production" Mode="" Description="Node environment (production / development)." Type="Variable" Display="advanced" Required="false" Mask="false">production</Config>
<Config Name="TZ" Target="TZ" Default="UTC" Mode="" Description="Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)." Type="Variable" Display="always" Required="false" Mask="false">UTC</Config> <Config Name="TZ" Target="TZ" Default="UTC" Mode="" Description="Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)." Type="Variable" Display="always" Required="false" Mask="false">UTC</Config>
<Config Name="LOG_LEVEL" Target="LOG_LEVEL" Default="info" Mode="" Description="Log verbosity: info = concise user actions, debug = verbose admin-level details." Type="Variable" Display="advanced" Required="false" Mask="false">info</Config> <Config Name="LOG_LEVEL" Target="LOG_LEVEL" Default="info" Mode="" Description="Log verbosity: info = concise user actions, debug = verbose admin-level details." Type="Variable" Display="advanced" Required="false" Mask="false">info</Config>
<Config Name="DEFAULT_LANGUAGE" Target="DEFAULT_LANGUAGE" Default="en" Mode="" Description="Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar." Type="Variable" Display="advanced" Required="false" Mask="false">en</Config>
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/> <Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/> <Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config> <Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>