Skip to content

Commit a378335

Browse files
paulirishrishikanthc
authored andcommitted
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.
1 parent bccf81d commit a378335

5 files changed

Lines changed: 114 additions & 77 deletions

File tree

web/frontend/src/features/auth/hooks/useAuth.ts

Lines changed: 17 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import { useEffect, useRef, useCallback } from 'react';
22
import { useAuthStore } from '../store/authStore';
3-
4-
declare global {
5-
interface Window {
6-
__scriberr_original_fetch?: typeof window.fetch;
7-
}
8-
}
3+
import { refreshToken, navigateToHome } from '../../../lib/authHelpers';
4+
import '../../../lib/authTypes';
95

106
export function useAuth() {
117
const {
@@ -21,7 +17,6 @@ export function useAuth() {
2117
const isAuthenticated = !!token;
2218

2319
const tokenCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
24-
const fetchWrapperSetupRef = useRef(false);
2520

2621
const getAuthHeaders = useCallback((): Record<string, string> => {
2722
if (token) {
@@ -52,11 +47,7 @@ export function useAuth() {
5247
},
5348
}).catch(() => { });
5449

55-
if (window.location.pathname !== "/") {
56-
// Force navigation handled by RouterContext or window.location if critical
57-
window.history.pushState({ route: { path: 'home' } }, "", "/");
58-
window.dispatchEvent(new PopStateEvent('popstate', { state: { route: { path: 'home' } } }));
59-
}
50+
navigateToHome();
6051
}, [token, storeLogout]);
6152

6253

@@ -65,67 +56,17 @@ export function useAuth() {
6556
setRequiresRegistration(false);
6657
}, [setToken, setRequiresRegistration]);
6758

68-
69-
const tryRefresh = useCallback(async (): Promise<string | null> => {
70-
try {
71-
const fetchToUse = window.__scriberr_original_fetch || window.fetch;
72-
const res = await fetchToUse('/api/v1/auth/refresh', { method: 'POST' })
73-
if (!res.ok) return null
74-
const data = await res.json()
75-
if (data?.token) {
76-
login(data.token)
77-
return data.token as string
78-
}
79-
return null
80-
} catch {
81-
return null
82-
}
83-
}, [login])
84-
85-
86-
// Consolidated token management
8759
useEffect(() => {
88-
if (!fetchWrapperSetupRef.current) {
89-
if (!window.__scriberr_original_fetch) {
90-
window.__scriberr_original_fetch = window.fetch.bind(window);
91-
}
92-
93-
const originalFetch = window.__scriberr_original_fetch!;
94-
const wrappedFetch: typeof window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
95-
const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url);
96-
const isAuthEndpoint = url.includes('/api/v1/auth/');
97-
98-
let res = await originalFetch(input, init);
99-
if (res.status === 401 && !isAuthEndpoint) {
100-
const newToken = await tryRefresh()
101-
if (newToken) {
102-
const newInit: RequestInit = init ? { ...init } : {};
103-
const headers = new Headers(newInit.headers);
104-
headers.set('Authorization', `Bearer ${newToken}`);
105-
newInit.headers = headers;
106-
107-
res = await originalFetch(input, newInit)
108-
if (res.status !== 401) return res
109-
}
110-
logout()
111-
}
112-
return res;
113-
};
114-
window.fetch = wrappedFetch;
115-
fetchWrapperSetupRef.current = true;
116-
// Note: We don't restore originalFetch on unmount because other components
117-
// also use useAuth and expect the wrapped version. This is a bit hacky
118-
// but safer than multiple re-wrapping/unwrapping.
119-
}
120-
12160
if (tokenCheckIntervalRef.current) clearInterval(tokenCheckIntervalRef.current);
12261

12362
if (token) {
12463
const checkTokenExpiry = async () => {
12564
if (!token) return;
12665
if (isTokenExpired(token)) {
127-
const newToken = await tryRefresh();
128-
if (!newToken) logout();
66+
const newToken = await refreshToken();
67+
if (!newToken) {
68+
logout();
69+
}
12970
}
13071
};
13172
tokenCheckIntervalRef.current = setInterval(checkTokenExpiry, 60000);
@@ -135,26 +76,25 @@ export function useAuth() {
13576
return () => {
13677
if (tokenCheckIntervalRef.current) clearInterval(tokenCheckIntervalRef.current);
13778
};
138-
}, [token, isTokenExpired, logout, tryRefresh]);
79+
}, [token, isTokenExpired, logout]);
13980

