Compare commits

...
Author SHA1 Message Date
Anthony Stirling ccaae128b7 tauri removal for now 2025-11-21 13:08:52 +00:00
Anthony Stirling 63bcb73637 tauri 2025-11-19 15:15:35 +00:00
Anthony Stirling 531a7fdbcc again 2025-11-19 12:35:29 +00:00
Anthony Stirling 920f2898e1 Merge remote-tracking branch 'origin/V2' into
codex/update-login-logic-for-error-handling
2025-11-19 12:10:52 +00:00
Anthony Stirling dba316c14f startup screen 2025-11-19 12:04:52 +00:00
Anthony Stirling daf044a7e4 langs 2025-11-17 21:36:22 +00:00
Anthony Stirling 81512b7805 Clarify backend unavailable login header 2025-11-16 10:43:48 +00:00
6 changed files with 423 additions and 21 deletions
@@ -3381,7 +3381,15 @@
"unexpectedError": "Unexpected error: {{message}}",
"accountCreatedSuccess": "Account created successfully! You can now sign in.",
"passwordChangedSuccess": "Password changed successfully! Please sign in with your new password.",
"credentialsUpdated": "Your credentials have been updated. Please sign in again."
"credentialsUpdated": "Your credentials have been updated. Please sign in again.",
"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",
+2
View File
@@ -8,6 +8,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
@@ -30,6 +31,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>
);
}
+61 -3
View File
@@ -6,6 +6,7 @@ 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 { BASE_PATH } from '@app/constants/app'
/**
* Landing component - Smart router based on authentication status
@@ -16,11 +17,12 @@ 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, error: configError } = useAppConfig();
const location = useLocation();
const [isFirstLogin, setIsFirstLogin] = useState(false);
const [checkingFirstLogin, setCheckingFirstLogin] = useState(false);
const [username, setUsername] = useState('');
const [backendCheckFailed, setBackendCheckFailed] = useState(false);
const loading = authLoading || configLoading;
@@ -54,13 +56,64 @@ export default function Landing() {
// The auth system will automatically redirect to login when session is null
}
// If AppConfig failed to load, check if backend is down
useEffect(() => {
let isMounted = true;
// Only check if config has an error and we haven't already redirected
if (configError && !backendCheckFailed && !configLoading) {
console.debug('[Landing] Config error detected, checking backend status');
const checkBackend = async () => {
try {
const response = await fetch(`${BASE_PATH}/api/v1/info/status`, {
cache: 'no-cache'
});
if (!isMounted) return;
// If backend responds, it's up - continue normally
if (response.ok) {
console.debug('[Landing] Backend is up despite config error');
return;
}
// Backend returned error - redirect to startup page
console.debug('[Landing] Backend not ready, redirecting to startup page');
setBackendCheckFailed(true);
sessionStorage.setItem('backendStartupRedirect', location.pathname + location.search);
} catch (err) {
// Network error - backend is down
if (!isMounted) return;
console.debug('[Landing] Backend unavailable (network error), redirecting to startup page');
setBackendCheckFailed(true);
sessionStorage.setItem('backendStartupRedirect', location.pathname + location.search);
}
};
checkBackend();
}
return () => {
isMounted = false;
};
}, [configError, configLoading, location, backendCheckFailed]);
console.log('[Landing] State:', {
pathname: location.pathname,
loading,
hasSession: !!session,
loginEnabled: config?.enableLogin,
configError,
backendCheckFailed,
});
// Redirect to backend startup if backend check failed
if (backendCheckFailed) {
return <Navigate to="/backend-startup" replace />;
}
// Show loading while checking auth and config
if (loading || checkingFirstLogin) {
return (
@@ -76,8 +129,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 />;
}
+166 -15
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,37 +31,169 @@ export default function Login() {
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | 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`);
if (response.ok) {
const data = await response.json();
const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`, {
cache: 'no-cache'
});
// Check if login is disabled - if so, redirect to home
if (data.enableLogin === false) {
console.debug('[Login] Login disabled, redirecting to home');
navigate('/');
if (!isMounted) return;
if (!response.ok) {
// 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;
}
setEnableLogin(data.enableLogin ?? true);
// 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);
// Extract provider IDs from the providerList map
// The keys are like "/oauth2/authorization/google" - extract the last part
const providerIds = Object.keys(data.providerList || {})
.map(key => key.split('/').pop())
.filter((id): id is string => id !== undefined);
setEnabledProviders(providerIds);
setTimeout(() => {
if (isMounted) {
navigate('/backend-startup', { replace: true });
}
}, 300);
return;
}
const errorText = await response.text();
throw new Error(errorText || `Failed to fetch login configuration (${response.status})`);
}
const data = await response.json();
if (!isMounted) return;
if (!data || data.enableLogin === 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');
}
// Backend is not responding - redirect to startup page
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('/', { replace: true });
return;
}
setEnableLogin(data.enableLogin ?? true);
// Extract provider IDs from the providerList map
// The keys are like "/oauth2/authorization/google" - extract the last part
const providerIds = Object.keys(data.providerList || {})
.map(key => key.split('/').pop())
.filter((id): id is string => id !== undefined);
setEnabledProviders(providerIds);
} catch (err) {
if (!isMounted) return;
console.error('[Login] Failed to fetch enabled providers:', err);
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(() => {
@@ -124,6 +258,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);
@@ -2,7 +2,7 @@
import { BASE_PATH } from '@app/constants/app';
interface LoginHeaderProps {
title: string
title?: string
subtitle?: string
}
@@ -12,7 +12,7 @@ export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
<div className="login-header-logos">
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
</div>
<h1 className="login-title">{title}</h1>
{title && <h1 className="login-title">{title}</h1>}
{subtitle && (
<p className="login-subtitle">{subtitle}</p>
)}