From a9bbbede4ac940b759c626e96c5da4f564ee2ac8 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 5 Nov 2025 22:49:25 +0100 Subject: [PATCH] fix auth reload --- apps/frontend/app/dashboard/layout.tsx | 3 +- apps/frontend/src/lib/api/client.ts | 209 +++++++++++++++++- .../frontend/src/lib/context/auth-context.tsx | 33 ++- 3 files changed, 231 insertions(+), 14 deletions(-) diff --git a/apps/frontend/app/dashboard/layout.tsx b/apps/frontend/app/dashboard/layout.tsx index 31c1cc7..c1119e9 100644 --- a/apps/frontend/app/dashboard/layout.tsx +++ b/apps/frontend/app/dashboard/layout.tsx @@ -21,8 +21,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: '📊' }, { name: 'Bookings', href: '/dashboard/bookings', icon: '📦' }, - { name: 'Search Rates', href: '/dashboard/search', icon: '🔍' }, - { name: 'Search Advanced', href: '/dashboard/search-advanced', icon: '🔎' }, + { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' }, { name: 'My Profile', href: '/dashboard/profile', icon: '👤' }, { name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' }, { name: 'Users', href: '/dashboard/settings/users', icon: '👥' }, diff --git a/apps/frontend/src/lib/api/client.ts b/apps/frontend/src/lib/api/client.ts index ca39ff1..e1abed0 100644 --- a/apps/frontend/src/lib/api/client.ts +++ b/apps/frontend/src/lib/api/client.ts @@ -6,6 +6,10 @@ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; +// Track if we're currently refreshing to avoid multiple simultaneous refresh requests +let isRefreshing = false; +let refreshSubscribers: Array<(token: string) => void> = []; + /** * Get authentication token from localStorage */ @@ -38,6 +42,61 @@ export function clearAuthTokens(): void { if (typeof window === 'undefined') return; localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); +} + +/** + * Add subscriber to be notified when token is refreshed + */ +function subscribeTokenRefresh(callback: (token: string) => void): void { + refreshSubscribers.push(callback); +} + +/** + * Notify all subscribers that token has been refreshed + */ +function onTokenRefreshed(token: string): void { + refreshSubscribers.forEach(callback => callback(token)); + refreshSubscribers = []; +} + +/** + * Refresh access token using refresh token + */ +async function refreshAccessToken(): Promise { + const refreshToken = getRefreshToken(); + + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) { + // Refresh token invalid or expired, clear everything + clearAuthTokens(); + // Redirect to login + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + throw new Error('Failed to refresh token'); + } + + const data = await response.json(); + const newAccessToken = data.accessToken; + + // Update access token in localStorage (keep same refresh token) + if (typeof window !== 'undefined') { + localStorage.setItem('access_token', newAccessToken); + } + + return newAccessToken; } /** @@ -89,9 +148,13 @@ export class ApiError extends Error { } /** - * Make API request + * Make API request with automatic token refresh on 401 */ -export async function apiRequest(endpoint: string, options: RequestInit = {}): Promise { +export async function apiRequest( + endpoint: string, + options: RequestInit = {}, + isRetry = false +): Promise { const url = `${API_BASE_URL}${endpoint}`; const response = await fetch(url, { @@ -101,6 +164,73 @@ export async function apiRequest(endpoint: string, options: RequestInit = {}) }, }); + // Handle 401 Unauthorized - token expired + if (response.status === 401 && !isRetry && !endpoint.includes('/auth/refresh')) { + // Check if we have a refresh token + const refreshToken = getRefreshToken(); + if (!refreshToken) { + // No refresh token, redirect to login + clearAuthTokens(); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + throw new ApiError('Session expired', 401); + } + + // Try to refresh the token + try { + if (!isRefreshing) { + isRefreshing = true; + const newAccessToken = await refreshAccessToken(); + isRefreshing = false; + onTokenRefreshed(newAccessToken); + + // Retry the original request with new token + const newHeaders = { ...options.headers }; + if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) { + (newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`; + } + + return apiRequest( + endpoint, + { + ...options, + headers: newHeaders, + }, + true + ); + } else { + // Already refreshing, wait for the new token + return new Promise((resolve, reject) => { + subscribeTokenRefresh(async (newAccessToken: string) => { + const newHeaders = { ...options.headers }; + if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) { + (newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`; + } + + try { + const result = await apiRequest( + endpoint, + { + ...options, + headers: newHeaders, + }, + true + ); + resolve(result); + } catch (error) { + reject(error); + } + }); + }); + } + } catch (refreshError) { + isRefreshing = false; + refreshSubscribers = []; + throw refreshError; + } + } + if (!response.ok) { const error = await response.json().catch(() => ({})); throw new ApiError( @@ -176,6 +306,42 @@ export async function upload( body: formData, }); + // Handle 401 Unauthorized for file uploads + if (response.status === 401) { + const refreshToken = getRefreshToken(); + if (refreshToken) { + try { + const newAccessToken = await refreshAccessToken(); + // Retry upload with new token + const retryResponse = await fetch(url, { + method: 'POST', + headers: { + ...createMultipartHeaders(includeAuth), + Authorization: `Bearer ${newAccessToken}`, + }, + body: formData, + }); + + if (!retryResponse.ok) { + const error = await retryResponse.json().catch(() => ({})); + throw new ApiError( + error.message || `Upload failed: ${retryResponse.statusText}`, + retryResponse.status, + error + ); + } + + return retryResponse.json(); + } catch (refreshError) { + clearAuthTokens(); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + throw refreshError; + } + } + } + if (!response.ok) { const error = await response.json().catch(() => ({})); throw new ApiError( @@ -203,6 +369,45 @@ export async function download( headers: createHeaders(includeAuth), }); + // Handle 401 Unauthorized for downloads + if (response.status === 401) { + const refreshToken = getRefreshToken(); + if (refreshToken) { + try { + const newAccessToken = await refreshAccessToken(); + // Retry download with new token + const retryResponse = await fetch(url, { + method: 'GET', + headers: { + ...createHeaders(includeAuth), + Authorization: `Bearer ${newAccessToken}`, + }, + }); + + if (!retryResponse.ok) { + throw new ApiError(`Download failed: ${retryResponse.statusText}`, retryResponse.status); + } + + const blob = await retryResponse.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + return; + } catch (refreshError) { + clearAuthTokens(); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + throw refreshError; + } + } + } + if (!response.ok) { throw new ApiError(`Download failed: ${response.statusText}`, response.status); } diff --git a/apps/frontend/src/lib/context/auth-context.tsx b/apps/frontend/src/lib/context/auth-context.tsx index 4714272..2431eb6 100644 --- a/apps/frontend/src/lib/context/auth-context.tsx +++ b/apps/frontend/src/lib/context/auth-context.tsx @@ -57,23 +57,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const checkAuth = async () => { try { if (isAuthenticated()) { - // Try to fetch current user from API + // Try to fetch current user from API (will auto-refresh token if expired) try { const currentUser = await getCurrentUser(); setUser(currentUser); // Update stored user localStorage.setItem('user', JSON.stringify(currentUser)); } catch (apiError) { - console.error('Failed to fetch user from API, checking localStorage:', apiError); - // If API fails, try to use stored user as fallback - const storedUser = getStoredUser(); - if (storedUser) { - console.log('Using stored user as fallback:', storedUser); - setUser(storedUser); - } else { - // No stored user and API failed - clear everything - throw apiError; + console.error('Failed to fetch user from API:', apiError); + // If API fails after token refresh attempt, clear everything + if (typeof window !== 'undefined') { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); } + setUser(null); } } } catch (error) { @@ -91,6 +89,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; checkAuth(); + + // Check token validity every 5 minutes and refresh if needed + const tokenCheckInterval = setInterval(async () => { + if (isAuthenticated()) { + try { + // This will automatically refresh the token if it's expired (via API client) + await getCurrentUser(); + } catch (error) { + console.error('Token validation failed:', error); + // If token refresh fails, user will be redirected to login by the API client + } + } + }, 5 * 60 * 1000); // 5 minutes + + return () => clearInterval(tokenCheckInterval); }, []); const login = async (email: string, password: string) => {