From 57503a6a102eb746a8114ffb0789a2a5edd2e925 Mon Sep 17 00:00:00 2001 From: Isaias Tavares Date: Sat, 11 Apr 2026 17:54:50 -0300 Subject: [PATCH] feat(login): add language dropdown, browser auto-detection and configurable default Replace the language cycling button on the login page with a dropdown showing all 14 supported languages. Add automatic browser/OS language detection via navigator.languages, falling back to a configurable DEFAULT_LANGUAGE env var, then 'en' as last resort. Co-Authored-By: Claude Sonnet 4.6 --- client/src/api/client.ts | 5 ++ client/src/i18n/TranslationContext.tsx | 21 +++++ client/src/i18n/index.ts | 1 + client/src/pages/LoginPage.tsx | 112 +++++++++++++++++++------ client/src/store/settingsStore.ts | 8 ++ server/.env.example | 3 + server/src/app.ts | 3 +- server/src/config.ts | 5 ++ 8 files changed, 130 insertions(+), 28 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 156da726..41f026b5 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -378,6 +378,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 }> => + fetch('/api/config').then(r => r.json()), +} + 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..e73e2caa 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -51,6 +51,27 @@ 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 { + const browserLangs = navigator.languages?.length ? navigator.languages : [navigator.language] + const supported = SUPPORTED_LANGUAGES.map(l => l.value) + + for (const lang of browserLangs) { + // Exact match (e.g. 'de', 'zh-TW') + if (supported.includes(lang)) return lang + + // Portuguese variants → our code is 'br' (pt-BR) + if (lang.startsWith('pt')) return 'br' + + // Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') + const prefix = lang.split('-')[0] + if (supported.includes(prefix)) return prefix + } + + 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/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index ac5fb4f3..64b20666 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' +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(() => {}) + }, [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 +60,13 @@ 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) => { + set((state) => ({ settings: { ...state.settings, language: lang } })) + }, + updateSettings: async (settingsObj: Partial) => { set((state) => ({ settings: { ...state.settings, ...settingsObj }, 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 3bf2336a..f88ac81d 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'; @@ -193,6 +193,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..1f3e787d 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -98,3 +98,8 @@ 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 +export const DEFAULT_LANGUAGE = process.env.DEFAULT_LANGUAGE || 'en';