From f17ad56def64f1eb0f148191a7782889beda82da Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:12:18 +0000 Subject: [PATCH] Handle restricted language configuration fallback V2 (#5154) ## Summary - restrict supported languages to the validated list from app-config instead of always adding an extra fallback - set the effective fallback locale to the preferred configured language and switch away from disallowed selections automatically ## Testing - ./gradlew build ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6930529bc6c08328a1ce05f7d1316e27) --- .../software/SPDF/config/InitialSetup.java | 1 + .../controller/api/misc/ConfigController.java | 1 + .../api/ProprietaryUIDataController.java | 6 ++ frontend/src/core/components/AppProviders.tsx | 2 + .../components/shared/AppConfigLoader.tsx | 5 +- .../components/shared/LanguageSelector.tsx | 54 +++++++---- .../src/core/contexts/AppConfigContext.tsx | 1 + frontend/src/core/i18n.ts | 91 ++++++++++++++++--- .../configSections/AdminGeneralSection.tsx | 64 ++++++++----- .../src/proprietary/routes/Login.test.tsx | 16 ++++ frontend/src/proprietary/routes/Login.tsx | 6 ++ 11 files changed, 187 insertions(+), 60 deletions(-) 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 ef592cb55..88755f950 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 4f31b83ef..43aaecd9d 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 7e290f0ae..88fddb7ea 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); } @@ -492,6 +496,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 7e47d00e0..5fa8d7cb9 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 45cd7ad1b..7da104a6f 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 87fd0db62..7eb35d1f7 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 5ae0d30a6..2e42f9581 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 e3f3f7bd2..badc72949 100644 --- a/frontend/src/core/i18n.ts +++ b/frontend/src/core/i18n.ts @@ -105,33 +105,94 @@ i18n.on('languageChanged', (lng) => { document.documentElement.lang = lng; }); +export function normalizeLanguageCode(languageCode: string): string { + // Replace underscores with hyphens to align with i18next/translation file naming + const hyphenated = languageCode.replace(/_/g, '-'); + const [base, ...rest] = hyphenated.split('-'); + + if (rest.length === 0) { + return base.toLowerCase(); + } + + const normalizedParts = rest.map(part => (part.length <= 3 ? part.toUpperCase() : part)); + return [base.toLowerCase(), ...normalizedParts].join('-'); +} + +/** + * Convert language codes to underscore format (e.g., en-GB → en_GB) + * Used for backend API communication which expects underscore format + */ +export function toUnderscoreFormat(languageCode: string): string { + return languageCode.replace(/-/g, '_'); +} + +/** + * Convert array of language codes to underscore format + */ +export function toUnderscoreLanguages(languages: string[]): string[] { + return languages.map(toUnderscoreFormat); +} + /** * Updates the supported languages list dynamically based on config * If configLanguages is null/empty, all languages remain available - * Otherwise, only specified languages plus 'en-GB' fallback are enabled + * 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; } - // Ensure fallback language is always included - const languagesToSupport = new Set(['en-GB', ...configLanguages]); + const validLanguages = configLanguages + .map(normalizeLanguageCode) + .filter(lang => lang in supportedLanguages); - // Filter to only valid language codes that exist in our translations - const validLanguages = Array.from(languagesToSupport).filter( - lang => lang in supportedLanguages - ); + // If no valid languages were provided, keep existing configuration + if (validLanguages.length === 0) { + return; + } - if (validLanguages.length > 0) { - i18n.options.supportedLngs = validLanguages; + // 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]; - // If current language is not in the new supported list, switch to fallback - const currentLang = i18n.language; - if (currentLang && !validLanguages.includes(currentLang)) { - i18n.changeLanguage('en-GB'); - } + i18n.options.supportedLngs = validLanguages; + i18n.options.fallbackLng = fallback; + + // If current language is not in the new supported list, switch to fallback + 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 faebf4d48..75f8516e9 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; +import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl } from '@mantine/core'; +import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core'; import { alert } from '@app/components/toast'; import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; import { useRestartServer } from '@app/components/shared/config/useRestartServer'; @@ -11,6 +11,8 @@ import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; import { usePreferences } from '@app/contexts/PreferencesContext'; import { useUnsavedChanges } from '@app/contexts/UnsavedChangesContext'; +import { supportedLanguages, toUnderscoreFormat, toUnderscoreLanguages } from '@app/i18n'; +import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex'; interface GeneralSettingsData { ui: { @@ -49,6 +51,12 @@ export default function AdminGeneralSection() { const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { preferences, updatePreference } = usePreferences(); const { setIsDirty, markClean } = useUnsavedChanges(); + const languageOptions = useMemo( + () => Object.entries(supportedLanguages) + .map(([code, label]) => ({ value: toUnderscoreFormat(code), label: `${label} (${code})` })) + .sort((a, b) => a.label.localeCompare(b.label)), + [] + ); // Track original settings for dirty detection const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState(''); @@ -73,9 +81,11 @@ export default function AdminGeneralSection() { apiClient.get('/api/v1/admin/settings/section/premium') ]); - const ui = uiResponse.data || {}; - const system = systemResponse.data || {}; - const premium = premiumResponse.data || {}; + const ui = { ...(uiResponse.data || {}) }; + const system = { ...(systemResponse.data || {}) }; + const premium = { ...(premiumResponse.data || {}) }; + + ui.languages = Array.isArray(ui.languages) ? toUnderscoreLanguages(ui.languages) : []; const result: any = { ui, @@ -152,6 +162,21 @@ export default function AdminGeneralSection() { } }); + const selectedLanguages = useMemo( + () => toUnderscoreLanguages(settings.ui?.languages || []), + [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) { @@ -369,30 +394,19 @@ export default function AdminGeneralSection() { } description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')} - value={settings.ui?.languages || []} + value={selectedLanguages} onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })} - data={[ - { value: 'de_DE', label: 'Deutsch' }, - { value: 'es_ES', label: 'Español' }, - { value: 'fr_FR', label: 'Français' }, - { value: 'it_IT', label: 'Italiano' }, - { value: 'pl_PL', label: 'Polski' }, - { value: 'pt_BR', label: 'Português (Brasil)' }, - { value: 'ru_RU', label: 'Русский' }, - { value: 'zh_CN', label: '简体中文' }, - { value: 'ja_JP', label: '日本語' }, - { value: 'ko_KR', label: '한국어' }, - ]} + data={languageOptions} searchable clearable placeholder={t('admin.settings.general.languages.placeholder', 'Select languages')} - comboboxProps={{ zIndex: 1400 }} + comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }} disabled={!loginEnabled} />
- {t('admin.settings.general.defaultLocale.label', 'Default Locale')} @@ -400,9 +414,13 @@ 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={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })} - placeholder="en_US" + value={settings.system?.defaultLocale || ''} + onChange={(value) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: value || '' } })} + data={defaultLocaleOptions} + searchable + clearable + placeholder="en_GB" + comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }} disabled={!loginEnabled} />
diff --git a/frontend/src/proprietary/routes/Login.test.tsx b/frontend/src/proprietary/routes/Login.test.tsx index 996176c01..218eb7efb 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 cf6004e50..4bca34d43 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 || {})