mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 18:44:05 +02:00
## 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)
250 lines
7.9 KiB
TypeScript
250 lines
7.9 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
|
import apiClient from '@app/services/apiClient';
|
|
import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
|
|
|
|
/**
|
|
* Sleep utility for delays
|
|
*/
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export interface AppConfigRetryOptions {
|
|
maxRetries?: number;
|
|
initialDelay?: number;
|
|
}
|
|
|
|
export interface AppConfig {
|
|
baseUrl?: string;
|
|
contextPath?: string;
|
|
serverPort?: number;
|
|
appNameNavbar?: string;
|
|
languages?: string[];
|
|
defaultLocale?: string;
|
|
logoStyle?: 'modern' | 'classic';
|
|
enableLogin?: boolean;
|
|
enableEmailInvites?: boolean;
|
|
isAdmin?: boolean;
|
|
enableAlphaFunctionality?: boolean;
|
|
enableAnalytics?: boolean | null;
|
|
enablePosthog?: boolean | null;
|
|
enableScarf?: boolean | null;
|
|
premiumEnabled?: boolean;
|
|
premiumKey?: string;
|
|
termsAndConditions?: string;
|
|
privacyPolicy?: string;
|
|
cookiePolicy?: string;
|
|
impressum?: string;
|
|
accessibilityStatement?: string;
|
|
runningProOrHigher?: boolean;
|
|
runningEE?: boolean;
|
|
license?: string;
|
|
SSOAutoLogin?: boolean;
|
|
serverCertificateEnabled?: boolean;
|
|
appVersion?: string;
|
|
machineType?: string;
|
|
activeSecurity?: boolean;
|
|
dependenciesReady?: boolean;
|
|
error?: string;
|
|
isNewServer?: boolean;
|
|
isNewUser?: boolean;
|
|
}
|
|
|
|
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
|
|
|
|
interface AppConfigContextValue {
|
|
config: AppConfig | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
refetch: () => Promise<void>;
|
|
}
|
|
|
|
const AppConfigContext = createContext<AppConfigContextValue | undefined>({
|
|
config: null,
|
|
loading: true,
|
|
error: null,
|
|
refetch: async () => {},
|
|
});
|
|
|
|
/**
|
|
* Provider component that fetches and provides app configuration
|
|
* Should be placed at the top level of the app, before any components that need config
|
|
*/
|
|
export interface AppConfigProviderProps {
|
|
children: ReactNode;
|
|
retryOptions?: AppConfigRetryOptions;
|
|
initialConfig?: AppConfig | null;
|
|
bootstrapMode?: AppConfigBootstrapMode;
|
|
autoFetch?: boolean;
|
|
}
|
|
|
|
export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
|
children,
|
|
retryOptions,
|
|
initialConfig = null,
|
|
bootstrapMode = 'blocking',
|
|
autoFetch = true,
|
|
}) => {
|
|
const isBlockingMode = bootstrapMode === 'blocking';
|
|
const [config, setConfig] = useState<AppConfig | null>(initialConfig);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Track how many times we've attempted to fetch. useRef avoids re-renders that can trigger loops.
|
|
const fetchCountRef = React.useRef(0);
|
|
const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode);
|
|
const [loading, setLoading] = useState(!hasResolvedConfig);
|
|
|
|
const maxRetries = retryOptions?.maxRetries ?? 0;
|
|
const initialDelay = retryOptions?.initialDelay ?? 1000;
|
|
|
|
const fetchConfig = useCallback(async (force = false) => {
|
|
// Prevent duplicate fetches unless forced
|
|
if (!force && fetchCountRef.current > 0) {
|
|
console.debug('[AppConfig] Already fetched, skipping');
|
|
return;
|
|
}
|
|
|
|
// Mark that we've attempted a fetch to prevent repeated auto-fetch loops
|
|
fetchCountRef.current += 1;
|
|
|
|
const shouldBlockUI = !hasResolvedConfig || isBlockingMode;
|
|
if (shouldBlockUI) {
|
|
setLoading(true);
|
|
}
|
|
setError(null);
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const testConfig = getSimulatedAppConfig();
|
|
if (testConfig) {
|
|
setConfig(testConfig);
|
|
setHasResolvedConfig(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (attempt > 0) {
|
|
const delay = initialDelay * Math.pow(2, attempt - 1);
|
|
console.log(`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`);
|
|
await sleep(delay);
|
|
} else {
|
|
console.log('[AppConfig] Fetching app config...');
|
|
}
|
|
|
|
// apiClient automatically adds JWT header if available via interceptors
|
|
// Always suppress error toast - we handle 401 errors locally
|
|
const response = await apiClient.get<AppConfig>(
|
|
'/api/v1/config/app-config',
|
|
{
|
|
suppressErrorToast: true,
|
|
skipAuthRedirect: true,
|
|
} as any,
|
|
);
|
|
const data = response.data;
|
|
|
|
console.debug('[AppConfig] Config fetched successfully:', data);
|
|
setConfig(data);
|
|
setHasResolvedConfig(true);
|
|
setLoading(false);
|
|
return; // Success - exit function
|
|
} catch (err: any) {
|
|
const status = err?.response?.status;
|
|
|
|
// On 401 (not authenticated), use default config with login enabled
|
|
// This allows the app to work even without authentication
|
|
if (status === 401) {
|
|
console.debug('[AppConfig] 401 error - using default config (login enabled)');
|
|
setConfig({ enableLogin: true });
|
|
setHasResolvedConfig(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Check if we should retry (network errors or 5xx errors)
|
|
const shouldRetry = (!status || status >= 500) && attempt < maxRetries;
|
|
|
|
if (shouldRetry) {
|
|
console.warn(`[AppConfig] Attempt ${attempt + 1} failed (status ${status || 'network error'}):`, err.message, '- will retry...');
|
|
continue;
|
|
}
|
|
|
|
// Final attempt failed or non-retryable error (4xx)
|
|
const errorMessage = err?.response?.data?.message || err?.message || 'Unknown error occurred';
|
|
setError(errorMessage);
|
|
console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err);
|
|
// Preserve existing config (initial default or previous fetch). If nothing is set, assume login enabled.
|
|
setConfig((current) => current ?? { enableLogin: true });
|
|
setHasResolvedConfig(true);
|
|
break;
|
|
}
|
|
}
|
|
|
|
setLoading(false);
|
|
}, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
|
|
|
|
useEffect(() => {
|
|
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
|
|
// Config will be fetched after successful authentication via jwt-available event
|
|
const currentPath = window.location.pathname;
|
|
const isAuthPage = currentPath.includes('/login') ||
|
|
currentPath.includes('/signup') ||
|
|
currentPath.includes('/auth/callback') ||
|
|
currentPath.includes('/invite/');
|
|
|
|
// On auth pages, always skip the config fetch
|
|
// The config will be fetched after authentication via jwt-available event
|
|
if (isAuthPage) {
|
|
console.debug('[AppConfig] On auth page - using default config, skipping fetch');
|
|
setConfig({ enableLogin: true });
|
|
setHasResolvedConfig(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// On non-auth pages, fetch config (will validate JWT if present)
|
|
if (autoFetch) {
|
|
fetchConfig();
|
|
}
|
|
}, [autoFetch, fetchConfig]);
|
|
|
|
// Listen for JWT availability (triggered on login/signup)
|
|
useEffect(() => {
|
|
const handleJwtAvailable = () => {
|
|
console.debug('[AppConfig] JWT available event - refetching config');
|
|
// Force refetch with JWT
|
|
fetchConfig(true);
|
|
};
|
|
|
|
window.addEventListener('jwt-available', handleJwtAvailable);
|
|
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
|
}, [fetchConfig]);
|
|
|
|
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
|
|
|
|
const value: AppConfigContextValue = {
|
|
config,
|
|
loading,
|
|
error,
|
|
refetch,
|
|
};
|
|
|
|
return (
|
|
<AppConfigContext.Provider value={value}>
|
|
{children}
|
|
</AppConfigContext.Provider>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Hook to access application configuration
|
|
* Must be used within AppConfigProvider
|
|
*/
|
|
export function useAppConfig(): AppConfigContextValue {
|
|
const context = useContext(AppConfigContext);
|
|
|
|
if (context === undefined) {
|
|
throw new Error('useAppConfig must be used within AppConfigProvider');
|
|
}
|
|
|
|
return context;
|
|
}
|