Main Issues Fixed: 1. Tools Disabled on Initial Login (Required Page Refresh) Problem: After successful login, all PDF tools appeared grayed out/disabled until the user refreshed the page. Root Cause: Race condition where tools checked endpoint availability before JWT was stored in localStorage. Fix: - Implemented optimistic defaults in useEndpointConfig - assumes endpoints are enabled when no JWT exists - Added JWT availability event system (jwt-available event) to notify components when authentication is ready - Tools now remain enabled during auth initialization instead of defaulting to disabled 2. Session Lost on Page Refresh (Immediate Logout) Problem: Users were immediately logged out when refreshing the page, losing their authenticated session. Root Causes: - Spring Security form login was redirecting API calls to /login with 302 responses instead of returning JSON - /api/v1/auth/me endpoint was incorrectly in the permitAll list - JWT filter wasn't allowing /api/v1/config endpoints without authentication Fixes: - Backend: Disabled form login in v2/JWT mode by adding && !v2Enabled condition to form login configuration - Backend: Removed /api/v1/auth/me from permitAll list - it now requires authentication - Backend: Added /api/v1/config to public endpoints in JWT filter - Backend: Configured proper exception handling for API endpoints to return JSON (401) instead of HTML redirects (302) 3. Multiple Duplicate API Calls Problem: After login, /app-config was called 5+ times, /endpoints-enabled and /me called multiple times, causing unnecessary network traffic. Root Cause: Multiple React components each had their own instance of useAppConfig and useEndpointConfig hooks, each fetching data independently. Fix: - Frontend: Created singleton AppConfigContext provider to ensure only one global config fetch - Frontend: Added global caching to useEndpointConfig with module-level cache variables - Frontend: Implemented fetch deduplication with fetchCount tracking and globalFetchedSets - Result: Reduced API calls from 5+ to 1-2 per endpoint (2 in dev due to React StrictMode) Additional Improvements: CORS Configuration - Added flexible CORS configuration matching SaaS pattern - Explicitly allows localhost development ports (3000, 5173, 5174, etc.) - No hardcoded URLs in application.properties Security Handlers Integration - Added IP-based account locking without dependency on form login - Preserved audit logging with @Audited annotations Key Code Changes: Backend Files: - SecurityConfiguration.java - Disabled form login for v2, added CORS config - JwtAuthenticationFilter.java - Added /api/v1/config to public endpoints - JwtAuthenticationEntryPoint.java - Returns JSON for API requests Frontend Files: - AppConfigContext.tsx - New singleton context for app configuration - useEndpointConfig.ts - Added global caching and deduplication - UseSession.tsx - Removed redundant config checking - Various hooks - Updated to use context providers instead of direct fetching --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ludy <Ludy87@users.noreply.github.com> Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Ethan <ethan@MacBook-Pro.local> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
194 lines
6.5 KiB
TypeScript
194 lines
6.5 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import apiClient from '@app/services/apiClient';
|
|
|
|
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
|
const globalFetchedSets = new Set<string>();
|
|
const globalEndpointCache: Record<string, boolean> = {};
|
|
|
|
/**
|
|
* Hook to check if a specific endpoint is enabled
|
|
* This wraps the context for single endpoint checks
|
|
*/
|
|
export function useEndpointEnabled(endpoint: string): {
|
|
enabled: boolean | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
refetch: () => Promise<void>;
|
|
} {
|
|
const [enabled, setEnabled] = useState<boolean | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchEndpointStatus = async () => {
|
|
if (!endpoint) {
|
|
setEnabled(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
|
|
const isEnabled = response.data;
|
|
setEnabled(isEnabled);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchEndpointStatus();
|
|
}, [endpoint]);
|
|
|
|
return {
|
|
enabled,
|
|
loading,
|
|
error,
|
|
refetch: fetchEndpointStatus,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook to check multiple endpoints at once using batch API
|
|
* Returns a map of endpoint -> enabled status
|
|
*/
|
|
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|
endpointStatus: Record<string, boolean>;
|
|
loading: boolean;
|
|
error: string | null;
|
|
refetch: () => Promise<void>;
|
|
} {
|
|
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchAllEndpointStatuses = async (force = false) => {
|
|
const endpointsKey = [...endpoints].sort().join(',');
|
|
|
|
// Skip if we already fetched these exact endpoints globally
|
|
if (!force && globalFetchedSets.has(endpointsKey)) {
|
|
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
|
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
|
if (endpoint in globalEndpointCache) {
|
|
acc[endpoint] = globalEndpointCache[endpoint];
|
|
}
|
|
return acc;
|
|
}, {} as Record<string, boolean>);
|
|
setEndpointStatus(cachedStatus);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (!endpoints || endpoints.length === 0) {
|
|
setEndpointStatus({});
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Check if JWT exists - if not, optimistically enable all endpoints
|
|
const hasJwt = !!localStorage.getItem('stirling_jwt');
|
|
if (!hasJwt) {
|
|
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
|
|
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
|
acc[endpoint] = true;
|
|
return acc;
|
|
}, {} as Record<string, boolean>);
|
|
setEndpointStatus(optimisticStatus);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Check which endpoints we haven't fetched yet
|
|
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
|
if (newEndpoints.length === 0) {
|
|
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
|
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
|
acc[endpoint] = globalEndpointCache[endpoint];
|
|
return acc;
|
|
}, {} as Record<string, boolean>);
|
|
setEndpointStatus(cachedStatus);
|
|
globalFetchedSets.add(endpointsKey);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Use batch API for efficiency - only fetch new endpoints
|
|
const endpointsParam = newEndpoints.join(',');
|
|
|
|
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
|
const statusMap = response.data;
|
|
|
|
// Update global cache with new results
|
|
Object.assign(globalEndpointCache, statusMap);
|
|
|
|
// Get all requested endpoints from cache (including previously cached ones)
|
|
const fullStatus = endpoints.reduce((acc, endpoint) => {
|
|
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
|
|
return acc;
|
|
}, {} as Record<string, boolean>);
|
|
|
|
setEndpointStatus(fullStatus);
|
|
globalFetchedSets.add(endpointsKey);
|
|
} catch (err: any) {
|
|
// On 401 (auth error), use optimistic fallback instead of disabling
|
|
if (err.response?.status === 401) {
|
|
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
|
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
|
acc[endpoint] = true;
|
|
globalEndpointCache[endpoint] = true; // Cache the optimistic value
|
|
return acc;
|
|
}, {} as Record<string, boolean>);
|
|
setEndpointStatus(optimisticStatus);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
setError(errorMessage);
|
|
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
|
|
|
// Fallback: assume all endpoints are enabled on error (optimistic)
|
|
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
|
acc[endpoint] = true;
|
|
return acc;
|
|
}, {} as Record<string, boolean>);
|
|
setEndpointStatus(optimisticStatus);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchAllEndpointStatuses();
|
|
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
|
|
|
// Listen for JWT availability (triggered on login/signup)
|
|
useEffect(() => {
|
|
const handleJwtAvailable = () => {
|
|
console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth');
|
|
// Clear the global cache to allow refetch with JWT
|
|
globalFetchedSets.clear();
|
|
Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]);
|
|
fetchAllEndpointStatuses(true);
|
|
};
|
|
|
|
window.addEventListener('jwt-available', handleJwtAvailable);
|
|
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
|
}, [endpoints.join(',')]);
|
|
|
|
return {
|
|
endpointStatus,
|
|
loading,
|
|
error,
|
|
refetch: () => fetchAllEndpointStatuses(true),
|
|
};
|
|
}
|