Compare commits

..
Author SHA1 Message Date
Anthony StirlingandGitHub 7b220ee46b Merge branch 'V2' into activeUsers 2025-11-19 12:35:15 +00:00
Anthony Stirling 25f7e6e0b5 cors fix 2025-11-18 23:19:41 +00:00
Anthony Stirling c5a2d765ad browser ident 2025-11-18 21:01:17 +00:00
Anthony Stirling 815ad384d1 activeUsers 2025-11-18 19:52:27 +00:00
12 changed files with 280 additions and 429 deletions
@@ -0,0 +1,49 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
/**
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
* Only active when security is disabled (no-login mode).
*/
@Component
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
@RequiredArgsConstructor
@Slf4j
public class WAUTrackingFilter implements Filter {
private final WeeklyActiveUsersService wauService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request instanceof HttpServletRequest httpRequest) {
// Extract browser ID from header
String browserId = httpRequest.getHeader("X-Browser-Id");
if (browserId != null && !browserId.trim().isEmpty()) {
// Record browser access
wauService.recordBrowserAccess(browserId);
}
}
// Continue the filter chain
chain.doFilter(request, response);
}
}
@@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer {
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -73,7 +89,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN")
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
@@ -98,7 +115,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN")
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
import stirling.software.common.annotations.api.InfoApi;
import stirling.software.common.model.ApplicationProperties;
@@ -34,6 +35,7 @@ public class MetricsController {
private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private final Optional<WeeklyActiveUsersService> wauService;
private boolean metricsEnabled;
@PostConstruct
@@ -352,6 +354,35 @@ public class MetricsController {
return ResponseEntity.ok(formatDuration(uptime));
}
@GetMapping("/wau")
@Operation(
summary = "Weekly Active Users statistics",
description =
"Returns WAU (Weekly Active Users) count and total unique browsers. "
+ "Only available when security is disabled (no-login mode). "
+ "Tracks unique browsers via client-generated UUID in localStorage.")
public ResponseEntity<?> getWeeklyActiveUsers() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
// Check if WAU service is available (only when security.enableLogin=false)
if (wauService.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("WAU tracking is only available when security is disabled (no-login mode)");
}
WeeklyActiveUsersService service = wauService.get();
Map<String, Object> wauStats = new HashMap<>();
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
wauStats.put("daysOnline", service.getDaysOnline());
wauStats.put("trackingSince", service.getStartTime().toString());
return ResponseEntity.ok(wauStats);
}
private String formatDuration(Duration duration) {
long days = duration.toDays();
long hours = duration.toHoursPart();
@@ -0,0 +1,100 @@
package stirling.software.SPDF.service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Service for tracking Weekly Active Users (WAU) in no-login mode.
* Uses in-memory storage with automatic cleanup of old entries.
*/
@Service
@Slf4j
public class WeeklyActiveUsersService {
// Map of browser ID -> last seen timestamp
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
// Track total unique browsers seen (overall)
private long totalUniqueBrowsers = 0;
// Application start time
private final Instant startTime = Instant.now();
/**
* Records a browser access with the current timestamp
* @param browserId Unique browser identifier from X-Browser-Id header
*/
public void recordBrowserAccess(String browserId) {
if (browserId == null || browserId.trim().isEmpty()) {
return;
}
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
activeBrowsers.put(browserId, Instant.now());
if (isNewBrowser) {
totalUniqueBrowsers++;
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
}
}
/**
* Gets the count of unique browsers seen in the last 7 days
* @return Weekly Active Users count
*/
public long getWeeklyActiveUsers() {
cleanupOldEntries();
return activeBrowsers.size();
}
/**
* Gets the total count of unique browsers ever seen
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
return totalUniqueBrowsers;
}
/**
* Gets the number of days the service has been running
* @return Days online
*/
public long getDaysOnline() {
return ChronoUnit.DAYS.between(startTime, Instant.now());
}
/**
* Gets the timestamp when tracking started
* @return Start time
*/
public Instant getStartTime() {
return startTime;
}
/**
* Removes entries older than 7 days
*/
private void cleanupOldEntries() {
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
}
/**
* Manual cleanup trigger (can be called by scheduled task if needed)
*/
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
int sizeAfter = activeBrowsers.size();
if (sizeBefore != sizeAfter) {
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
}
}
}
@@ -3381,15 +3381,7 @@
"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.",
"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."
"credentialsUpdated": "Your credentials have been updated. Please sign in again."
},
"signup": {
"title": "Create an account",
+11 -2
View File
@@ -1,5 +1,14 @@
import { AxiosInstance } from 'axios';
import { getBrowserId } from '@app/utils/browserIdentifier';
export function setupApiInterceptors(_client: AxiosInstance): void {
// Core version: no interceptors to add
export function setupApiInterceptors(client: AxiosInstance): void {
// Add browser ID header for WAU tracking
client.interceptors.request.use(
(config) => {
const browserId = getBrowserId();
config.headers['X-Browser-Id'] = browserId;
return config;
},
(error) => Promise.reject(error)
);
}
@@ -0,0 +1,46 @@
/**
* Browser identifier utility for anonymous usage tracking
* Generates and persists a unique UUID in localStorage for WAU tracking
*/
const BROWSER_ID_KEY = 'stirling_browser_id';
/**
* Gets or creates a unique browser identifier
* Used for Weekly Active Users (WAU) tracking in no-login mode
*/
export function getBrowserId(): string {
try {
// Try to get existing ID from localStorage
let browserId = localStorage.getItem(BROWSER_ID_KEY);
if (!browserId) {
// Generate new UUID v4
browserId = generateUUID();
localStorage.setItem(BROWSER_ID_KEY, browserId);
}
return browserId;
} catch (error) {
// Fallback to session-based ID if localStorage is unavailable
console.warn('localStorage unavailable, using session-based ID', error);
return `session_${generateUUID()}`;
}
}
/**
* Generates a UUID v4
*/
function generateUUID(): string {
// Use crypto.randomUUID if available (modern browsers)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback to manual UUID generation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
-2
View File
@@ -8,7 +8,6 @@ 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
@@ -31,7 +30,6 @@ 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 />} />
@@ -1,183 +0,0 @@
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>
);
}
+3 -61
View File
@@ -6,7 +6,6 @@ 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
@@ -17,12 +16,11 @@ import { BASE_PATH } from '@app/constants/app'
*/
export default function Landing() {
const { session, loading: authLoading, refreshSession } = useAuth();
const { config, loading: configLoading, error: configError } = useAppConfig();
const { config, loading: configLoading } = 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;
@@ -56,64 +54,13 @@ 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 (
@@ -129,13 +76,8 @@ export default function Landing() {
}
// If login is disabled, show app directly (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
});
if (config?.enableLogin === false) {
console.debug('[Landing] Login disabled - showing app in anonymous mode');
return <HomePage />;
}
+15 -166
View File
@@ -4,7 +4,6 @@ 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
@@ -20,7 +19,6 @@ 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);
@@ -31,169 +29,37 @@ 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`, {
cache: 'no-cache'
});
const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`);
if (response.ok) {
const data = await response.json();
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);
// Check if login is disabled - if so, redirect to home
if (data.enableLogin === false) {
console.debug('[Login] Login disabled, redirecting to home');
navigate('/');
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);
setEnableLogin(data.enableLogin ?? true);
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})`);
// 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);
}
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();
return () => {
isMounted = false;
};
}, [navigate, BASE_PATH, config, configLoading]);
}, [navigate]);
// Update hasSSOProviders and showEmailForm when enabledProviders changes
useEffect(() => {
@@ -258,23 +124,6 @@ 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>
{title && <h1 className="login-title">{title}</h1>}
<h1 className="login-title">{title}</h1>
{subtitle && (
<p className="login-subtitle">{subtitle}</p>
)}