fix auth reload

This commit is contained in:
David 2025-11-05 22:49:25 +01:00
parent 0ac5b589e8
commit a9bbbede4a
3 changed files with 231 additions and 14 deletions

View File

@ -21,8 +21,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: '📊' }, { name: 'Dashboard', href: '/dashboard', icon: '📊' },
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' }, { name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
{ name: 'Search Rates', href: '/dashboard/search', icon: '🔍' }, { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' },
{ name: 'Search Advanced', href: '/dashboard/search-advanced', icon: '🔎' },
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' }, { name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' }, { name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' }, { name: 'Users', href: '/dashboard/settings/users', icon: '👥' },

View File

@ -6,6 +6,10 @@
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; 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 * Get authentication token from localStorage
*/ */
@ -38,6 +42,61 @@ export function clearAuthTokens(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
localStorage.removeItem('refresh_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<string> {
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> { export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {},
isRetry = false
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`; const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
@ -101,6 +164,73 @@ export async function apiRequest<T>(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<T>(
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<T>(
endpoint,
{
...options,
headers: newHeaders,
},
true
);
resolve(result);
} catch (error) {
reject(error);
}
});
});
}
} catch (refreshError) {
isRefreshing = false;
refreshSubscribers = [];
throw refreshError;
}
}
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})); const error = await response.json().catch(() => ({}));
throw new ApiError( throw new ApiError(
@ -176,6 +306,42 @@ export async function upload<T>(
body: formData, 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) { if (!response.ok) {
const error = await response.json().catch(() => ({})); const error = await response.json().catch(() => ({}));
throw new ApiError( throw new ApiError(
@ -203,6 +369,45 @@ export async function download(
headers: createHeaders(includeAuth), 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) { if (!response.ok) {
throw new ApiError(`Download failed: ${response.statusText}`, response.status); throw new ApiError(`Download failed: ${response.statusText}`, response.status);
} }

View File

@ -57,23 +57,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
if (isAuthenticated()) { if (isAuthenticated()) {
// Try to fetch current user from API // Try to fetch current user from API (will auto-refresh token if expired)
try { try {
const currentUser = await getCurrentUser(); const currentUser = await getCurrentUser();
setUser(currentUser); setUser(currentUser);
// Update stored user // Update stored user
localStorage.setItem('user', JSON.stringify(currentUser)); localStorage.setItem('user', JSON.stringify(currentUser));
} catch (apiError) { } catch (apiError) {
console.error('Failed to fetch user from API, checking localStorage:', apiError); console.error('Failed to fetch user from API:', apiError);
// If API fails, try to use stored user as fallback // If API fails after token refresh attempt, clear everything
const storedUser = getStoredUser(); if (typeof window !== 'undefined') {
if (storedUser) { localStorage.removeItem('access_token');
console.log('Using stored user as fallback:', storedUser); localStorage.removeItem('refresh_token');
setUser(storedUser); localStorage.removeItem('user');
} else {
// No stored user and API failed - clear everything
throw apiError;
} }
setUser(null);
} }
} }
} catch (error) { } catch (error) {
@ -91,6 +89,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}; };
checkAuth(); 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) => { const login = async (email: string, password: string) => {