From 5a66f9dd99a0fe2b291aa10ad035c49c90af130e Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Tue, 3 Mar 2026 17:14:02 -0800 Subject: [PATCH] refactor(frontend): extract auth logic to helpers and interceptor needed because I was adding a new SpeakerSettings component but the useAuth hook triggered an infinite recusion bug because of the window.fetch wrappings. --- .../src/features/auth/hooks/useAuth.ts | 94 ++++--------------- web/frontend/src/lib/authHelpers.ts | 35 +++++++ web/frontend/src/lib/authInterceptor.ts | 51 ++++++++++ web/frontend/src/lib/authTypes.ts | 7 ++ web/frontend/src/main.tsx | 4 + 5 files changed, 114 insertions(+), 77 deletions(-) create mode 100644 web/frontend/src/lib/authHelpers.ts create mode 100644 web/frontend/src/lib/authInterceptor.ts create mode 100644 web/frontend/src/lib/authTypes.ts diff --git a/web/frontend/src/features/auth/hooks/useAuth.ts b/web/frontend/src/features/auth/hooks/useAuth.ts index 63fcd09c3..4be84e3b4 100644 --- a/web/frontend/src/features/auth/hooks/useAuth.ts +++ b/web/frontend/src/features/auth/hooks/useAuth.ts @@ -1,11 +1,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useAuthStore } from '../store/authStore'; - -declare global { - interface Window { - __scriberr_original_fetch?: typeof window.fetch; - } -} +import { refreshToken, navigateToHome } from '../../../lib/authHelpers'; +import '../../../lib/authTypes'; export function useAuth() { const { @@ -21,7 +17,6 @@ export function useAuth() { const isAuthenticated = !!token; const tokenCheckIntervalRef = useRef(null); - const fetchWrapperSetupRef = useRef(false); const getAuthHeaders = useCallback((): Record => { if (token) { @@ -52,11 +47,7 @@ export function useAuth() { }, }).catch(() => { }); - if (window.location.pathname !== "/") { - // Force navigation handled by RouterContext or window.location if critical - window.history.pushState({ route: { path: 'home' } }, "", "/"); - window.dispatchEvent(new PopStateEvent('popstate', { state: { route: { path: 'home' } } })); - } + navigateToHome(); }, [token, storeLogout]); @@ -65,67 +56,17 @@ export function useAuth() { setRequiresRegistration(false); }, [setToken, setRequiresRegistration]); - - const tryRefresh = useCallback(async (): Promise => { - try { - const fetchToUse = window.__scriberr_original_fetch || window.fetch; - const res = await fetchToUse('/api/v1/auth/refresh', { method: 'POST' }) - if (!res.ok) return null - const data = await res.json() - if (data?.token) { - login(data.token) - return data.token as string - } - return null - } catch { - return null - } - }, [login]) - - - // Consolidated token management useEffect(() => { - if (!fetchWrapperSetupRef.current) { - if (!window.__scriberr_original_fetch) { - window.__scriberr_original_fetch = window.fetch.bind(window); - } - - const originalFetch = window.__scriberr_original_fetch!; - const wrappedFetch: typeof window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url); - const isAuthEndpoint = url.includes('/api/v1/auth/'); - - let res = await originalFetch(input, init); - if (res.status === 401 && !isAuthEndpoint) { - const newToken = await tryRefresh() - if (newToken) { - const newInit: RequestInit = init ? { ...init } : {}; - const headers = new Headers(newInit.headers); - headers.set('Authorization', `Bearer ${newToken}`); - newInit.headers = headers; - - res = await originalFetch(input, newInit) - if (res.status !== 401) return res - } - logout() - } - return res; - }; - window.fetch = wrappedFetch; - fetchWrapperSetupRef.current = true; - // Note: We don't restore originalFetch on unmount because other components - // also use useAuth and expect the wrapped version. This is a bit hacky - // but safer than multiple re-wrapping/unwrapping. - } - if (tokenCheckIntervalRef.current) clearInterval(tokenCheckIntervalRef.current); if (token) { const checkTokenExpiry = async () => { if (!token) return; if (isTokenExpired(token)) { - const newToken = await tryRefresh(); - if (!newToken) logout(); + const newToken = await refreshToken(); + if (!newToken) { + logout(); + } } }; tokenCheckIntervalRef.current = setInterval(checkTokenExpiry, 60000); @@ -135,26 +76,25 @@ export function useAuth() { return () => { if (tokenCheckIntervalRef.current) clearInterval(tokenCheckIntervalRef.current); }; - }, [token, isTokenExpired, logout, tryRefresh]); + }, [token, isTokenExpired, logout]); - // Initial check (equivalent to old AuthProvider mount effect) useEffect(() => { const initializeAuth = async () => { - if (isInitialized) return; // Don't run if already initialized + if (isInitialized) return; try { const response = await fetch("/api/v1/auth/registration-status"); if (response.ok) { const data = await response.json(); - const regEnabled = typeof data.registration_enabled === 'boolean' ? data.registration_enabled : !!data.requiresRegistration; + const regEnabled = typeof data.registration_enabled === 'boolean' + ? data.registration_enabled + : !!data.requiresRegistration; setRequiresRegistration(regEnabled); - if (!regEnabled) { - // Check token validity if present - if (token && isTokenExpired(token)) { - // Try refresh or logout - const Refreshed = await tryRefresh(); - if (!Refreshed) logout(); + if (!regEnabled && token && isTokenExpired(token)) { + const newToken = await refreshToken(); + if (!newToken) { + logout(); } } } @@ -165,7 +105,7 @@ export function useAuth() { } }; initializeAuth(); - }, [isInitialized, setRequiresRegistration, setInitialized, token, isTokenExpired, tryRefresh, logout]); + }, [isInitialized, setRequiresRegistration, setInitialized, token, isTokenExpired, logout]); return { token, diff --git a/web/frontend/src/lib/authHelpers.ts b/web/frontend/src/lib/authHelpers.ts new file mode 100644 index 000000000..d67a2e59e --- /dev/null +++ b/web/frontend/src/lib/authHelpers.ts @@ -0,0 +1,35 @@ +import { useAuthStore } from '../features/auth/store/authStore'; +import './authTypes'; + +export async function refreshToken(): Promise { + const originalFetch = window.__scriberr_original_fetch || window.fetch; + const state = useAuthStore.getState(); + + try { + const response = await originalFetch('/api/v1/auth/refresh', { method: 'POST' }); + if (!response.ok) return null; + + const data = await response.json(); + if (data?.token) { + state.setToken(data.token); + state.setRequiresRegistration(false); + return data.token; + } + return null; + } catch { + return null; + } +} + +export function navigateToHome(): void { + if (window.location.pathname !== "/") { + window.history.pushState({ route: { path: 'home' } }, "", "/"); + window.dispatchEvent(new PopStateEvent('popstate', { state: { route: { path: 'home' } } })); + } +} + +export function parseRequestUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + return input.url; +} diff --git a/web/frontend/src/lib/authInterceptor.ts b/web/frontend/src/lib/authInterceptor.ts new file mode 100644 index 000000000..3334b95f5 --- /dev/null +++ b/web/frontend/src/lib/authInterceptor.ts @@ -0,0 +1,51 @@ +import { useAuthStore } from '../features/auth/store/authStore'; +import { refreshToken, navigateToHome, parseRequestUrl } from './authHelpers'; +import './authTypes'; + +export function setupAuthInterceptor(): void { + if (window.__scriberr_original_fetch) { + return; + } + + const originalFetch = window.fetch.bind(window); + window.__scriberr_original_fetch = originalFetch; + + const wrappedFetch: typeof window.fetch = async (input, init) => { + const url = parseRequestUrl(input); + const isAuthEndpoint = url.includes('/api/v1/auth/'); + + const state = useAuthStore.getState(); + const token = state.token; + + let requestInit = init || {}; + if (token && !isAuthEndpoint) { + const headers = new Headers(requestInit.headers); + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + requestInit = { ...requestInit, headers }; + } + } + + let response = await originalFetch(input, requestInit); + + if (response.status === 401 && !isAuthEndpoint) { + const newToken = await refreshToken(); + + if (newToken) { + const retryHeaders = new Headers(requestInit.headers); + retryHeaders.set('Authorization', `Bearer ${newToken}`); + const retryInit = { ...requestInit, headers: retryHeaders }; + + response = await originalFetch(input, retryInit); + if (response.status !== 401) return response; + } + + state.logout(); + navigateToHome(); + } + + return response; + }; + + window.fetch = wrappedFetch; +} diff --git a/web/frontend/src/lib/authTypes.ts b/web/frontend/src/lib/authTypes.ts new file mode 100644 index 000000000..c47bd1a7e --- /dev/null +++ b/web/frontend/src/lib/authTypes.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + __scriberr_original_fetch?: typeof window.fetch; + } +} + +export {}; diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx index ffd3d8c70..1732b6656 100644 --- a/web/frontend/src/main.tsx +++ b/web/frontend/src/main.tsx @@ -13,6 +13,10 @@ import { ToastProvider } from '@/components/ui/toast' import { ChatEventsProvider } from './contexts/ChatEventsContext' import { GlobalUploadProvider } from './contexts/GlobalUploadContext' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { setupAuthInterceptor } from './lib/authInterceptor' + +// Initialize the global fetch interceptor for auth +setupAuthInterceptor(); const queryClient = new QueryClient()