diff --git a/client/src/api/client.ts b/client/src/api/client.ts index ac3e6f65..f296ee39 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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), } +export const configApi = { + getPublicConfig: (): Promise<{ defaultLanguage: string }> => + apiClient.get('/config').then(r => r.data), +} + export const settingsApi = { get: () => apiClient.get('/settings').then(r => r.data), set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index a8a595a9..f18dcf20 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -14,28 +14,22 @@ import ar from './translations/ar' import br from './translations/br' import cs from './translations/cs' import pl from './translations/pl' +import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages' + +export { SUPPORTED_LANGUAGES } type TranslationStrings = Record -export const SUPPORTED_LANGUAGES = [ - { value: 'de', label: 'Deutsch' }, - { value: 'en', label: 'English' }, - { value: 'es', label: 'Español' }, - { value: 'fr', label: 'Français' }, - { value: 'hu', label: 'Magyar' }, - { value: 'nl', label: 'Nederlands' }, - { value: 'br', label: 'Português (Brasil)' }, - { 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 +// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation. +const translations: Record = { + de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl, +} + +// Derived from SUPPORTED_LANGUAGES — add new languages there, not here. +const LOCALES: Record = Object.fromEntries( + SUPPORTED_LANGUAGES.map(l => [l.value, l.locale]) +) -const translations: Record = { de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl } -const LOCALES: Record = { 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']) export function getLocaleForLanguage(language: string): string { @@ -51,6 +45,32 @@ export function isRtlLanguage(language: string): boolean { 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 + + // Portuguese variants → our code is 'br' (pt-BR) + if (lang.toLowerCase().startsWith('pt')) 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 { t: (key: string, params?: Record) => string language: string diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 4d221cd0..6895bdf2 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -4,5 +4,6 @@ export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage, + detectBrowserLanguage, SUPPORTED_LANGUAGES, } from './TranslationContext' diff --git a/client/src/i18n/supportedLanguages.ts b/client/src/i18n/supportedLanguages.ts new file mode 100644 index 00000000..28d957b8 --- /dev/null +++ b/client/src/i18n/supportedLanguages.ts @@ -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) diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index ac5fb4f3..8a309b62 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -2,10 +2,10 @@ import React, { useState, useEffect, useMemo, useRef } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' -import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' -import { authApi } from '../api/client' +import { SUPPORTED_LANGUAGES, useTranslation, detectBrowserLanguage } from '../i18n/TranslationContext' +import { authApi, configApi } from '../api/client' 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 { has_users: boolean @@ -36,8 +36,10 @@ export default function LoginPage(): React.ReactElement { const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) + const [langDropdownOpen, setLangDropdownOpen] = useState(false) + const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore() - const { setLanguageLocal } = useSettingsStore() + const { setLanguageLocal, setLanguageTransient } = useSettingsStore() const navigate = useNavigate() const location = useLocation() const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect @@ -116,6 +118,32 @@ export default function LoginPage(): React.ReactElement { }) }, [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 (localStorage.getItem('app_language')) 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 => { setError('') setIsLoading(true) @@ -364,29 +392,59 @@ export default function LoginPage(): React.ReactElement { return (
- {/* Language toggle */} - + {/* Language dropdown */} +
+ + + {langDropdownOpen && ( +
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 }) => ( + + ))} +
+ )} +
{/* Left — branding */}
Promise updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise setLanguageLocal: (lang: string) => void + setLanguageTransient: (lang: string) => void updateSettings: (settingsObj: Partial) => Promise } @@ -59,6 +61,14 @@ export const useSettingsStore = create((set, get) => ({ 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) => { set((state) => ({ settings: { ...state.settings, ...settingsObj }, diff --git a/client/tests/unit/i18n/index.test.ts b/client/tests/unit/i18n/index.test.ts index 3e529b3f..ae1f7f4b 100644 --- a/client/tests/unit/i18n/index.test.ts +++ b/client/tests/unit/i18n/index.test.ts @@ -91,8 +91,8 @@ describe('SUPPORTED_LANGUAGES', () => { it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => { expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true) expect(SUPPORTED_LANGUAGES).toHaveLength(14) - expect(SUPPORTED_LANGUAGES).toContainEqual({ value: 'en', label: 'English' }) - expect(SUPPORTED_LANGUAGES).toContainEqual({ value: 'ar', label: 'العربية' }) + expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' })) + expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' })) }) }) diff --git a/server/.env.example b/server/.env.example index 932a274f..ba9da901 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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. # 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) +# 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 ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links diff --git a/server/src/app.ts b/server/src/app.ts index 4187dc44..86a1c275 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import fs from 'node:fs'; import jwt from 'jsonwebtoken'; -import { JWT_SECRET } from './config'; +import { JWT_SECRET, DEFAULT_LANGUAGE } from './config'; import { logDebug, logWarn, logError } from './services/auditLog'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import { authenticate } from './middleware/auth'; @@ -194,6 +194,7 @@ export function createApp(): express.Application { app.use('/api/trips/:tripId/reservations', reservationsRoutes); app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' })); + app.get('/api/config', (_req: Request, res: Response) => res.json({ defaultLanguage: DEFAULT_LANGUAGE })); app.use('/api', assignmentsRoutes); app.use('/api/tags', tagsRoutes); app.use('/api/categories', categoriesRoutes); diff --git a/server/src/config.ts b/server/src/config.ts index be5d2bcc..50a6dc97 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -98,3 +98,13 @@ if (_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 +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';