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.
This commit is contained in:
Paul Irish
2026-03-03 17:14:02 -08:00
committed by Rishikanth Chandrasekaran
parent bccf81d3e6
commit a378335ebd
5 changed files with 114 additions and 77 deletions

View File

@@ -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<NodeJS.Timeout | null>(null);
const fetchWrapperSetupRef = useRef(false);
const getAuthHeaders = useCallback((): Record<string, string> => {
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<string | null> => {
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,

View File

@@ -0,0 +1,35 @@
import { useAuthStore } from '../features/auth/store/authStore';
import './authTypes';
export async function refreshToken(): Promise<string | null> {
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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
declare global {
interface Window {
__scriberr_original_fetch?: typeof window.fetch;
}
}
export {};

View File

@@ -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()