mirror of
https://github.com/rishikanthc/Scriberr.git
synced 2026-06-27 22:36:07 +00:00
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:
committed by
Rishikanth Chandrasekaran
parent
bccf81d3e6
commit
a378335ebd
@@ -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,
|
||||
|
||||
35
web/frontend/src/lib/authHelpers.ts
Normal file
35
web/frontend/src/lib/authHelpers.ts
Normal 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;
|
||||
}
|
||||
51
web/frontend/src/lib/authInterceptor.ts
Normal file
51
web/frontend/src/lib/authInterceptor.ts
Normal 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;
|
||||
}
|
||||
7
web/frontend/src/lib/authTypes.ts
Normal file
7
web/frontend/src/lib/authTypes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__scriberr_original_fetch?: typeof window.fetch;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user