Login Refresh Fix (#4779)

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] <[email protected]>
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 <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Ethan <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
Dario Ghunney Ware
2025-11-06 15:42:22 +00:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Ludy EthanHealy01 Ethan Anthony Stirling stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> ConnorYoh Connor Yoh
parent 9440e99227
commit f5c67a3239
18 changed files with 760 additions and 197 deletions
+37 -5
View File
@@ -441,6 +441,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -487,6 +488,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -510,6 +512,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@@ -593,6 +596,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -609,6 +613,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -626,6 +631,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -662,6 +668,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -696,6 +703,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -732,6 +740,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -807,6 +816,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -962,6 +972,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1005,6 +1016,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2035,6 +2047,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2085,6 +2098,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2152,6 +2166,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.4",
@@ -3835,6 +3850,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4158,6 +4174,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4168,6 +4185,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4228,6 +4246,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -4941,7 +4960,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.22"
}
@@ -4951,7 +4969,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
@@ -4962,7 +4979,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
@@ -4975,7 +4991,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
@@ -5002,6 +5017,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5686,6 +5702,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6731,7 +6748,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7126,6 +7144,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7296,6 +7315,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -8618,6 +8638,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9425,6 +9446,7 @@
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
@@ -11201,6 +11223,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11480,6 +11503,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -11852,6 +11876,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11861,6 +11886,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13531,6 +13557,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13832,6 +13859,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13914,6 +13942,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14118,6 +14147,7 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14269,6 +14299,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14282,6 +14313,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
+48 -17
View File
@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import apiClient from '@app/services/apiClient';
export interface AppConfig {
baseUrl?: string;
@@ -36,52 +36,84 @@ interface AppConfigContextValue {
refetch: () => Promise<void>;
}
// Create context
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
const AppConfigContext = createContext<AppConfigContextValue | undefined>({
config: null,
loading: true,
error: null,
refetch: async () => {},
});
/**
* Provider component that fetches and provides app configuration
* Should be placed at the top level of the app, before any components that need config
*/
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [config, setConfig] = useState<AppConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const headers = useRequestHeaders();
const [fetchCount, setFetchCount] = useState(0);
const fetchConfig = async (force = false) => {
// Prevent duplicate fetches unless forced
if (!force && fetchCount > 0) {
console.debug('[AppConfig] Already fetched, skipping');
return;
}
const fetchConfig = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/config/app-config', {
headers,
});
// apiClient automatically adds JWT header if available via interceptors
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
const data = response.data;
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
console.debug('[AppConfig] Config fetched successfully:', data);
setConfig(data);
setFetchCount(prev => prev + 1);
} catch (err: any) {
// On 401 (not authenticated), use default config with login enabled
// This allows the app to work even without authentication
if (err.response?.status === 401) {
console.debug('[AppConfig] 401 error - using default config (login enabled)');
setConfig({ enableLogin: true });
setLoading(false);
return;
}
const data: AppConfig = await response.json();
setConfig(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('[AppConfig] Failed to fetch app config:', err);
// On error, assume login is enabled (safe default)
setConfig({ enableLogin: true });
} finally {
setLoading(false);
}
};
useEffect(() => {
// Always try to fetch config to check if login is disabled
// The endpoint should be public and return proper JSON
fetchConfig();
}, []);
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[AppConfig] JWT available event - refetching config');
// Force refetch with JWT
fetchConfig(true);
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, []);
const value: AppConfigContextValue = {
config,
loading,
error,
refetch: fetchConfig,
refetch: () => fetchConfig(true),
};
return (
@@ -104,4 +136,3 @@ export function useAppConfig(): AppConfigContextValue {
return context;
}
+102 -33
View File
@@ -1,8 +1,13 @@
import { useState, useEffect } from 'react';
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
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;
@@ -13,7 +18,6 @@ export function useEndpointEnabled(endpoint: string): {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const headers = useRequestHeaders();
const fetchEndpointStatus = async () => {
if (!endpoint) {
@@ -26,15 +30,8 @@ export function useEndpointEnabled(endpoint: string): {
setLoading(true);
setError(null);
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, {
headers,
});
if (!response.ok) {
throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`);
}
const isEnabled: boolean = await response.json();
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';
@@ -69,43 +66,101 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const headers = useRequestHeaders();
const fetchAllEndpointStatuses = async () => {
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);
// Use batch API for efficiency
const endpointsParam = endpoints.join(',');
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, {
headers,
});
if (!response.ok) {
throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`);
// 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;
}
const statusMap: Record<string, boolean> = await response.json();
setEndpointStatus(statusMap);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('Failed to check multiple endpoints:', err);
// Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(',');
// Fallback: assume all endpoints are disabled on error
const fallbackStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = false;
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(fallbackStatus);
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);
}
@@ -115,10 +170,24 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
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,
refetch: () => fetchAllEndpointStatuses(true),
};
}
@@ -24,10 +24,18 @@ export const useToolManagement = (): ToolManagementResult => {
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
if (endpointsLoading) return true;
const tool = baseRegistry[toolKey as ToolId];
const endpoints = tool?.endpoints || [];
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
// Tools without endpoints are always available
if (endpoints.length === 0) return true;
// Check if at least one endpoint is enabled
// If endpoint is not in status map, assume enabled (optimistic fallback)
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
+2 -17
View File
@@ -95,23 +95,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
console.debug('[Auth] Initializing auth...');
// First check if login is enabled
const configResponse = await fetch('/api/v1/config/app-config');
if (configResponse.ok) {
const config = await configResponse.json();
// If login is disabled, skip authentication entirely
if (config.enableLogin === false) {
console.debug('[Auth] Login disabled - skipping authentication');
if (mounted) {
setSession(null);
setLoading(false);
}
return;
}
}
// Login is enabled, proceed with normal auth check
// Skip config check entirely - let the app handle login state
// The config will be fetched by useAppConfig when needed
const { data, error } = await springAuth.getSession();
if (!mounted) return;
@@ -1,3 +1,5 @@
import { BASE_PATH } from '@app/constants/app';
/**
* Spring Auth Client
*
@@ -7,6 +9,37 @@
* - No email confirmation flow (auto-confirmed on registration)
*/
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`;
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== 'string') {
return DEFAULT_REDIRECT_PATH;
}
try {
const parsed = new URL(target, window.location.origin);
const path = parsed.pathname || '/';
const query = parsed.search || '';
return `${path}${query}`;
} catch {
const trimmed = target.trim();
if (!trimmed) {
return DEFAULT_REDIRECT_PATH;
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
}
}
function persistRedirectPath(path: string): void {
try {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
} catch (error) {
console.warn('[SpringAuth] Failed to persist OAuth redirect path', error);
}
}
// Auth types
export interface User {
id: string;
@@ -85,20 +118,44 @@ class SpringAuthClient {
}
// Verify with backend
console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
const response = await fetch('/api/v1/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
console.debug('[SpringAuth] /me response status:', response.status);
const contentType = response.headers.get('content-type');
console.debug('[SpringAuth] /me content-type:', contentType);
if (!response.ok) {
// Log the error response for debugging
const errorBody = await response.text();
console.error('[SpringAuth] getSession: /api/v1/auth/me failed', {
status: response.status,
statusText: response.statusText,
body: errorBody
});
// Token invalid or expired - clear it
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
return { data: { session: null }, error: null };
console.warn('[SpringAuth] getSession: Cleared invalid JWT from localStorage');
return { data: { session: null }, error: { message: `Auth failed: ${response.status}` } };
}
// Check if response is JSON before parsing
if (!contentType?.includes('application/json')) {
const text = await response.text();
console.error('[SpringAuth] /me returned non-JSON:', {
contentType,
bodyPreview: text.substring(0, 200)
});
throw new Error(`/api/v1/auth/me returned HTML instead of JSON`);
}
const data = await response.json();
console.debug('[SpringAuth] /me response data:', data);
// Create session object
const session: Session = {
@@ -151,6 +208,9 @@ class SpringAuthClient {
localStorage.setItem('stirling_jwt', token);
console.log('[SpringAuth] JWT stored in localStorage');
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
const session: Session = {
user: data.user,
access_token: token,
@@ -220,6 +280,9 @@ class SpringAuthClient {
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
persistRedirectPath(redirectPath);
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
const redirectUrl = `/oauth2/authorization/${params.provider}`;
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
@@ -299,6 +362,9 @@ class SpringAuthClient {
// Store new token
localStorage.setItem('stirling_jwt', newToken);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
// Get updated user info
const userResponse = await fetch('/api/v1/auth/me', {
headers: {
@@ -36,6 +36,9 @@ export default function AuthCallback() {
localStorage.setItem('stirling_jwt', token);
console.log('[AuthCallback] JWT stored in localStorage');
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'))
// Refresh session to load user info into state
await refreshSession();
+18 -15
View File
@@ -30,21 +30,24 @@ export default defineConfig(({ mode }) => {
// tell vite to ignore watching `src-tauri`
ignored: ['**/src-tauri/**'],
},
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
'/oauth2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
'/login/oauth2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/oauth2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/login/oauth2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
},
},