Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 17 additions & 77 deletions web/frontend/src/features/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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]);


Expand All @@ -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);
Expand All @@ -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();
}
}
}
Expand All @@ -165,7 +105,7 @@ export function useAuth() {
}
};
initializeAuth();
}, [isInitialized, setRequiresRegistration, setInitialized, token, isTokenExpired, tryRefresh, logout]);
}, [isInitialized, setRequiresRegistration, setInitialized, token, isTokenExpired, logout]);

return {
token,
Expand Down
35 changes: 35 additions & 0 deletions web/frontend/src/lib/authHelpers.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 51 additions & 0 deletions web/frontend/src/lib/authInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions web/frontend/src/lib/authTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare global {
interface Window {
__scriberr_original_fetch?: typeof window.fetch;
}
}

export {};
4 changes: 4 additions & 0 deletions web/frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading