diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index ef592cb550..88755f9504 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -58,6 +58,7 @@ public class InitialSetup { applicationProperties.getAutomaticallyGenerated().setKey(secretKey); } } + public void initLegalUrls() throws IOException { // Initialize Terms and Conditions String termsUrl = applicationProperties.getLegal().getTermsAndConditions(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 4f31b83ef9..43aaecd9d7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -74,6 +74,7 @@ public class ConfigController { configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar()); configData.put("languages", applicationProperties.getUi().getLanguages()); configData.put("logoStyle", applicationProperties.getUi().getLogoStyle()); + configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale()); // Security settings // enableLogin requires both the config flag AND proprietary features to be loaded diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index d335eb9c25..4c7f03c7e9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -205,6 +205,10 @@ public class ProprietaryUIDataController { data.setLoginMethod(securityProps.getLoginMethod()); data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin()); + // Add language configuration for login page + data.setLanguages(applicationProperties.getUi().getLanguages()); + data.setDefaultLocale(applicationProperties.getSystem().getDefaultLocale()); + return ResponseEntity.ok(data); } @@ -491,6 +495,8 @@ public class ProprietaryUIDataController { private boolean altLogin; private boolean firstTimeSetup; private boolean showDefaultCredentials; + private List languages; + private String defaultLocale; } @Data diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 7e47d00e0d..5fa8d7cb91 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -20,6 +20,7 @@ import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useLogoAssets } from '@app/hooks/useLogoAssets'; +import AppConfigLoader from '@app/components/shared/AppConfigLoader'; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -81,6 +82,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide {...appConfigProviderProps} > + diff --git a/frontend/src/core/components/shared/AppConfigLoader.tsx b/frontend/src/core/components/shared/AppConfigLoader.tsx index 45cd7ad1bb..7da104a6f6 100644 --- a/frontend/src/core/components/shared/AppConfigLoader.tsx +++ b/frontend/src/core/components/shared/AppConfigLoader.tsx @@ -14,8 +14,9 @@ export default function AppConfigLoader() { useEffect(() => { if (!loading && config) { - // Update supported languages if config specifies a language filter - updateSupportedLanguages(config.languages); + // Update supported languages and apply default locale from server config + // Priority: localStorage > config.defaultLocale > browser detection > fallback + updateSupportedLanguages(config.languages, config.defaultLocale); } }, [config, loading]); diff --git a/frontend/src/core/components/shared/LanguageSelector.tsx b/frontend/src/core/components/shared/LanguageSelector.tsx index 87fd0db628..7eb35d1f73 100644 --- a/frontend/src/core/components/shared/LanguageSelector.tsx +++ b/frontend/src/core/components/shared/LanguageSelector.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { supportedLanguages } from '@app/i18n'; import LocalIcon from '@app/components/shared/LocalIcon'; import styles from '@app/components/shared/LanguageSelector.module.css'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex'; // Types interface LanguageSelectorProps { @@ -163,13 +163,35 @@ const LanguageSelector: React.FC = ({ const [pendingLanguage, setPendingLanguage] = useState(null); const [rippleEffect, setRippleEffect] = useState(null); + // Get the filtered list of supported languages from i18n + // This respects server config (ui.languages) applied by AppConfigLoader + const allowedLanguages = (i18n.options.supportedLngs as string[] || []) + .filter(lang => lang !== 'cimode'); // Exclude i18next debug language + const languageOptions: LanguageOption[] = Object.entries(supportedLanguages) + .filter(([code]) => allowedLanguages.length === 0 || allowedLanguages.includes(code)) .sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB)) .map(([code, name]) => ({ value: code, - label: name, + label: `${name} (${code})`, })); + // Hide the language selector if there's only one language option + // (no point showing a selector when there's nothing to select) + if (languageOptions.length <= 1) { + return null; + } + + // Calculate dropdown width and grid columns based on number of languages + // 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols + const dropdownWidth = languageOptions.length <= 4 ? 300 + : languageOptions.length <= 9 ? 400 + : 600; + + const gridColumns = languageOptions.length <= 4 ? 2 + : languageOptions.length <= 9 ? 3 + : 4; + const handleLanguageChange = (value: string, event: React.MouseEvent) => { // Create ripple effect at click position (only for button mode) if (!compact) { @@ -219,10 +241,11 @@ const LanguageSelector: React.FC = ({ = ({ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))', border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))', - zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE, }} > -
- {languageOptions.map((option, index) => { - const enabledLanguages = [ - 'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI', - 'ru-RU', 'ja-JP', 'ko-KR', 'hu-HU', 'ga-IE', 'bg-BG', 'es-ES', 'hi-IN', 'hr-HR', - 'el-GR', 'ml-ML', 'pt-BR', 'pl-PL', 'pt-PT', 'sk-SK', 'sr-LATN-RS', 'no-NB', - 'th-TH', 'vi-VN', 'az-AZ', 'eu-ES', 'de-DE', 'sv-SE', 'it-IT', 'ca-CA', 'id-ID', - 'ro-RO', 'fr-FR', 'nl-NL', 'da-DK', 'cs-CZ' - ]; - const isDisabled = !enabledLanguages.includes(option.value); - - return ( +
+ {languageOptions.map((option, index) => ( = ({ rippleEffect={rippleEffect} pendingLanguage={pendingLanguage} compact={compact} - disabled={isDisabled} + disabled={false} /> - ); - })} + ))}
diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 5ae0d30a67..2e42f95812 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -20,6 +20,7 @@ export interface AppConfig { serverPort?: number; appNameNavbar?: string; languages?: string[]; + defaultLocale?: string; logoStyle?: 'modern' | 'classic'; enableLogin?: boolean; enableEmailInvites?: boolean; diff --git a/frontend/src/core/i18n.ts b/frontend/src/core/i18n.ts index 1f8452da38..65b3f461f3 100644 --- a/frontend/src/core/i18n.ts +++ b/frontend/src/core/i18n.ts @@ -123,10 +123,21 @@ function normalizeLanguageCode(languageCode: string): string { * If configLanguages is null/empty, all languages remain available * Otherwise, only the specified languages are enabled with the first valid * option (preferring en-GB when present) used as the fallback language. + * + * @param configLanguages - Optional array of language codes from server config (ui.languages) + * @param defaultLocale - Optional default language for new users (system.defaultLocale) */ -export function updateSupportedLanguages(configLanguages?: string[] | null) { +export function updateSupportedLanguages(configLanguages?: string[] | null, defaultLocale?: string | null) { + // Normalize and validate default locale if provided + const normalizedDefault = defaultLocale ? normalizeLanguageCode(defaultLocale) : null; + const validDefault = normalizedDefault && normalizedDefault in supportedLanguages ? normalizedDefault : null; + if (!configLanguages || configLanguages.length === 0) { // No filter specified - keep all languages + // But still apply default locale if provided and user has no preference + if (validDefault) { + applyDefaultLocale(validDefault); + } return; } @@ -139,7 +150,12 @@ export function updateSupportedLanguages(configLanguages?: string[] | null) { return; } - const fallback = validLanguages.includes('en-GB') ? 'en-GB' : validLanguages[0]; + // Determine fallback: prefer validDefault if in the list, then en-GB, then first valid language + const fallback = validDefault && validLanguages.includes(validDefault) + ? validDefault + : validLanguages.includes('en-GB') + ? 'en-GB' + : validLanguages[0]; i18n.options.supportedLngs = validLanguages; i18n.options.fallbackLng = fallback; @@ -148,6 +164,20 @@ export function updateSupportedLanguages(configLanguages?: string[] | null) { const currentLang = normalizeLanguageCode(i18n.language || ''); if (currentLang && !validLanguages.includes(currentLang)) { i18n.changeLanguage(fallback); + } else if (validDefault && !localStorage.getItem('i18nextLng')) { + // User has no saved preference - apply server default + i18n.changeLanguage(validDefault); + } +} + +/** + * Apply server default locale when user has no saved language preference + * This respects the priority: localStorage > defaultLocale > browser detection > fallback + */ +function applyDefaultLocale(defaultLocale: string) { + // Only apply if user has no saved preference + if (!localStorage.getItem('i18nextLng')) { + i18n.changeLanguage(defaultLocale); } } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx index e410e398ac..e366ada89c 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx @@ -55,7 +55,7 @@ export default function AdminGeneralSection() { const { setIsDirty, markClean } = useUnsavedChanges(); const languageOptions = useMemo( () => Object.entries(supportedLanguages) - .map(([code, label]) => ({ value: code.replace(/-/g, '_'), label })) + .map(([code, label]) => ({ value: code.replace(/-/g, '_'), label: `${label} (${code})` })) .sort((a, b) => a.label.localeCompare(b.label)), [] ); @@ -169,6 +169,16 @@ export default function AdminGeneralSection() { [settings.ui?.languages] ); + // Filter default locale options based on available languages setting + const defaultLocaleOptions = useMemo(() => { + // If no languages are selected (empty), show all languages + if (!selectedLanguages || selectedLanguages.length === 0) { + return languageOptions; + } + // Otherwise, only show languages that are in the selected list + return languageOptions.filter(option => selectedLanguages.includes(option.value)); + }, [selectedLanguages, languageOptions]); + useEffect(() => { // Only fetch real settings if login is enabled if (loginEnabled) { @@ -408,10 +418,11 @@ export default function AdminGeneralSection() { description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')} value={settings.system?.defaultLocale || ''} onChange={(value) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: value || '' } })} - data={languageOptions} + data={defaultLocaleOptions} searchable clearable placeholder="en_GB" + comboboxProps={{ zIndex: 1400 }} disabled={!loginEnabled} /> diff --git a/frontend/src/proprietary/routes/Login.test.tsx b/frontend/src/proprietary/routes/Login.test.tsx index 996176c01c..218eb7efb4 100644 --- a/frontend/src/proprietary/routes/Login.test.tsx +++ b/frontend/src/proprietary/routes/Login.test.tsx @@ -17,6 +17,22 @@ vi.mock('react-i18next', () => ({ return key; }, }), + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, +})); + +// Mock i18n module to avoid initialization +vi.mock('@app/i18n', () => ({ + updateSupportedLanguages: vi.fn(), + supportedLanguages: { 'en-GB': 'English' }, + rtlLanguages: [], + default: { + language: 'en-GB', + changeLanguage: vi.fn(), + options: {}, + }, })); // Mock useAuth hook diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index cf6004e505..4bca34d435 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -11,6 +11,7 @@ import { useBackendProbe } from '@app/hooks/useBackendProbe'; import apiClient from '@app/services/apiClient'; import { BASE_PATH } from '@app/constants/app'; import { type OAuthProvider } from '@app/auth/oauthTypes'; +import { updateSupportedLanguages } from '@app/i18n'; // Import login components import LoginHeader from '@app/routes/login/LoginHeader'; @@ -103,6 +104,11 @@ export default function Login() { setIsFirstTimeSetup(data.firstTimeSetup ?? false); setShowDefaultCredentials(data.showDefaultCredentials ?? false); + // Apply language configuration from server + if (data.languages || data.defaultLocale) { + updateSupportedLanguages(data.languages, data.defaultLocale); + } + // Extract provider IDs from the providerList map // The keys are like "/oauth2/authorization/google" - extract the last part const providerIds = Object.keys(data.providerList || {})