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 <noreply@anthropic.com>
This commit is contained in:
Isaias Tavares
2026-04-11 17:54:50 -03:00
parent 34df665944
commit 57503a6a10
8 changed files with 130 additions and 28 deletions
+5
View File
@@ -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),
+21
View File
@@ -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, string | number>) => string
language: string
+1
View File
@@ -4,5 +4,6 @@ export {
getLocaleForLanguage,
getIntlLanguage,
isRtlLanguage,
detectBrowserLanguage,
SUPPORTED_LANGUAGES,
} from './TranslationContext'
+85 -27
View File
@@ -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<boolean>(false)
const exchangeInitiated = useRef(false)
const [langDropdownOpen, setLangDropdownOpen] = useState<boolean>(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<void> => {
setError('')
setIsLoading(true)
@@ -364,29 +392,59 @@ export default function LoginPage(): React.ReactElement {
return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
{/* Language toggle */}
<button
onClick={() => {
const languages = SUPPORTED_LANGUAGES.map(({ value }) => value)
const currentIndex = languages.findIndex(code => code === language)
const nextLanguage = languages[(currentIndex + 1) % languages.length]
setLanguageLocal(nextLanguage)
}}
style={{
position: 'absolute', top: 16, right: 16, zIndex: 10,
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99,
background: 'rgba(0,0,0,0.06)', border: 'none',
fontSize: 13, fontWeight: 500, color: '#374151',
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)'}
>
<Globe size={14} />
{language.toUpperCase()}
</button>
{/* Language dropdown */}
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
<button
onClick={(e) => { e.stopPropagation(); setLangDropdownOpen(o => !o) }}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99,
background: 'rgba(0,0,0,0.06)', border: 'none',
fontSize: 13, fontWeight: 500, color: '#374151',
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)'}
>
<Globe size={14} />
{SUPPORTED_LANGUAGES.find(l => l.value === language)?.label ?? language.toUpperCase()}
<ChevronDown size={12} style={{ transition: 'transform 0.15s', transform: langDropdownOpen ? 'rotate(180deg)' : 'none' }} />
</button>
{langDropdownOpen && (
<div
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}
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 */}
<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' }}
+8
View File
@@ -10,6 +10,7 @@ interface SettingsState {
loadSettings: () => Promise<void>
updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise<void>
setLanguageLocal: (lang: string) => void
setLanguageTransient: (lang: string) => void
updateSettings: (settingsObj: Partial<Settings>) => Promise<void>
}
@@ -59,6 +60,13 @@ export const useSettingsStore = create<SettingsState>((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<Settings>) => {
set((state) => ({
settings: { ...state.settings, ...settingsObj },
+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.
# 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
+2 -1
View File
@@ -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);
+5
View File
@@ -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';