From 31b3219169eb48af0b1b9e70501397128161b42c Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:10:38 +0000 Subject: [PATCH] Detect backend down (#5010) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/en-GB/translation.json | 5 + .../core/contexts/AppConfigContext.test.tsx | 2 + .../src/core/contexts/AppConfigContext.tsx | 24 +++-- frontend/src/core/hooks/useBackendProbe.ts | 86 +++++++++++++++++ .../src/core/services/httpErrorHandler.ts | 16 +++- frontend/src/proprietary/routes/Landing.tsx | 86 +++++++++++++++-- .../src/proprietary/routes/Login.test.tsx | 24 ++++- frontend/src/proprietary/routes/Login.tsx | 95 ++++++++++++++++++- 8 files changed, 315 insertions(+), 23 deletions(-) create mode 100644 frontend/src/core/hooks/useBackendProbe.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 1933691dd..8068eac9c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -40,6 +40,11 @@ "discardChanges": "Discard & Leave", "applyAndContinue": "Save & Leave", "exportAndContinue": "Export & Continue", + "backendStartup": { + "notFoundTitle": "Backend not found", + "retry": "Retry", + "unreachable": "The application cannot currently connect to the backend. Verify the backend status and network connectivity, then try again." + }, "zipWarning": { "title": "Large ZIP File", "message": "This ZIP contains {{count}} files. Extract anyway?", diff --git a/frontend/src/core/contexts/AppConfigContext.test.tsx b/frontend/src/core/contexts/AppConfigContext.test.tsx index 63a953d8a..8ec3a79ad 100644 --- a/frontend/src/core/contexts/AppConfigContext.test.tsx +++ b/frontend/src/core/contexts/AppConfigContext.test.tsx @@ -51,6 +51,7 @@ describe('AppConfigContext', () => { expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', { suppressErrorToast: true, + skipAuthRedirect: true, }); }); @@ -282,6 +283,7 @@ describe('AppConfigContext', () => { await waitFor(() => { expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', { suppressErrorToast: true, + skipAuthRedirect: true, }); }); }); diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 73bc99847..5ae0d30a6 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -87,7 +87,8 @@ export const AppConfigProvider: React.FC = ({ const isBlockingMode = bootstrapMode === 'blocking'; const [config, setConfig] = useState(initialConfig); const [error, setError] = useState(null); - const [fetchCount, setFetchCount] = useState(0); + // 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); @@ -96,11 +97,14 @@ export const AppConfigProvider: React.FC = ({ const fetchConfig = useCallback(async (force = false) => { // Prevent duplicate fetches unless forced - if (!force && fetchCount > 0) { + 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); @@ -112,7 +116,6 @@ export const AppConfigProvider: React.FC = ({ const testConfig = getSimulatedAppConfig(); if (testConfig) { setConfig(testConfig); - setFetchCount((prev) => prev + 1); setHasResolvedConfig(true); setLoading(false); return; @@ -128,12 +131,17 @@ export const AppConfigProvider: React.FC = ({ // apiClient automatically adds JWT header if available via interceptors // Always suppress error toast - we handle 401 errors locally - const response = await apiClient.get('/api/v1/config/app-config', { suppressErrorToast: true }); + const response = await apiClient.get( + '/api/v1/config/app-config', + { + suppressErrorToast: true, + skipAuthRedirect: true, + } as any, + ); const data = response.data; console.debug('[AppConfig] Config fetched successfully:', data); setConfig(data); - setFetchCount(prev => prev + 1); setHasResolvedConfig(true); setLoading(false); return; // Success - exit function @@ -170,7 +178,7 @@ export const AppConfigProvider: React.FC = ({ } setLoading(false); - }, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]); + }, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]); useEffect(() => { // Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*) @@ -209,11 +217,13 @@ export const AppConfigProvider: React.FC = ({ return () => window.removeEventListener('jwt-available', handleJwtAvailable); }, [fetchConfig]); + const refetch = useCallback(() => fetchConfig(true), [fetchConfig]); + const value: AppConfigContextValue = { config, loading, error, - refetch: () => fetchConfig(true), + refetch, }; return ( diff --git a/frontend/src/core/hooks/useBackendProbe.ts b/frontend/src/core/hooks/useBackendProbe.ts new file mode 100644 index 000000000..979bc8a07 --- /dev/null +++ b/frontend/src/core/hooks/useBackendProbe.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from 'react'; +import { BASE_PATH } from '@app/constants/app'; + +type BackendStatus = 'up' | 'starting' | 'down'; + +interface BackendProbeState { + status: BackendStatus; + loginDisabled: boolean; + loading: boolean; +} + +/** + * Lightweight backend probe that avoids global axios interceptors. + * Used on auth screens to decide whether to show login, anonymous mode, or a backend-starting message. + */ +export function useBackendProbe() { + const [state, setState] = useState({ + status: 'starting', + loginDisabled: false, + loading: true, + }); + + const probe = useCallback(async () => { + const statusUrl = `${BASE_PATH || ''}/api/v1/info/status`; + const loginUrl = `${BASE_PATH || ''}/api/v1/proprietary/ui-data/login`; + + const next: BackendProbeState = { + status: 'starting', + loginDisabled: false, + loading: false, + }; + + try { + const res = await fetch(statusUrl, { method: 'GET', cache: 'no-store' }); + if (res.ok) { + const data = await res.json().catch(() => null); + if (data && data.status === 'UP') { + next.status = 'up'; + setState(next); + return next; + } + next.status = 'starting'; + } else if (res.status === 404 || res.status === 503) { + next.status = 'starting'; + } else { + next.status = 'down'; + } + } catch { + next.status = 'down'; + } + + // Fallback: proprietary login endpoint to detect disabled login and backend availability + try { + const res = await fetch(loginUrl, { method: 'GET', cache: 'no-store' }); + if (res.ok) { + next.status = 'up'; + const data = await res.json().catch(() => null); + if (data && data.enableLogin === false) { + next.loginDisabled = true; + } + } else if (res.status === 404) { + // Endpoint missing usually means login disabled + next.status = 'up'; + next.loginDisabled = true; + } else if (res.status === 503) { + next.status = 'starting'; + } else { + next.status = 'down'; + } + } catch { + // keep previous inferred state (down/starting) + } + + setState(next); + return next; + }, []); + + useEffect(() => { + void probe(); + }, [probe]); + + return { + ...state, + probe, + }; +} diff --git a/frontend/src/core/services/httpErrorHandler.ts b/frontend/src/core/services/httpErrorHandler.ts index 53e04516a..f5b24cc14 100644 --- a/frontend/src/core/services/httpErrorHandler.ts +++ b/frontend/src/core/services/httpErrorHandler.ts @@ -88,6 +88,7 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate * Returns true if the error should be suppressed (deduplicated), false otherwise */ export async function handleHttpError(error: any): Promise { + const skipAuthRedirect = error?.config?.skipAuthRedirect === true; // Check if this error should skip the global toast (component will handle it) if (error?.config?.suppressErrorToast === true) { return false; // Don't show global toast, but continue rejection @@ -105,12 +106,19 @@ export async function handleHttpError(error: any): Promise { pathname.includes('/invite/'); // If not on auth page, redirect to login with expired session message - if (!isAuthPage) { + if (!isAuthPage && !skipAuthRedirect) { console.debug('[httpErrorHandler] 401 detected, redirecting to login'); // Store the current location so we can redirect back after login const currentLocation = window.location.pathname + window.location.search; - // Redirect to login with state - window.location.href = `/login?expired=true&from=${encodeURIComponent(currentLocation)}`; + // Redirect to login with state (only show expired when a JWT existed) + let hadStoredJwt = false; + try { + hadStoredJwt = Boolean(localStorage.getItem('stirling_jwt')); + } catch { + // ignore storage access failures + } + const expiredPrefix = hadStoredJwt ? 'expired=true&' : ''; + window.location.href = `/login?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`; return true; // Suppress toast since we're redirecting } @@ -173,4 +181,4 @@ export async function handleHttpError(error: any): Promise { } return false; // Error was handled with toast, continue normal rejection -} \ No newline at end of file +} diff --git a/frontend/src/proprietary/routes/Landing.tsx b/frontend/src/proprietary/routes/Landing.tsx index 165013e6f..86156dd50 100644 --- a/frontend/src/proprietary/routes/Landing.tsx +++ b/frontend/src/proprietary/routes/Landing.tsx @@ -1,11 +1,15 @@ import { useState, useEffect } from 'react' -import { Navigate, useLocation } from 'react-router-dom' +import { Navigate, useLocation, useNavigate } from 'react-router-dom' import { useAuth } from '@app/auth/UseSession' import { useAppConfig } from '@app/contexts/AppConfigContext' import HomePage from '@app/pages/HomePage' // Login component is used via routing, not directly imported import FirstLoginModal from '@app/components/shared/FirstLoginModal' import { accountService } from '@app/services/accountService' +import { useBackendProbe } from '@app/hooks/useBackendProbe' +import AuthLayout from '@app/routes/authShared/AuthLayout' +import LoginHeader from '@app/routes/login/LoginHeader' +import { useTranslation } from 'react-i18next' /** * Landing component - Smart router based on authentication status @@ -16,13 +20,36 @@ import { accountService } from '@app/services/accountService' */ export default function Landing() { const { session, loading: authLoading, refreshSession } = useAuth(); - const { config, loading: configLoading } = useAppConfig(); + const { config, loading: configLoading, refetch } = useAppConfig(); + const backendProbe = useBackendProbe(); const location = useLocation(); + const navigate = useNavigate(); + const { t } = useTranslation(); const [isFirstLogin, setIsFirstLogin] = useState(false); const [checkingFirstLogin, setCheckingFirstLogin] = useState(false); const [username, setUsername] = useState(''); - const loading = authLoading || configLoading; + const loading = authLoading || configLoading || backendProbe.loading; + + // Periodically probe while backend isn't up so the screen can auto-advance when it comes online + useEffect(() => { + if (backendProbe.status === 'up' || backendProbe.loginDisabled) { + return; + } + const tick = async () => { + const result = await backendProbe.probe(); + if (result.status === 'up') { + await refetch(); + if (result.loginDisabled) { + navigate('/', { replace: true }); + } + } + }; + const intervalId = window.setInterval(() => { + void tick(); + }, 5000); + return () => window.clearInterval(intervalId); + }, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, navigate, refetch]); // Check if user needs to change password on first login useEffect(() => { @@ -46,6 +73,12 @@ export default function Landing() { checkFirstLogin() }, [session, config]) + useEffect(() => { + if (backendProbe.status === 'up') { + void refetch(); + } + }, [backendProbe.status, refetch]); + const handlePasswordChanged = async () => { // After password change, backend logs out the user // Refresh session to detect logout and redirect to login @@ -58,7 +91,7 @@ export default function Landing() { pathname: location.pathname, loading, hasSession: !!session, - loginEnabled: config?.enableLogin, + loginEnabled: config?.enableLogin === true && !backendProbe.loginDisabled, }); // Show loading while checking auth and config @@ -76,11 +109,50 @@ export default function Landing() { } // If login is disabled, show app directly (anonymous mode) - if (config?.enableLogin === false) { + if (config?.enableLogin === false || backendProbe.loginDisabled) { console.debug('[Landing] Login disabled - showing app in anonymous mode'); return ; } + // If backend is not up yet and user is not authenticated, show a branded status screen + if (!session && backendProbe.status !== 'up') { + const backendTitle = t('backendStartup.notFoundTitle', 'Backend not found'); + const handleRetry = async () => { + const result = await backendProbe.probe(); + if (result.status === 'up') { + await refetch(); + navigate('/', { replace: true }); + } + }; + return ( + + +
+

+ {t('backendStartup.unreachable')} +

+ +
+
+ ); + } + // If we have a session, show the main app if (session) { return ( @@ -97,5 +169,7 @@ export default function Landing() { // No session - redirect to login page // This ensures the URL always shows /login when not authenticated - return ; + return (config?.enableLogin === true && !backendProbe.loginDisabled) + ? + : ; } diff --git a/frontend/src/proprietary/routes/Login.test.tsx b/frontend/src/proprietary/routes/Login.test.tsx index 16a6cb5e6..5c6f078bf 100644 --- a/frontend/src/proprietary/routes/Login.test.tsx +++ b/frontend/src/proprietary/routes/Login.test.tsx @@ -40,6 +40,20 @@ vi.mock('@app/hooks/useDocumentMeta', () => ({ global.fetch = vi.fn(); const mockNavigate = vi.fn(); +const mockBackendProbeState = { + status: 'up' as const, + loginDisabled: false, + loading: false, +}; +const mockProbe = vi.fn().mockResolvedValue(mockBackendProbeState); + +vi.mock('@app/hooks/useBackendProbe', () => ({ + useBackendProbe: () => ({ + ...mockBackendProbeState, + probe: mockProbe, + }), +})); + vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); return { @@ -58,6 +72,10 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => ( describe('Login', () => { beforeEach(() => { vi.clearAllMocks(); + mockBackendProbeState.status = 'up'; + mockBackendProbeState.loginDisabled = false; + mockBackendProbeState.loading = false; + mockProbe.mockResolvedValue(mockBackendProbeState); // Default auth state - not logged in vi.mocked(useAuth).mockReturnValue({ @@ -330,13 +348,15 @@ describe('Login', () => { ); - waitFor(() => { + return waitFor(() => { const emailInput = document.getElementById('email') as HTMLInputElement; expect(emailInput.value).toBe(email); }); }); it('should redirect to home when login disabled', async () => { + mockBackendProbeState.loginDisabled = true; + mockProbe.mockResolvedValueOnce({ status: 'up', loginDisabled: true, loading: false }); vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => ({ @@ -354,7 +374,7 @@ describe('Login', () => { ); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/'); + expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }); }); }); diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index c5b361a8f..1637afc66 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; import { Text, Stack, Alert } from '@mantine/core'; import { springAuth } from '@app/auth/springAuthClient'; import { useAuth } from '@app/auth/UseSession'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; import { useTranslation } from 'react-i18next'; import { useDocumentMeta } from '@app/hooks/useDocumentMeta'; import AuthLayout from '@app/routes/authShared/AuthLayout'; +import { useBackendProbe } from '@app/hooks/useBackendProbe'; // Import login components import LoginHeader from '@app/routes/login/LoginHeader'; @@ -20,18 +22,41 @@ export default function Login() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { session, loading } = useAuth(); + const { refetch } = useAppConfig(); const { t } = useTranslation(); const [isSigningIn, setIsSigningIn] = useState(false); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [showEmailForm, setShowEmailForm] = useState(false); - const [email, setEmail] = useState(''); + const [email, setEmail] = useState(() => searchParams.get('email') ?? ''); const [password, setPassword] = useState(''); const [enabledProviders, setEnabledProviders] = useState([]); const [hasSSOProviders, setHasSSOProviders] = useState(false); const [_enableLogin, setEnableLogin] = useState(null); + const backendProbe = useBackendProbe(); const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false); const [showDefaultCredentials, setShowDefaultCredentials] = useState(false); + const loginDisabled = backendProbe.loginDisabled === true || _enableLogin === false; + + // Periodically probe while backend isn't up so the screen can auto-advance when it comes online + useEffect(() => { + if (backendProbe.status === 'up' || backendProbe.loginDisabled) { + return; + } + const tick = async () => { + const result = await backendProbe.probe(); + if (result.status === 'up') { + await refetch(); + if (loginDisabled) { + navigate('/', { replace: true }); + } + } + }; + const intervalId = window.setInterval(() => { + void tick(); + }, 5000); + return () => window.clearInterval(intervalId); + }, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, refetch, navigate, loginDisabled]); // Redirect immediately if user has valid session (JWT already validated by AuthProvider) useEffect(() => { @@ -41,6 +66,21 @@ export default function Login() { } }, [session, loading, navigate]); + // If backend reports login is disabled, redirect to home (anonymous mode) + useEffect(() => { + if (backendProbe.loginDisabled) { + // Slight delay to allow state updates before redirecting + const id = setTimeout(() => navigate('/', { replace: true }), 0); + return () => clearTimeout(id); + } + }, [backendProbe.loginDisabled, navigate]); + + useEffect(() => { + if (backendProbe.status === 'up') { + void refetch(); + } + }, [backendProbe.status, refetch]); + // Fetch enabled SSO providers and login config from backend useEffect(() => { const fetchProviders = async () => { @@ -73,8 +113,11 @@ export default function Login() { console.error('[Login] Failed to fetch enabled providers:', err); } }; - fetchProviders(); - }, [navigate]); + + if (backendProbe.status === 'up' || backendProbe.loginDisabled) { + fetchProviders(); + } + }, [navigate, backendProbe.status, backendProbe.loginDisabled]); // Update hasSSOProviders and showEmailForm when enabledProviders changes useEffect(() => { @@ -134,11 +177,55 @@ export default function Login() { ogUrl: `${window.location.origin}${window.location.pathname}` }); + // If login is disabled, short-circuit to home (avoids rendering the form after retry) + if (loginDisabled) { + return ; + } + // Show logged in state if authenticated if (session && !loading) { return ; } + // If backend isn't ready yet, show a lightweight status screen instead of the form + if (backendProbe.status !== 'up' && !loginDisabled) { + const backendTitle = t('backendStartup.notFoundTitle', 'Backend not found'); + const handleRetry = async () => { + const result = await backendProbe.probe(); + if (result.status === 'up') { + await refetch(); + navigate('/', { replace: true }); + } + }; + return ( + + +
+

+ {t('backendStartup.unreachable')} +

+ +
+
+ ); + } + const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => { try { setIsSigningIn(true);