mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
chore: merge PR 592 changes into branch
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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,32 @@ 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
|
||||||
|
|
||||||
|
// 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 {
|
interface TranslationContextValue {
|
||||||
t: (key: string, params?: Record<string, string | number>) => string
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
language: string
|
language: string
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export {
|
|||||||
getLocaleForLanguage,
|
getLocaleForLanguage,
|
||||||
getIntlLanguage,
|
getIntlLanguage,
|
||||||
isRtlLanguage,
|
isRtlLanguage,
|
||||||
|
detectBrowserLanguage,
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
} from './TranslationContext'
|
} from './TranslationContext'
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -2,10 +2,10 @@ 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/TranslationContext'
|
||||||
import { authApi } from '../api/client'
|
import { authApi, configApi } from '../api/client'
|
||||||
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 +36,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 +118,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 (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<void> => {
|
const handleDemoLogin = async (): Promise<void> => {
|
||||||
setError('')
|
setError('')
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -364,29 +392,59 @@ 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)
|
style={{
|
||||||
const nextLanguage = languages[(currentIndex + 1) % languages.length]
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
setLanguageLocal(nextLanguage)
|
padding: '6px 12px', borderRadius: 99,
|
||||||
}}
|
background: 'rgba(0,0,0,0.06)', border: 'none',
|
||||||
style={{
|
fontSize: 13, fontWeight: 500, color: '#374151',
|
||||||
position: 'absolute', top: 16, right: 16, zIndex: 10,
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
transition: 'background 0.15s',
|
||||||
padding: '6px 12px', borderRadius: 99,
|
}}
|
||||||
background: 'rgba(0,0,0,0.06)', border: 'none',
|
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
|
||||||
fontSize: 13, fontWeight: 500, color: '#374151',
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
>
|
||||||
transition: 'background 0.15s',
|
<Globe size={14} />
|
||||||
}}
|
{SUPPORTED_LANGUAGES.find(l => l.value === language)?.label ?? language.toUpperCase()}
|
||||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
|
<ChevronDown size={12} style={{ transition: 'transform 0.15s', transform: langDropdownOpen ? 'rotate(180deg)' : 'none' }} />
|
||||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
</button>
|
||||||
>
|
|
||||||
<Globe size={14} />
|
{langDropdownOpen && (
|
||||||
{language.toUpperCase()}
|
<div
|
||||||
</button>
|
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 */}
|
{/* 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' }}
|
||||||
|
|||||||
@@ -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,6 +11,7 @@ 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>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,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 },
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ 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: 'العربية' }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
-1
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { JWT_SECRET } from './config';
|
import { JWT_SECRET, DEFAULT_LANGUAGE } from './config';
|
||||||
import { logDebug, logWarn, logError } from './services/auditLog';
|
import { logDebug, logWarn, logError } from './services/auditLog';
|
||||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||||
import { authenticate } from './middleware/auth';
|
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/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.get('/api/config', (_req: Request, res: Response) => res.json({ defaultLanguage: DEFAULT_LANGUAGE }));
|
||||||
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);
|
||||||
|
|||||||
@@ -98,3 +98,13 @@ 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
|
||||||
|
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';
|
||||||
|
|||||||
Reference in New Issue
Block a user