Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b220ee46b | ||
|
|
25f7e6e0b5 | ||
|
|
c5a2d765ad | ||
|
|
815ad384d1 |
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user