startup screen

This commit is contained in:
Anthony Stirling
2025-11-19 12:04:52 +00:00
parent daf044a7e4
commit dba316c14f
5 changed files with 393 additions and 100 deletions
@@ -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",
+2
View File
@@ -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() {
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/backend-startup" element={<BackendStartup />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
@@ -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<string | null>(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 (
<AuthLayout>
{/* Header without title prop for custom styling */}
<div className="login-header">
<div className="login-header-logos">
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
</div>
</div>
{/* Backend loading message */}
<div
className="auth-section"
style={{
padding: '1.5rem',
marginTop: '1rem',
borderRadius: '0.75rem',
backgroundColor: 'rgba(37, 99, 235, 0.08)',
border: '1px solid rgba(37, 99, 235, 0.2)',
}}
>
<p style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '0.5rem', textAlign: 'center' }}>
{t('backendStartup.loadingTitle', 'Backend starting up')}
</p>
<p style={{ margin: 0, textAlign: 'center', color: 'rgba(15, 23, 42, 0.8)' }}>
{t('backendStartup.loadingMessage', 'The backend is currently starting up. This usually takes a few moments.')}
</p>
{/* Loading indicator */}
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '1rem' }}>
<div style={{
width: '32px',
height: '32px',
border: '3px solid rgba(37, 99, 235, 0.2)',
borderTop: '3px solid rgba(37, 99, 235, 0.8)',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
</div>
{statusMessage && (
<p style={{ marginTop: '0.75rem', fontSize: '0.875rem', textAlign: 'center', color: 'rgba(15, 23, 42, 0.6)' }}>
{statusMessage}
</p>
)}
{pollAttempt > 10 && (
<p style={{ marginTop: '1rem', fontSize: '0.875rem', textAlign: 'center', color: 'rgba(15, 23, 42, 0.7)' }}>
{t('backendStartup.takingLonger', 'This is taking longer than expected. The backend should be ready soon.')}
</p>
)}
</div>
{/* Add spinner animation */}
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</AuthLayout>
);
}
+7 -2
View File
@@ -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 <HomePage />;
}
+195 -98
View File
@@ -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<string | null>(null);
@@ -29,20 +31,91 @@ export default function Login() {
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
const [backendUnavailable, setBackendUnavailable] = useState(false);
const [backendStatusDetails, setBackendStatusDetails] = useState<string | null>(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 <LoggedInState />;
}
// 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 (
<AuthLayout>
<div style={{ minHeight: '300px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
Loading...
</div>
</div>
</div>
</AuthLayout>
);
}
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 (
<AuthLayout>
<LoginHeader title={loginHeaderTitle} />
<LoginHeader title={t('login.login') || 'Sign in'} />
{backendUnavailable ? (
<div
className="auth-section"
style={{
padding: '1.5rem',
marginTop: '1rem',
borderRadius: '0.75rem',
backgroundColor: 'rgba(37, 99, 235, 0.08)',
border: '1px solid rgba(37, 99, 235, 0.2)',
}}
>
<p style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '0.5rem', textAlign: 'center' }}>
{t('login.backendLoadingTitle', 'Backend starting up')}
{/* Success message */}
{successMessage && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: '0.5rem',
color: '#16a34a'
}}>
<p style={{ margin: 0, fontSize: '0.875rem', textAlign: 'center' }}>
{successMessage}
</p>
<p style={{ margin: 0, textAlign: 'center', color: 'rgba(15, 23, 42, 0.8)' }}>
{t('login.backendLoadingMessage', 'Backend is currently loading and/or not started. Please wait a few moments and try again.')}
</p>
{backendStatusDetails && (
<p style={{ marginTop: '0.75rem', fontSize: '0.875rem', textAlign: 'center', color: 'rgba(15, 23, 42, 0.6)' }}>
{backendStatusDetails}
</p>
)}
</div>
) : (
<>
{/* Success message */}
{successMessage && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: '0.5rem',
color: '#16a34a'
}}>
<p style={{ margin: 0, fontSize: '0.875rem', textAlign: 'center' }}>
{successMessage}
</p>
</div>
)}
)}
<ErrorMessage error={error} />
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={enabledProviders}
/>
{/* Divider between OAuth and Email - only show if SSO is available */}
{hasSSOProviders && (
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
)}
{/* Sign in with email button - only show if SSO providers exist */}
{hasSSOProviders && !showEmailForm && (
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm(true)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Login with email')}
</button>
</div>
)}
{/* Email form - show by default if no SSO, or when button clicked */}
{showEmailForm && (
<div style={{ marginTop: hasSSOProviders ? '1rem' : '0' }}>
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={enabledProviders}
submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')}
/>
{/* Divider between OAuth and Email - only show if SSO is available */}
{hasSSOProviders && (
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
)}
{/* Sign in with email button - only show if SSO providers exist */}
{hasSSOProviders && !showEmailForm && (
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm(true)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Login with email')}
</button>
</div>
)}
{/* Email form - show by default if no SSO, or when button clicked */}
{showEmailForm && (
<div style={{ marginTop: hasSSOProviders ? '1rem' : '0' }}>
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')}
/>
</div>
)}
</>
</div>
)}
</AuthLayout>