diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index f9230a9534..62e9ce3f21 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3229,6 +3229,12 @@ "backendLoadingTitle": "Backend starting up", "backendLoadingMessage": "Backend is currently loading and/or not started. Please wait a few moments and try again." }, + "backendStartup": { + "title": "Backend Starting", + "loadingTitle": "Backend starting up", + "loadingMessage": "The backend is currently starting up. This usually takes a few moments.", + "takingLonger": "This is taking longer than expected. The backend should be ready soon." + }, "signup": { "title": "Create an account", "subtitle": "Join Stirling PDF to get started", diff --git a/frontend/src/proprietary/App.tsx b/frontend/src/proprietary/App.tsx index eba2fa5c4c..522f039ef9 100644 --- a/frontend/src/proprietary/App.tsx +++ b/frontend/src/proprietary/App.tsx @@ -7,6 +7,7 @@ import Login from "@app/routes/Login"; import Signup from "@app/routes/Signup"; import AuthCallback from "@app/routes/AuthCallback"; import InviteAccept from "@app/routes/InviteAccept"; +import BackendStartup from "@app/routes/BackendStartup"; import OnboardingTour from "@app/components/onboarding/OnboardingTour"; // Import global styles @@ -28,6 +29,7 @@ export default function App() { } /> } /> } /> + } /> {/* Main app routes - Landing handles auth logic */} } /> diff --git a/frontend/src/proprietary/routes/BackendStartup.tsx b/frontend/src/proprietary/routes/BackendStartup.tsx new file mode 100644 index 0000000000..a5bff3d37f --- /dev/null +++ b/frontend/src/proprietary/routes/BackendStartup.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useDocumentMeta } from '@app/hooks/useDocumentMeta'; +import AuthLayout from '@app/routes/authShared/AuthLayout'; +import { BASE_PATH } from '@app/constants/app'; + +export default function BackendStartup() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const [pollAttempt, setPollAttempt] = useState(0); + const [statusMessage, setStatusMessage] = useState(null); + const baseUrl = window.location.origin + BASE_PATH; + + // Set document meta + useDocumentMeta({ + title: `${t('backendStartup.title', 'Backend Starting')} - Stirling PDF`, + description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogTitle: `${t('backendStartup.title', 'Backend Starting')} - Stirling PDF`, + ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogImage: `${baseUrl}/og_images/home.png`, + ogUrl: `${window.location.origin}${window.location.pathname}` + }); + + // Poll backend to check if it's ready + useEffect(() => { + let timeoutId: NodeJS.Timeout | null = null; + let isMounted = true; + + const checkBackendStatus = async () => { + try { + // Use a universal endpoint that works in both proprietary and non-proprietary builds + // Try /api/v1/info/status first (works in both builds if metrics enabled) + const response = await fetch(`${BASE_PATH}/api/v1/info/status`, { + method: 'GET', + cache: 'no-cache' + }); + + if (!isMounted) return; + + if (response.ok) { + const data = await response.json(); + + // Backend is ready if we get valid status back + if (data && data.status === 'UP') { + console.log('[BackendStartup] Backend is ready, redirecting'); + const redirectTo = sessionStorage.getItem('backendStartupRedirect') || '/'; + sessionStorage.removeItem('backendStartupRedirect'); + + // Small delay to ensure backend is fully ready + setTimeout(() => { + if (isMounted) { + navigate(redirectTo, { replace: true }); + } + }, 500); + return; + } + } + + // If status endpoint fails with 403 (metrics disabled), try proprietary endpoint + if (response.status === 403) { + console.debug('[BackendStartup] Metrics disabled, trying proprietary endpoint'); + const proprietaryResponse = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`, { + method: 'GET', + cache: 'no-cache' + }); + + if (!isMounted) return; + + if (proprietaryResponse.ok) { + const data = await proprietaryResponse.json(); + if (data && data.enableLogin !== null) { + console.log('[BackendStartup] Backend is ready (proprietary check), redirecting'); + const redirectTo = sessionStorage.getItem('backendStartupRedirect') || '/'; + sessionStorage.removeItem('backendStartupRedirect'); + + setTimeout(() => { + if (isMounted) { + navigate(redirectTo, { replace: true }); + } + }, 500); + return; + } + } + } + + // Backend not ready yet, continue polling + if (response.status === 404 || response.status === 503) { + setPollAttempt(prev => prev + 1); + setStatusMessage(`Backend not ready (${response.status})`); + } else { + setPollAttempt(prev => prev + 1); + setStatusMessage(response.statusText || null); + } + + // Schedule next poll (exponential backoff, max 5 seconds) + const delay = Math.min(1000 * Math.pow(1.5, pollAttempt), 5000); + timeoutId = setTimeout(checkBackendStatus, delay); + } catch (err) { + // Network error or backend not responding + if (!isMounted) return; + + console.debug('[BackendStartup] Poll attempt failed:', err); + setPollAttempt(prev => prev + 1); + setStatusMessage(err instanceof Error ? err.message : null); + + // Continue polling with backoff + const delay = Math.min(1000 * Math.pow(1.5, pollAttempt), 5000); + timeoutId = setTimeout(checkBackendStatus, delay); + } + }; + + // Start polling after a short delay to avoid race conditions + timeoutId = setTimeout(checkBackendStatus, 500); + + // Cleanup on unmount + return () => { + isMounted = false; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [navigate, pollAttempt, BASE_PATH]); + + return ( + + {/* Header without title prop for custom styling */} +
+
+ Stirling PDF +
+
+ + {/* Backend loading message */} +
+

+ {t('backendStartup.loadingTitle', 'Backend starting up')} +

+

+ {t('backendStartup.loadingMessage', 'The backend is currently starting up. This usually takes a few moments.')} +

+ + {/* Loading indicator */} +
+
+
+ + {statusMessage && ( +

+ {statusMessage} +

+ )} + + {pollAttempt > 10 && ( +

+ {t('backendStartup.takingLonger', 'This is taking longer than expected. The backend should be ready soon.')} +

+ )} +
+ + {/* Add spinner animation */} + + + ); +} diff --git a/frontend/src/proprietary/routes/Landing.tsx b/frontend/src/proprietary/routes/Landing.tsx index 165013e6fd..53d5e2b6e3 100644 --- a/frontend/src/proprietary/routes/Landing.tsx +++ b/frontend/src/proprietary/routes/Landing.tsx @@ -76,8 +76,13 @@ export default function Landing() { } // If login is disabled, show app directly (anonymous mode) - if (config?.enableLogin === false) { - console.debug('[Landing] Login disabled - showing app in anonymous mode'); + // Check both the config AND sessionStorage flag (in case config is wrong) + const loginActuallyDisabled = sessionStorage.getItem('loginActuallyDisabled') === 'true'; + if (config?.enableLogin === false || loginActuallyDisabled) { + console.debug('[Landing] Login disabled - showing app in anonymous mode', { + configSays: config?.enableLogin, + actuallyDisabled: loginActuallyDisabled + }); return ; } diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index e56d9faf90..505ef35842 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -4,6 +4,7 @@ import { springAuth } from '@app/auth/springAuthClient'; import { useAuth } from '@app/auth/UseSession'; import { useTranslation } from 'react-i18next'; import { useDocumentMeta } from '@app/hooks/useDocumentMeta'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; import AuthLayout from '@app/routes/authShared/AuthLayout'; // Import login components @@ -19,6 +20,7 @@ export default function Login() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { session, loading } = useAuth(); + const { config, loading: configLoading } = useAppConfig(); const { t } = useTranslation(); const [isSigningIn, setIsSigningIn] = useState(false); const [error, setError] = useState(null); @@ -29,20 +31,91 @@ export default function Login() { const [enabledProviders, setEnabledProviders] = useState([]); const [hasSSOProviders, setHasSSOProviders] = useState(false); const [_enableLogin, setEnableLogin] = useState(null); - const [backendUnavailable, setBackendUnavailable] = useState(false); - const [backendStatusDetails, setBackendStatusDetails] = useState(null); + const [backendCheckFailed, setBackendCheckFailed] = useState(false); + const [hasRedirected, setHasRedirected] = useState(false); + + // Check AppConfig first - if login is disabled, redirect to home immediately + useEffect(() => { + if (!configLoading && config?.enableLogin === false && !hasRedirected) { + console.debug('[Login] Config loaded, login disabled - redirecting to home'); + setHasRedirected(true); + navigate('/', { replace: true }); + } + }, [config, configLoading, navigate, hasRedirected]); // Fetch enabled SSO providers and login config from backend useEffect(() => { + let isMounted = true; + + // Don't fetch if config is still loading or if we know login is disabled + if (configLoading) { + console.debug('[Login] Waiting for config to load before fetching providers'); + return; + } + + if (config?.enableLogin === false) { + console.debug('[Login] Login disabled per config, skipping provider fetch'); + return; + } + const fetchProviders = async () => { try { - const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`); + const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`, { + cache: 'no-cache' + }); + + if (!isMounted) return; if (!response.ok) { - if (response.status === 404 || response.status === 503) { - console.warn(`[Login] Login config endpoint unavailable (status ${response.status})`); - setBackendUnavailable(true); - setBackendStatusDetails(response.statusText || null); + // 404 likely means security is disabled (non-proprietary backend) + // Check the general status endpoint to see if backend is up + if (response.status === 404) { + console.debug('[Login] Proprietary endpoint not found - checking if security is disabled (this is expected behavior when security is disabled)'); + + try { + const statusResponse = await fetch(`${BASE_PATH}/api/v1/info/status`, { + cache: 'no-cache' + }); + + if (statusResponse.ok) { + // Backend is up, but security is disabled + // This means the config is wrong - login is actually disabled + console.debug('[Login] Backend is up but security disabled - redirecting to home'); + + // Set a flag in sessionStorage so Landing knows login is actually disabled + sessionStorage.setItem('loginActuallyDisabled', 'true'); + + navigate('/', { replace: true }); + return; + } + } catch (statusErr) { + console.debug('[Login] Status check failed, backend may be starting up'); + } + + // Backend is not responding properly - redirect to startup page + console.debug('[Login] Backend starting up - redirecting to backend startup page'); + setBackendCheckFailed(true); + sessionStorage.setItem('backendStartupRedirect', window.location.pathname + window.location.search); + + setTimeout(() => { + if (isMounted) { + navigate('/backend-startup', { replace: true }); + } + }, 300); + return; + } + + // 503 means backend is starting up + if (response.status === 503) { + console.warn('[Login] Backend unavailable (503) - redirecting to backend startup'); + setBackendCheckFailed(true); + sessionStorage.setItem('backendStartupRedirect', window.location.pathname + window.location.search); + + setTimeout(() => { + if (isMounted) { + navigate('/backend-startup', { replace: true }); + } + }, 300); return; } @@ -52,22 +125,45 @@ export default function Login() { const data = await response.json(); + if (!isMounted) return; + if (!data || data.enableLogin === null) { - console.warn('[Login] Login config returned empty or null data - showing backend unavailable message'); - setBackendUnavailable(true); - setBackendStatusDetails(null); + console.warn('[Login] Login config returned empty or null data - checking backend status'); + + // Check if backend is actually up before assuming it's starting + try { + const statusResponse = await fetch(`${BASE_PATH}/api/v1/info/status`, { + cache: 'no-cache' + }); + + if (statusResponse.ok) { + // Backend is up but returning invalid data - redirect to home as fallback + console.debug('[Login] Backend up but invalid login data - redirecting to home'); + navigate('/', { replace: true }); + return; + } + } catch (statusErr) { + console.debug('[Login] Status check failed'); + } + + setBackendCheckFailed(true); + sessionStorage.setItem('backendStartupRedirect', window.location.pathname + window.location.search); + + setTimeout(() => { + if (isMounted) { + navigate('/backend-startup', { replace: true }); + } + }, 300); return; } // Check if login is disabled - if so, redirect to home if (data.enableLogin === false) { console.debug('[Login] Login disabled, redirecting to home'); - navigate('/'); + navigate('/', { replace: true }); return; } - setBackendUnavailable(false); - setBackendStatusDetails(null); setEnableLogin(data.enableLogin ?? true); // Extract provider IDs from the providerList map @@ -77,13 +173,26 @@ export default function Login() { .filter((id): id is string => id !== undefined); setEnabledProviders(providerIds); } catch (err) { + if (!isMounted) return; + console.error('[Login] Failed to fetch enabled providers:', err); - setBackendUnavailable(true); - setBackendStatusDetails(err instanceof Error ? err.message : null); + setBackendCheckFailed(true); + sessionStorage.setItem('backendStartupRedirect', window.location.pathname + window.location.search); + + setTimeout(() => { + if (isMounted) { + navigate('/backend-startup', { replace: true }); + } + }, 300); } }; + fetchProviders(); - }, [navigate]); + + return () => { + isMounted = false; + }; + }, [navigate, BASE_PATH, config, configLoading]); // Update hasSSOProviders and showEmailForm when enabledProviders changes useEffect(() => { @@ -148,6 +257,23 @@ export default function Login() { return ; } + // Show loading while checking backend, config, or redirecting + // Also show loading if we know login is disabled (we're about to redirect) + if (loading || configLoading || backendCheckFailed || hasRedirected || (!configLoading && config?.enableLogin === false)) { + return ( + +
+
+
+
+ Loading... +
+
+
+
+ ); + } + const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => { try { setIsSigningIn(true); @@ -211,97 +337,68 @@ export default function Login() { // navigate('/auth/reset'); // }; - const loginHeaderTitle = backendUnavailable ? undefined : (t('login.login') || 'Sign in'); - return ( - + - {backendUnavailable ? ( -
-

- {t('login.backendLoadingTitle', 'Backend starting up')} + {/* Success message */} + {successMessage && ( +

+

+ {successMessage}

-

- {t('login.backendLoadingMessage', 'Backend is currently loading and/or not started. Please wait a few moments and try again.')} -

- {backendStatusDetails && ( -

- {backendStatusDetails} -

- )}
- ) : ( - <> - {/* Success message */} - {successMessage && ( -
-

- {successMessage} -

-
- )} + )} - + - {/* OAuth first */} - + + {/* Divider between OAuth and Email - only show if SSO is available */} + {hasSSOProviders && ( + + )} + + {/* Sign in with email button - only show if SSO providers exist */} + {hasSSOProviders && !showEmailForm && ( +
+ +
+ )} + + {/* Email form - show by default if no SSO, or when button clicked */} + {showEmailForm && ( +
+ - - {/* Divider between OAuth and Email - only show if SSO is available */} - {hasSSOProviders && ( - - )} - - {/* Sign in with email button - only show if SSO providers exist */} - {hasSSOProviders && !showEmailForm && ( -
- -
- )} - - {/* Email form - show by default if no SSO, or when button clicked */} - {showEmailForm && ( -
- -
- )} - +
)}