315 lines
8.6 KiB
TypeScript
315 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
import React, { createContext, useContext, useEffect, useState, useRef, ReactNode } from 'react';
|
|
import type { RefreshToken } from '../types/api';
|
|
import { API_PATHS } from '../types/api';
|
|
|
|
interface TokenContextType {
|
|
accessToken: string | null;
|
|
refreshToken: string | null;
|
|
isRefreshing: boolean;
|
|
refreshError: string | null;
|
|
forceRefresh: () => Promise<void>;
|
|
clearTokens: () => void;
|
|
}
|
|
|
|
const TokenContext = createContext<TokenContextType | null>(null);
|
|
|
|
interface TokenRefreshManagerProps {
|
|
children: ReactNode;
|
|
autoRefresh?: boolean;
|
|
refreshThreshold?: number; // seconds before expiration to refresh
|
|
onTokenExpired?: () => void;
|
|
onRefreshFailed?: (error: string) => void;
|
|
}
|
|
|
|
export function TokenRefreshManager({
|
|
children,
|
|
autoRefresh = true,
|
|
refreshThreshold = 300, // 5 minutes
|
|
onTokenExpired,
|
|
onRefreshFailed,
|
|
}: TokenRefreshManagerProps) {
|
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
|
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [refreshError, setRefreshError] = useState<string | null>(null);
|
|
const [tokenExpiration, setTokenExpiration] = useState<Date | null>(null);
|
|
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Load tokens from storage on mount
|
|
useEffect(() => {
|
|
const loadTokens = () => {
|
|
try {
|
|
const storedAccess = localStorage.getItem('access_token');
|
|
const storedRefresh = localStorage.getItem('refresh_token');
|
|
const storedExpiration = localStorage.getItem('token_expiration');
|
|
|
|
if (storedAccess) {
|
|
setAccessToken(storedAccess);
|
|
}
|
|
|
|
if (storedRefresh) {
|
|
setRefreshToken(storedRefresh);
|
|
}
|
|
|
|
if (storedExpiration) {
|
|
setTokenExpiration(new Date(storedExpiration));
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load tokens from storage:', error);
|
|
}
|
|
};
|
|
|
|
loadTokens();
|
|
}, []);
|
|
|
|
// Save tokens to storage
|
|
const saveTokens = (access: string, refresh: string, expiration: Date) => {
|
|
try {
|
|
localStorage.setItem('access_token', access);
|
|
localStorage.setItem('refresh_token', refresh);
|
|
localStorage.setItem('token_expiration', expiration.toISOString());
|
|
} catch (error) {
|
|
console.error('Failed to save tokens to storage:', error);
|
|
}
|
|
};
|
|
|
|
// Clear tokens from storage
|
|
const clearTokensFromStorage = () => {
|
|
try {
|
|
localStorage.removeItem('access_token');
|
|
localStorage.removeItem('refresh_token');
|
|
localStorage.removeItem('token_expiration');
|
|
} catch (error) {
|
|
console.error('Failed to clear tokens from storage:', error);
|
|
}
|
|
};
|
|
|
|
// Clear tokens (logout)
|
|
const clearTokens = () => {
|
|
setAccessToken(null);
|
|
setRefreshToken(null);
|
|
setTokenExpiration(null);
|
|
setRefreshError(null);
|
|
clearTokensFromStorage();
|
|
|
|
if (refreshTimeoutRef.current) {
|
|
clearTimeout(refreshTimeoutRef.current);
|
|
}
|
|
};
|
|
|
|
// Refresh access token
|
|
const refreshAccessToken = async (): Promise<boolean> => {
|
|
if (!refreshToken) {
|
|
setRefreshError('No refresh token available');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
setIsRefreshing(true);
|
|
setRefreshError(null);
|
|
|
|
const response = await fetch(API_PATHS.AUTH_REFRESH, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
refreshToken,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
// Refresh token is invalid/expired
|
|
clearTokens();
|
|
onTokenExpired?.();
|
|
return false;
|
|
}
|
|
throw new Error('Token refresh failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Update tokens
|
|
setAccessToken(data.accessToken);
|
|
setRefreshToken(data.refreshToken || refreshToken);
|
|
|
|
const expiration = data.expiresAt
|
|
? new Date(data.expiresAt)
|
|
: new Date(Date.now() + 3600 * 1000); // Default 1 hour
|
|
|
|
setTokenExpiration(expiration);
|
|
saveTokens(data.accessToken, data.refreshToken || refreshToken, expiration);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
setRefreshError(errorMessage);
|
|
onRefreshFailed?.(errorMessage);
|
|
|
|
// Don't clear tokens on network error, but clear on auth error
|
|
if (!navigator.onLine) {
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
// Force refresh
|
|
const forceRefresh = async () => {
|
|
await refreshAccessToken();
|
|
};
|
|
|
|
// Set up auto-refresh
|
|
useEffect(() => {
|
|
if (!autoRefresh || !tokenExpiration || !refreshToken) {
|
|
return;
|
|
}
|
|
|
|
const scheduleRefresh = () => {
|
|
if (refreshTimeoutRef.current) {
|
|
clearTimeout(refreshTimeoutRef.current);
|
|
}
|
|
|
|
const now = new Date();
|
|
const timeUntilExpiration = tokenExpiration.getTime() - now.getTime();
|
|
const refreshTime = timeUntilExpiration - refreshThreshold * 1000;
|
|
|
|
if (refreshTime <= 0) {
|
|
// Token is already expired or will expire soon
|
|
refreshAccessToken();
|
|
} else {
|
|
// Schedule refresh
|
|
refreshTimeoutRef.current = setTimeout(() => {
|
|
refreshAccessToken();
|
|
}, refreshTime);
|
|
}
|
|
};
|
|
|
|
scheduleRefresh();
|
|
|
|
return () => {
|
|
if (refreshTimeoutRef.current) {
|
|
clearTimeout(refreshTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [autoRefresh, tokenExpiration, refreshToken, refreshThreshold]);
|
|
|
|
// Add token to API requests
|
|
useEffect(() => {
|
|
const originalFetch = window.fetch;
|
|
|
|
window.fetch = async (input, init) => {
|
|
let requestInit = init || {};
|
|
|
|
// Add auth header if we have a token
|
|
if (accessToken && !requestInit.headers) {
|
|
requestInit.headers = {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
};
|
|
} else if (accessToken && requestInit.headers) {
|
|
(requestInit.headers as Record<string, string>)['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
try {
|
|
const response = await originalFetch(input, requestInit);
|
|
|
|
// Handle 401 responses (token expired)
|
|
if (response.status === 401 && accessToken) {
|
|
// Try to refresh the token
|
|
const refreshed = await refreshAccessToken();
|
|
|
|
if (refreshed) {
|
|
// Retry the request with new token
|
|
const retryInit = {
|
|
...requestInit,
|
|
headers: {
|
|
...(requestInit.headers as Record<string, string>),
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
},
|
|
};
|
|
return originalFetch(input, retryInit);
|
|
} else {
|
|
// Refresh failed, redirect to login
|
|
clearTokens();
|
|
onTokenExpired?.();
|
|
}
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
// Handle network errors
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
return () => {
|
|
window.fetch = originalFetch;
|
|
};
|
|
}, [accessToken]);
|
|
|
|
const contextValue: TokenContextType = {
|
|
accessToken,
|
|
refreshToken,
|
|
isRefreshing,
|
|
refreshError,
|
|
forceRefresh,
|
|
clearTokens,
|
|
};
|
|
|
|
return (
|
|
<TokenContext.Provider value={contextValue}>
|
|
{children}
|
|
</TokenContext.Provider>
|
|
);
|
|
}
|
|
|
|
// Hook to use token context
|
|
export function useAuth() {
|
|
const context = useContext(TokenContext);
|
|
if (!context) {
|
|
throw new Error('useAuth must be used within a TokenRefreshManager');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
// Component to show refresh status (for debugging)
|
|
export function TokenStatus() {
|
|
const { accessToken, isRefreshing, refreshError, forceRefresh } = useAuth();
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
|
|
<h3 className="font-semibold mb-2">Auth Status</h3>
|
|
|
|
<div className="space-y-1 text-sm">
|
|
<p>
|
|
<span className="font-medium">Token:</span>{' '}
|
|
<span className={accessToken ? 'text-green-600' : 'text-red-600'}>
|
|
{accessToken ? 'Present' : 'Missing'}
|
|
</span>
|
|
</p>
|
|
|
|
<p>
|
|
<span className="font-medium">Status:</span>{' '}
|
|
{isRefreshing ? 'Refreshing...' : 'Idle'}
|
|
</p>
|
|
|
|
{refreshError && (
|
|
<p className="text-red-600">Error: {refreshError}</p>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={forceRefresh}
|
|
className="mt-2 px-3 py-1 bg-blue-100 hover:bg-blue-200 text-blue-700 text-sm rounded transition-colors"
|
|
>
|
|
Force Refresh
|
|
</button>
|
|
</div>
|
|
);
|
|
} |