diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index cf8ce0a..a397c0f 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -209,54 +209,54 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [appConfig]); useEffect(() => { - if (!ttsProvidersTabDisabled || !isDBReady || !appConfig || sharedProvidersLoading) return; + if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) { + const resetPatch: Partial = {}; - const resetPatch: Partial = {}; + // When the provider tab is hidden, clear any user-set provider config back to + // "inherit the admin default" (empty) rather than baking in a concrete value. + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + if (appConfig.providerRef !== '') resetPatch.providerRef = ''; + if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; + if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; + if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; + // Keep voice selection state intact so player/Audiobook voice pickers still + // work when the TTS providers tab is hidden. This reset is only for provider + // configuration fields. - // When the provider tab is hidden, clear any user-set provider config back to - // "inherit the admin default" (empty) rather than baking in a concrete value. - if (appConfig.apiKey !== '') resetPatch.apiKey = ''; - if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; - if (appConfig.providerRef !== '') resetPatch.providerRef = ''; - if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; - if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; - if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; - // Keep voice selection state intact so player/Audiobook voice pickers still - // work when the TTS providers tab is hidden. This reset is only for provider - // configuration fields. + if (Object.keys(resetPatch).length === 0) return; - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - console.warn('Failed to clear hidden TTS provider settings:', error); - }); - queueSyncedPreferencePatch(resetPatch); + updateAppConfig(resetPatch).catch((error) => { + console.warn('Failed to clear hidden TTS provider settings:', error); + }); + queueSyncedPreferencePatch(resetPatch); + } }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (!restrictUserApiKeys || !isDBReady || !appConfig || sharedProvidersLoading) return; + if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) { + const resetPatch: Partial = {}; - const resetPatch: Partial = {}; + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + // Built-in providers aren't selectable in restricted mode. Clear any stale + // built-in selection (including the old 'custom-openai' default that used to + // be baked into every config) back to "inherit the admin default" so the + // user follows whatever shared provider the admin has configured. + if (isBuiltInTtsProviderId(appConfig.providerRef)) { + resetPatch.providerRef = ''; + resetPatch.providerType = 'unknown'; + resetPatch.ttsModel = ''; + resetPatch.ttsInstructions = ''; + } - if (appConfig.apiKey !== '') resetPatch.apiKey = ''; - if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; - // Built-in providers aren't selectable in restricted mode. Clear any stale - // built-in selection (including the old 'custom-openai' default that used to - // be baked into every config) back to "inherit the admin default" so the - // user follows whatever shared provider the admin has configured. - if (isBuiltInTtsProviderId(appConfig.providerRef)) { - resetPatch.providerRef = ''; - resetPatch.providerType = 'unknown'; - resetPatch.ttsModel = ''; - resetPatch.ttsInstructions = ''; + if (Object.keys(resetPatch).length === 0) return; + + updateAppConfig(resetPatch).catch((error) => { + console.warn('Failed to enforce restricted user API key mode:', error); + }); + queueSyncedPreferencePatch(resetPatch); } - - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - console.warn('Failed to enforce restricted user API key mode:', error); - }); - queueSyncedPreferencePatch(resetPatch); }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts index 4555330..51d0a4e 100644 --- a/src/lib/server/user/preferences-normalize.ts +++ b/src/lib/server/user/preferences-normalize.ts @@ -14,7 +14,7 @@ export interface PreferenceNormalizationContext { } export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null && !Array.isArray(value); } export function sanitizeSavedVoices(value: unknown): Record { diff --git a/tests/unit/user-preferences-normalize.vitest.spec.ts b/tests/unit/user-preferences-normalize.vitest.spec.ts index 16975ea..13f5478 100644 --- a/tests/unit/user-preferences-normalize.vitest.spec.ts +++ b/tests/unit/user-preferences-normalize.vitest.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import { sanitizePreferencesPatch, + sanitizeSavedVoices, type PreferenceNormalizationContext, } from '../../src/lib/server/user/preferences-normalize'; @@ -96,4 +97,12 @@ describe('sanitizePreferencesPatch — inherit-by-default provider model', () => expect('providerType' in patch).toBe(false); expect('ttsModel' in patch).toBe(false); }); + + test('rejects arrays where preference records are expected', () => { + expect(sanitizePreferencesPatch(['voice'], makeContext(), { fillMissingProvider: false })).toEqual({ + patch: {}, + migrated: false, + }); + expect(sanitizeSavedVoices(['af_sarah'])).toEqual({}); + }); });