140-
// Initial check (equivalent to old AuthProvider mount effect)
14181
useEffect(() => {
14282
const initializeAuth = async () => {
143-
if (isInitialized) return; // Don't run if already initialized
83+
if (isInitialized) return;
14484

14585
try {
14686
const response = await fetch("/api/v1/auth/registration-status");
14787
if (response.ok) {
14888
const data = await response.json();
149-
const regEnabled = typeof data.registration_enabled === 'boolean' ? data.registration_enabled : !!data.requiresRegistration;
89+
const regEnabled = typeof data.registration_enabled === 'boolean'
90+
? data.registration_enabled
91+
: !!data.requiresRegistration;
15092
setRequiresRegistration(regEnabled);
15193

152-
if (!regEnabled) {
153-
// Check token validity if present
154-
if (token && isTokenExpired(token)) {
155-
// Try refresh or logout
156-
const Refreshed = await tryRefresh();
157-
if (!Refreshed) logout();
94+
if (!regEnabled && token && isTokenExpired(token)) {
95+
const newToken = await refreshToken();
96+
if (!newToken) {
97+
logout();
15898
}
15999
}
160100
}
@@ -165,7 +105,7 @@ export function useAuth() {
165105
}
166106
};
167107
initializeAuth();
168-
}, [isInitialized, setRequiresRegistration, setInitialized, token, isTokenExpired, tryRefresh, logout]);
108+
}, [isInitialized, setRequiresRegistration, setInitialized, token, isTokenExpired, logout]);
169109

170110
return {
171111
token,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useAuthStore } from '../features/auth/store/authStore';
2+
import './authTypes';
3+
4+
export async function refreshToken(): Promise<string | null> {
5+
const originalFetch = window.__scriberr_original_fetch || window.fetch;
6+
const state = useAuthStore.getState();
7+
8+
try {
9+
const response = await originalFetch('/api/v1/auth/refresh', { method: 'POST' });
10+
if (!response.ok) return null;
11+
12+
const data = await response.json();
13+
if (data?.token) {
14+
state.setToken(data.token);
15+
state.setRequiresRegistration(false);
16+
return data.token;
17+
}
18+
return null;
19+
} catch {
20+
return null;
21+
}
22+
}
23+
24+
export function navigateToHome(): void {
25+
if (window.location.pathname !== "/") {
26+
window.history.pushState({ route: { path: 'home' } }, "", "/");
27+
window.dispatchEvent(new PopStateEvent('popstate', { state: { route: { path: 'home' } } }));
28+
}
29+
}
30+
31+
export function parseRequestUrl(input: RequestInfo | URL): string {
32+
if (typeof input === 'string') return input;
33+
if (input instanceof URL) return input.href;
34+
return input.url;
35+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useAuthStore } from '../features/auth/store/authStore';
2+
import { refreshToken, navigateToHome, parseRequestUrl } from './authHelpers';
3+
import './authTypes';
4+
5+
export function setupAuthInterceptor(): void {
6+
if (window.__scriberr_original_fetch) {
7+
return;
8+
}
9+
10+
const originalFetch = window.fetch.bind(window);
11+
window.__scriberr_original_fetch = originalFetch;
12+
13+
const wrappedFetch: typeof window.fetch = async (input, init) => {
14+
const url = parseRequestUrl(input);
15+
const isAuthEndpoint = url.includes('/api/v1/auth/');
16+
17+
const state = useAuthStore.getState();
18+
const token = state.token;
19+
20+
let requestInit = init || {};
21+
if (token && !isAuthEndpoint) {
22+
const headers = new Headers(requestInit.headers);
23+
if (!headers.has('Authorization')) {
24+
headers.set('Authorization', `Bearer ${token}`);
25+
requestInit = { ...requestInit, headers };
26+
}
27+
}
28+
29+
let response = await originalFetch(input, requestInit);
30+
31+
if (response.status === 401 && !isAuthEndpoint) {
32+
const newToken = await refreshToken();
33+
34+
if (newToken) {
35+
const retryHeaders = new Headers(requestInit.headers);
36+
retryHeaders.set('Authorization', `Bearer ${newToken}`);
37+
const retryInit = { ...requestInit, headers: retryHeaders };
38+
39+
response = await originalFetch(input, retryInit);
40+
if (response.status !== 401) return response;
41+
}
42+
43+
state.logout();
44+
navigateToHome();
45+
}
46+
47+
return response;
48+
};
49+
50+
window.fetch = wrappedFetch;
51+
}

web/frontend/src/lib/authTypes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
declare global {
2+
interface Window {
3+
__scriberr_original_fetch?: typeof window.fetch;
4+
}
5+
}
6+
7+
export {};

web/frontend/src/main.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { ToastProvider } from '@/components/ui/toast'
1313
import { ChatEventsProvider } from './contexts/ChatEventsContext'
1414
import { GlobalUploadProvider } from './contexts/GlobalUploadContext'
1515
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
16+
import { setupAuthInterceptor } from './lib/authInterceptor'
17+
18+
// Initialize the global fetch interceptor for auth
19+
setupAuthInterceptor();
1620

1721
const queryClient = new QueryClient()
1822

0 commit comments

Comments
 (0)