openreader/src/contexts/ConfigContext.tsx
Richard R 5f28be5d58 refactor(env): remove legacy no-auth mode and enforce required auth env vars
Eliminate all code paths, configuration, and documentation related to running
without authentication. Require AUTH_SECRET and BASE_URL at startup, updating
middleware, server logic, and runtime checks to assume auth is always enabled.
Simplify onboarding, settings, and test helpers to reflect mandatory auth.
Update environment examples, Docker and deployment docs, and CI/test configs.
Remove no-auth-specific UI flows, test cases, and feature toggles.
2026-05-31 12:09:37 -06:00

513 lines
18 KiB
TypeScript

'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
import { applyConfigUpdate } from '@/lib/client/config/updates';
import { useSharedProviders } from '@/hooks/useSharedProviders';
import toast from 'react-hot-toast';
export type { ViewType } from '@/types/config';
/** Configuration values for the application */
/** Interface defining the configuration context shape and functionality */
interface ConfigContextType {
apiKey: string;
baseUrl: string;
viewType: ViewType;
voiceSpeed: number;
audioPlayerSpeed: number;
voice: string;
skipBlank: boolean;
epubTheme: boolean;
segmentPreloadDepthPages: number;
segmentPreloadSentenceLookahead: number;
ttsSegmentMaxBlockLength: number;
headerMargin: number;
footerMargin: number;
leftMargin: number;
rightMargin: number;
providerRef: string;
providerType: AppConfigValues['providerType'];
ttsModel: string;
ttsInstructions: string;
savedVoices: SavedVoices;
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
isLoading: boolean;
isDBReady: boolean;
pdfHighlightEnabled: boolean;
pdfWordHighlightEnabled: boolean;
epubHighlightEnabled: boolean;
epubWordHighlightEnabled: boolean;
htmlHighlightEnabled: boolean;
htmlWordHighlightEnabled: boolean;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
/**
* Provider component for application configuration
* Manages global configuration state and persistence
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to be wrapped by the provider
*/
export function ConfigProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false);
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
const showAllProviderModels = useFeatureFlag('showAllProviderModels');
const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
const { providers: sharedProviders } = useSharedProviders();
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
const sessionKey = sessionData?.user?.id ?? 'no-session';
const providerResetDefaults = useMemo(() => {
return resolveProviderDefaults({
providerRef: APP_CONFIG_DEFAULTS.providerRef,
providerType: APP_CONFIG_DEFAULTS.providerType,
sharedProviders,
});
}, [sharedProviders]);
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
if (sessionKey === 'no-session') return;
const syncedPatch: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) {
if (!(key in patch)) continue;
const value = patch[key];
if (value === undefined) continue;
(syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value;
}
if (Object.keys(syncedPatch).length === 0) return;
scheduleUserPreferencesSync(syncedPatch, sessionKey);
}, [sessionKey]);
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
useEffect(() => {
return () => {
cancelPendingPreferenceSync();
};
}, [sessionKey]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
const status = detail?.status;
if (status === 'opened') {
toast.dismiss('dexie-blocked');
return;
}
if (status === 'blocked' || status === 'stalled') {
const message =
'Database upgrade is waiting for another OpenReader tab. Close other OpenReader tabs and reload.';
toast.error(message, { id: 'dexie-blocked', duration: Infinity });
}
};
window.addEventListener('openreader:dexie', handler as EventListener);
return () => {
window.removeEventListener('openreader:dexie', handler as EventListener);
};
}, []);
useEffect(() => {
const initializeDB = async () => {
try {
setIsLoading(true);
await initDB();
setIsDBReady(true);
} catch (error) {
console.error('Error initializing Dexie:', error);
} finally {
setIsLoading(false);
}
};
initializeDB();
}, []);
useEffect(() => {
if (!isDBReady) return;
if (didRunStartupMigrations.current) return;
didRunStartupMigrations.current = true;
const run = async () => {
try {
await migrateLegacyDexieDocumentIdsToSha();
} catch (error) {
console.warn('Startup migrations failed:', error);
}
};
void run();
}, [isDBReady]);
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
if (!isDBReady) return;
try {
const remote = await getUserPreferences({ signal });
if (!remote?.hasStoredPreferences) return;
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to load synced preferences:', error);
}
}, [isDBReady]);
useEffect(() => {
if (!isDBReady || isSessionPending) return;
const controller = new AbortController();
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Synced preferences refresh failed:', error);
});
return () => controller.abort();
}, [isDBReady, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
useEffect(() => {
if (!isDBReady) return;
let activeController: AbortController | null = null;
const onFocus = () => {
if (activeController) activeController.abort();
activeController = new AbortController();
refreshSyncedPreferencesFromServer(activeController.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Focus synced preferences refresh failed:', error);
});
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
if (activeController) activeController.abort();
};
}, [isDBReady, refreshSyncedPreferencesFromServer]);
const appConfig = useLiveQuery(
async () => {
if (!isDBReady) return null;
const row = await db['app-config'].get('singleton');
return row ?? null;
},
[isDBReady],
null,
);
const config: AppConfigValues | null = useMemo(() => {
if (!appConfig) return null;
const { id, ...rest } = appConfig;
void id;
return { ...APP_CONFIG_DEFAULTS, ...rest };
}, [appConfig]);
useEffect(() => {
if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return;
const resetPatch: Partial<AppConfigRow> = {};
if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) {
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
}
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
}
if (appConfig.providerRef !== providerResetDefaults.providerRef) {
resetPatch.providerRef = providerResetDefaults.providerRef;
}
if (appConfig.providerType !== providerResetDefaults.providerType) {
resetPatch.providerType = providerResetDefaults.providerType;
}
if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
resetPatch.ttsModel = providerResetDefaults.defaultModel;
}
if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) {
resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions;
}
// 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;
updateAppConfig(resetPatch).catch((error) => {
console.warn('Failed to clear hidden TTS provider settings:', error);
});
queueSyncedPreferencePatch(resetPatch);
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
useEffect(() => {
if (!restrictUserApiKeys || !isDBReady || !appConfig) return;
const resetPatch: Partial<AppConfigRow> = {};
if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) {
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
}
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
}
if (isBuiltInTtsProviderId(appConfig.providerRef)) {
if (appConfig.providerRef !== providerResetDefaults.providerRef) {
resetPatch.providerRef = providerResetDefaults.providerRef;
}
if (appConfig.providerType !== providerResetDefaults.providerType) {
resetPatch.providerType = providerResetDefaults.providerType;
}
if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
resetPatch.ttsModel = providerResetDefaults.defaultModel;
}
if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) {
resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions;
}
}
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, providerResetDefaults]);
useEffect(() => {
if (showAllProviderModels || !isDBReady || !appConfig) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
sharedProviders,
fallbackProviderRef: providerResetDefaults.providerRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to enforce provider default model restriction:', error);
});
queueSyncedPreferencePatch(patch);
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]);
useEffect(() => {
if (!isDBReady || !appConfig || isSessionPending) return;
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
const controller = new AbortController();
const run = async () => {
try {
const remote = await getUserPreferences({ signal: controller.signal });
if (remote?.hasStoredPreferences) return;
// Seed only user-customized (non-default) values. This prevents fresh/default
// profiles from overwriting existing server values during first-run races.
const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true });
if (Object.keys(patch).length === 0) return;
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now(), signal: controller.signal });
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to seed initial synced preferences from local Dexie:', error);
}
};
run().catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Initial synced preferences seed failed:', error);
});
return () => controller.abort();
}, [isDBReady, appConfig, isSessionPending, sessionKey]);
// Destructure for convenience and to match context shape
const {
apiKey,
baseUrl,
viewType,
voiceSpeed,
audioPlayerSpeed,
voice,
skipBlank,
epubTheme,
headerMargin,
footerMargin,
leftMargin,
rightMargin,
providerRef,
providerType: _persistedProviderType,
ttsModel,
ttsInstructions,
savedVoices,
segmentPreloadDepthPages,
segmentPreloadSentenceLookahead,
ttsSegmentMaxBlockLength,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS;
const providerType = useMemo(
() => resolveEffectiveProviderType({
providerRef,
providerType: _persistedProviderType,
sharedProviders,
}),
[providerRef, _persistedProviderType, sharedProviders],
);
useEffect(() => {
if (!isDBReady || !appConfig) return;
if (appConfig.providerType === providerType) return;
const patch: Partial<AppConfigRow> = { providerType };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to persist resolved providerType:', error);
});
queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch]);
void _persistedProviderType;
useEffect(() => {
if (!isDBReady || !appConfig) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
sharedProviders,
fallbackProviderRef: providerResetDefaults.providerRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
// Heal stale fallback model values that were written while the provider UI
// was disabled and shared provider context was unavailable.
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) return;
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to normalize shared-provider default model:', error);
});
queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]);
/**
* Updates multiple configuration values simultaneously
* Only saves API credentials if they are explicitly set
*/
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
try {
setIsLoading(true);
const updates: Partial<AppConfigRow> = {};
if (newConfig.apiKey !== undefined) {
updates.apiKey = newConfig.apiKey;
}
if (newConfig.baseUrl !== undefined) {
updates.baseUrl = newConfig.baseUrl;
}
if (newConfig.viewType !== undefined) {
updates.viewType = newConfig.viewType;
}
await updateAppConfig(updates);
queueSyncedPreferencePatch(updates);
} catch (error) {
console.error('Error updating config:', error);
throw error;
} finally {
setIsLoading(false);
}
};
/**
* Updates a single configuration value by key
* @param {K} key - The configuration key to update
* @param {AppConfigValues[K]} value - The new value for the configuration
*/
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
try {
setIsLoading(true);
const { storagePatch, syncPatch } = applyConfigUpdate({
providerRef,
providerType,
ttsModel,
savedVoices,
}, key, value);
await updateAppConfig(storagePatch);
if (
key === 'voice' ||
key === 'providerRef' ||
key === 'providerType' ||
key === 'ttsModel' ||
key === 'savedVoices' ||
syncedPreferenceKeys.has(String(key))
) {
queueSyncedPreferencePatch(syncPatch);
}
} catch (error) {
console.error(`Error updating config key ${String(key)}:`, error);
throw error;
} finally {
setIsLoading(false);
}
};
return (
<ConfigContext.Provider value={{
apiKey,
baseUrl,
viewType,
voiceSpeed,
audioPlayerSpeed,
voice,
skipBlank,
epubTheme,
segmentPreloadDepthPages,
segmentPreloadSentenceLookahead,
ttsSegmentMaxBlockLength,
headerMargin,
footerMargin,
leftMargin,
rightMargin,
providerRef,
providerType,
ttsModel,
ttsInstructions,
savedVoices,
updateConfig,
updateConfigKey,
isLoading,
isDBReady,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
}}>
{children}
</ConfigContext.Provider>
);
}
/**
* Custom hook to consume the configuration context
* @returns {ConfigContextType} The configuration context value
* @throws {Error} When used outside of ConfigProvider
*/
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}