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)
This commit is contained in:
Anthony Stirling 2025-12-10 11:12:18 +00:00 committed by GitHub
parent de438d00e1
commit f17ad56def
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 187 additions and 60 deletions

View File

@ -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();

View File

@ -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

View File

@ -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<String> languages;
private String defaultLocale;
}
@Data

View File

@ -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}
>
<ScarfTrackingInitializer />
<AppConfigLoader />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />

View File

@ -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]);

View File

@ -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<LanguageSelectorProps> = ({
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(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<LanguageSelectorProps> = ({
<Menu
opened={opened}
onChange={setOpened}
width={600}
width={dropdownWidth}
position={position}
offset={offset}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
zIndex={Z_INDEX_CONFIG_MODAL}
withinPortal
transitionProps={{
transition: 'scale-y',
duration: 120,
@ -278,21 +301,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
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,
}}
>
<div className={styles.languageGrid}>
{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 (
<div
className={styles.languageGrid}
style={{ gridTemplateColumns: `repeat(${gridColumns}, 1fr)` }}
>
{languageOptions.map((option, index) => (
<LanguageItem
key={option.value}
option={option}
@ -303,10 +318,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
rippleEffect={rippleEffect}
pendingLanguage={pendingLanguage}
compact={compact}
disabled={isDisabled}
disabled={false}
/>
);
})}
))}
</div>
</Menu.Dropdown>
</Menu>

View File

@ -20,6 +20,7 @@ export interface AppConfig {
serverPort?: number;
appNameNavbar?: string;
languages?: string[];
defaultLocale?: string;
logoStyle?: 'modern' | 'classic';
enableLogin?: boolean;
enableEmailInvites?: boolean;

View File

@ -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);
}
}

View File

@ -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<string>('');
@ -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() {
</Group>
}
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}
/>
</div>
<div>
<TextInput
<Select
label={
<Group gap="xs">
<span>{t('admin.settings.general.defaultLocale.label', 'Default Locale')}</span>
@ -400,9 +414,13 @@ export default function AdminGeneralSection() {
</Group>
}
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}
/>
</div>

View File

@ -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

View File

@ -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 || {})