project-standalo-sonic-cloud/app/components/TokenRefreshManager.tsx

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>
);
}