@@ -1,375 +0,0 @@
|
||||
/**
|
||||
* API Service - Centralized API client with robust auth handling
|
||||
*
|
||||
* DESIGN PRINCIPLES:
|
||||
* 1. Only logout on explicit authentication failures (not permission/network errors)
|
||||
* 2. Refresh token deduplication - one refresh at a time
|
||||
* 3. Multi-tab coordination via BroadcastChannel
|
||||
* 4. Store access token in memory only (Zustand state)
|
||||
* 5. Automatic retry on network failures
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { trackLogout } from './logoutTracker';
|
||||
|
||||
// ===== TOKEN REFRESH DEDUPLICATION =====
|
||||
// Ensure only one refresh operation happens at a time across all API calls
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
// ===== MULTI-TAB COORDINATION =====
|
||||
// Use BroadcastChannel to sync token refresh across tabs
|
||||
let tokenSyncChannel: BroadcastChannel | null = null;
|
||||
try {
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
tokenSyncChannel = new BroadcastChannel('auth_token_sync');
|
||||
tokenSyncChannel.onmessage = (event) => {
|
||||
if (event.data.type === 'TOKEN_REFRESHED') {
|
||||
// Another tab refreshed the token - update our state
|
||||
const { setToken } = useAuthStore.getState();
|
||||
setToken(event.data.accessToken);
|
||||
} else if (event.data.type === 'LOGOUT') {
|
||||
// Another tab logged out - sync logout
|
||||
const { logout } = useAuthStore.getState();
|
||||
logout();
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('BroadcastChannel not available:', e);
|
||||
}
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const envUrl = import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_API_URL;
|
||||
if (envUrl) {
|
||||
return envUrl.endsWith('/api') ? envUrl : `${envUrl}/api`;
|
||||
}
|
||||
|
||||
const origin = window.location.origin;
|
||||
if (origin.includes('localhost') || origin.includes('127.0.0.1') || /^\d+\.\d+\.\d+\.\d+/.test(origin)) {
|
||||
if (origin.includes(':3000')) return origin.replace(':3000', ':8011') + '/api';
|
||||
if (origin.includes(':7921')) return origin.replace(':7921', ':7911') + '/api';
|
||||
return origin.split(':')[0] + ':8011/api';
|
||||
}
|
||||
|
||||
return 'https://api.igny8.com/api';
|
||||
}
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
// Get auth token from Zustand store (memory only - not localStorage)
|
||||
const getAuthToken = (): string | null => {
|
||||
try {
|
||||
const authState = useAuthStore.getState();
|
||||
return authState?.token || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get refresh token from Zustand store (persisted via middleware)
|
||||
const getRefreshToken = (): string | null => {
|
||||
try {
|
||||
const authState = useAuthStore.getState();
|
||||
return authState?.refreshToken || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh access token with deduplication
|
||||
* Returns new access token or null if refresh failed
|
||||
*/
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
// If a refresh is already in progress, wait for it
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// Start new refresh operation
|
||||
refreshPromise = (async () => {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/auth/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh: refreshToken }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const accessToken = data.data?.access || data.access;
|
||||
const newRefreshToken = data.data?.refresh || data.refresh;
|
||||
|
||||
if (data.success && accessToken) {
|
||||
// Update tokens in Zustand store
|
||||
const { setToken } = useAuthStore.getState();
|
||||
setToken(accessToken);
|
||||
|
||||
// Update refresh token if server returned a new one (rotation)
|
||||
if (newRefreshToken) {
|
||||
useAuthStore.setState({ refreshToken: newRefreshToken });
|
||||
}
|
||||
|
||||
// Broadcast to other tabs
|
||||
if (tokenSyncChannel) {
|
||||
tokenSyncChannel.postMessage({
|
||||
type: 'TOKEN_REFRESHED',
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken
|
||||
});
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh failed with explicit auth error
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) {
|
||||
// Refresh token is invalid - this is the ONLY case where we logout
|
||||
console.warn('Refresh token invalid or expired - logging out');
|
||||
|
||||
// Track the logout
|
||||
trackLogout(
|
||||
'Refresh token invalid or expired (401 on refresh)',
|
||||
'REFRESH_FAILED',
|
||||
{ errorData, refreshEndpoint: '/v1/auth/refresh/' }
|
||||
);
|
||||
|
||||
const { logout } = useAuthStore.getState();
|
||||
logout('Refresh token invalid or expired', 'REFRESH_FAILED');
|
||||
|
||||
// Broadcast logout to other tabs
|
||||
if (tokenSyncChannel) {
|
||||
tokenSyncChannel.postMessage({ type: 'LOGOUT' });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Network error during refresh - don't logout, just return null
|
||||
console.debug('Token refresh failed (network error):', error);
|
||||
|
||||
// Log but don't track as logout (just a failed refresh attempt)
|
||||
console.warn('[TOKEN-REFRESH] Failed due to network error, will retry on next request');
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
// Clear the refresh promise
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from API error
|
||||
*/
|
||||
export function getUserFriendlyError(error: any, fallback: string = 'An error occurred. Please try again.'): string {
|
||||
const message = error?.message || error?.error || fallback;
|
||||
|
||||
if (message.includes('limit exceeded') ||
|
||||
message.includes('not found') ||
|
||||
message.includes('already exists') ||
|
||||
message.includes('invalid') ||
|
||||
message.includes('required') ||
|
||||
message.includes('permission') ||
|
||||
message.includes('upgrade')) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API fetch function with robust error handling
|
||||
*
|
||||
* ERROR HANDLING POLICY:
|
||||
* - 401: Try to refresh token, retry request. Logout only if refresh fails.
|
||||
* - 403: NEVER logout - could be permission/plan error, not auth error
|
||||
* - 402: NEVER logout - payment/plan issue, not auth error
|
||||
* - 5xx: NEVER logout - server error, not auth error
|
||||
* - Network errors: NEVER logout - temporary issue
|
||||
*/
|
||||
export async function fetchAPI(endpoint: string, options?: RequestInit & { timeout?: number }) {
|
||||
const timeout = options?.timeout || 30000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
...options,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const text = await response.text();
|
||||
|
||||
// SUCCESS - parse and return
|
||||
if (response.ok) {
|
||||
if (text && text.trim()) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// === 401 UNAUTHORIZED - Try token refresh ===
|
||||
if (response.status === 401) {
|
||||
// Try to refresh the token
|
||||
const newToken = await refreshAccessToken();
|
||||
|
||||
if (newToken) {
|
||||
// Retry original request with new token
|
||||
const retryHeaders = {
|
||||
...headers,
|
||||
'Authorization': `Bearer ${newToken}`,
|
||||
};
|
||||
|
||||
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: retryHeaders,
|
||||
credentials: 'include',
|
||||
...options,
|
||||
});
|
||||
|
||||
const retryText = await retryResponse.text();
|
||||
|
||||
if (retryResponse.ok) {
|
||||
if (retryText && retryText.trim()) {
|
||||
try {
|
||||
return JSON.parse(retryText);
|
||||
} catch {
|
||||
return retryText;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Retry also failed - throw the retry error
|
||||
const retryError: any = new Error(retryResponse.statusText);
|
||||
retryError.status = retryResponse.status;
|
||||
try {
|
||||
const retryErrorData = JSON.parse(retryText);
|
||||
retryError.message = retryErrorData.error || retryErrorData.message || retryResponse.statusText;
|
||||
retryError.data = retryErrorData;
|
||||
} catch {
|
||||
retryError.message = retryText.substring(0, 200) || retryResponse.statusText;
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
|
||||
// Refresh failed - throw 401 error but DON'T logout here
|
||||
// Logout already happened in refreshAccessToken if needed
|
||||
const err: any = new Error('Authentication required');
|
||||
err.status = 401;
|
||||
err.data = text ? JSON.parse(text).catch(() => ({})) : {};
|
||||
throw err;
|
||||
}
|
||||
|
||||
// === 403 FORBIDDEN - NEVER logout ===
|
||||
// This could be permission error, plan restriction, or entitlement issue
|
||||
// NOT an authentication failure
|
||||
if (response.status === 403) {
|
||||
let errorData: any = {};
|
||||
try {
|
||||
errorData = JSON.parse(text);
|
||||
} catch {}
|
||||
|
||||
const errorMessage = errorData?.detail || errorData?.message || errorData?.error || response.statusText;
|
||||
const err: any = new Error(errorMessage);
|
||||
err.status = 403;
|
||||
err.data = errorData;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// === 402 PAYMENT REQUIRED - NEVER logout ===
|
||||
// This is a payment/plan issue, not an auth issue
|
||||
if (response.status === 402) {
|
||||
let errorData: any = {};
|
||||
try {
|
||||
errorData = JSON.parse(text);
|
||||
} catch {}
|
||||
|
||||
const errorMessage = errorData?.error || errorData?.message || response.statusText;
|
||||
const err: any = new Error(errorMessage);
|
||||
err.status = 402;
|
||||
err.data = errorData;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// === ALL OTHER ERRORS - NEVER logout ===
|
||||
// Parse error response
|
||||
let errorMessage = response.statusText;
|
||||
let errorData: any = null;
|
||||
|
||||
try {
|
||||
if (contentType.includes('application/json')) {
|
||||
errorData = JSON.parse(text);
|
||||
errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage;
|
||||
} else if (text.includes('<!DOCTYPE html>')) {
|
||||
// HTML error page - extract title
|
||||
const titleMatch = text.match(/<title>([^<]+) at ([^<]+)<\/title>/);
|
||||
if (titleMatch) {
|
||||
errorMessage = `${titleMatch[1].trim()} at ${titleMatch[2].trim()}`;
|
||||
}
|
||||
} else {
|
||||
errorMessage = text.substring(0, 200);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.error('API Error:', {
|
||||
status: response.status,
|
||||
message: errorMessage,
|
||||
endpoint,
|
||||
errorData,
|
||||
});
|
||||
|
||||
const err: any = new Error(errorMessage);
|
||||
err.status = response.status;
|
||||
err.data = errorData;
|
||||
throw err;
|
||||
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Network/timeout error - NEVER logout
|
||||
if (error.name === 'AbortError') {
|
||||
const err: any = new Error(`Request timeout after ${timeout}ms`);
|
||||
err.status = 408;
|
||||
err.isTimeout = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Re-throw error (it already has status code if it's an HTTP error)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export helper for backward compatibility
|
||||
export { API_BASE_URL as default };
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* Logout Tracking Service
|
||||
* Captures and logs every logout event with detailed context
|
||||
*/
|
||||
|
||||
interface LogoutReason {
|
||||
type: 'USER_ACTION' | 'TOKEN_EXPIRED' | 'REFRESH_FAILED' | 'AUTH_ERROR' | 'UNKNOWN';
|
||||
message: string;
|
||||
timestamp: number;
|
||||
context?: any;
|
||||
}
|
||||
|
||||
class LogoutTracker {
|
||||
private static instance: LogoutTracker;
|
||||
private lastActivity: number = Date.now();
|
||||
private activityCheckInterval: any = null;
|
||||
private logoutHistory: LogoutReason[] = [];
|
||||
|
||||
private constructor() {
|
||||
this.startActivityMonitoring();
|
||||
this.loadLogoutHistory();
|
||||
}
|
||||
|
||||
static getInstance(): LogoutTracker {
|
||||
if (!LogoutTracker.instance) {
|
||||
LogoutTracker.instance = new LogoutTracker();
|
||||
}
|
||||
return LogoutTracker.instance;
|
||||
}
|
||||
|
||||
private startActivityMonitoring() {
|
||||
// Track user activity
|
||||
const updateActivity = () => {
|
||||
this.lastActivity = Date.now();
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', updateActivity);
|
||||
window.addEventListener('keydown', updateActivity);
|
||||
window.addEventListener('click', updateActivity);
|
||||
window.addEventListener('scroll', updateActivity);
|
||||
|
||||
// Check activity every 30 seconds
|
||||
this.activityCheckInterval = setInterval(() => {
|
||||
const idleTime = Date.now() - this.lastActivity;
|
||||
const idleMinutes = Math.floor(idleTime / 60000);
|
||||
|
||||
if (idleMinutes > 0) {
|
||||
console.log(`[LOGOUT-TRACKER] User idle for ${idleMinutes} minutes`);
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private loadLogoutHistory() {
|
||||
try {
|
||||
const stored = localStorage.getItem('logout_history');
|
||||
if (stored) {
|
||||
this.logoutHistory = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LOGOUT-TRACKER] Failed to load history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private saveLogoutHistory() {
|
||||
try {
|
||||
// Keep only last 10 logouts
|
||||
const recent = this.logoutHistory.slice(-10);
|
||||
localStorage.setItem('logout_history', JSON.stringify(recent));
|
||||
} catch (e) {
|
||||
console.error('[LOGOUT-TRACKER] Failed to save history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a logout event with full context
|
||||
*/
|
||||
trackLogout(reason: LogoutReason) {
|
||||
const idleTime = Date.now() - this.lastActivity;
|
||||
const idleMinutes = Math.floor(idleTime / 60000);
|
||||
|
||||
const fullReason = {
|
||||
...reason,
|
||||
timestamp: Date.now(),
|
||||
idleMinutes,
|
||||
location: window.location.href,
|
||||
context: {
|
||||
...reason.context,
|
||||
userAgent: navigator.userAgent,
|
||||
screenResolution: `${window.screen.width}x${window.screen.height}`,
|
||||
}
|
||||
};
|
||||
|
||||
// Add to history
|
||||
this.logoutHistory.push(fullReason);
|
||||
this.saveLogoutHistory();
|
||||
|
||||
// Log to console with big warning
|
||||
console.group('%c🚨 LOGOUT DETECTED', 'color: red; font-size: 20px; font-weight: bold;');
|
||||
console.log('%cLogout Type:', 'font-weight: bold;', reason.type);
|
||||
console.log('%cReason:', 'font-weight: bold;', reason.message);
|
||||
console.log('%cIdle Time:', 'font-weight: bold;', `${idleMinutes} minutes`);
|
||||
console.log('%cTimestamp:', 'font-weight: bold;', new Date().toISOString());
|
||||
console.log('%cLocation:', 'font-weight: bold;', window.location.href);
|
||||
console.log('%cContext:', 'font-weight: bold;', fullReason.context);
|
||||
console.groupEnd();
|
||||
|
||||
// Show alert to user (only if not on signin page)
|
||||
if (!window.location.pathname.includes('signin')) {
|
||||
this.showLogoutAlert(reason);
|
||||
}
|
||||
|
||||
// Send to backend for server-side logging
|
||||
this.sendToBackend(fullReason);
|
||||
|
||||
// Store in sessionStorage for signin page to display
|
||||
sessionStorage.setItem('last_logout_reason', JSON.stringify(fullReason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a big alert to user before redirecting
|
||||
*/
|
||||
private showLogoutAlert(reason: LogoutReason) {
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
// Create alert box
|
||||
const alertBox = document.createElement('div');
|
||||
alertBox.style.cssText = `
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
`;
|
||||
|
||||
const idleMinutes = Math.floor((Date.now() - this.lastActivity) / 60000);
|
||||
|
||||
alertBox.innerHTML = `
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">🚨</div>
|
||||
<h2 style="color: #e53e3e; margin-bottom: 15px;">Session Expired</h2>
|
||||
<p style="color: #4a5568; margin-bottom: 10px; font-size: 16px;">
|
||||
<strong>Reason:</strong> ${reason.message}
|
||||
</p>
|
||||
<p style="color: #718096; margin-bottom: 20px; font-size: 14px;">
|
||||
Idle time: ${idleMinutes} minutes<br>
|
||||
Type: ${reason.type}
|
||||
</p>
|
||||
<button id="close-logout-alert" style="
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
padding: 10px 30px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
">Go to Sign In</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(alertBox);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
}, 5000);
|
||||
|
||||
// Manual close
|
||||
const closeBtn = document.getElementById('close-logout-alert');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => {
|
||||
overlay.remove();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send logout event to backend for server-side logging
|
||||
*/
|
||||
private async sendToBackend(reason: LogoutReason) {
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
||||
await fetch(`${API_BASE_URL}/v1/auth/logout-event/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(reason),
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (e) {
|
||||
// Silently fail - logging shouldn't break logout
|
||||
console.error('[LOGOUT-TRACKER] Failed to send to backend:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last logout reason (for display on signin page)
|
||||
*/
|
||||
getLastLogoutReason(): LogoutReason | null {
|
||||
try {
|
||||
const stored = sessionStorage.getItem('last_logout_reason');
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LOGOUT-TRACKER] Failed to get last logout reason:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear last logout reason (call after displaying on signin page)
|
||||
*/
|
||||
clearLastLogoutReason() {
|
||||
sessionStorage.removeItem('last_logout_reason');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logout history
|
||||
*/
|
||||
getLogoutHistory(): LogoutReason[] {
|
||||
return [...this.logoutHistory];
|
||||
}
|
||||
}
|
||||
|
||||
export const logoutTracker = LogoutTracker.getInstance();
|
||||
|
||||
/**
|
||||
* Wrap logout function to track reason
|
||||
*/
|
||||
export function trackLogout(
|
||||
reason: string,
|
||||
type: LogoutReason['type'] = 'UNKNOWN',
|
||||
context?: any
|
||||
) {
|
||||
logoutTracker.trackLogout({
|
||||
type,
|
||||
message: reason,
|
||||
timestamp: Date.now(),
|
||||
context,
|
||||
});
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* Token Expiry Monitor
|
||||
* Monitors JWT token expiry and logs warnings before logout
|
||||
*/
|
||||
|
||||
interface TokenPayload {
|
||||
exp: number;
|
||||
iat: number;
|
||||
user_id: number;
|
||||
email: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function decodeJWT(token: string): TokenPayload | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return payload;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenExpiryMonitor {
|
||||
private checkInterval: any = null;
|
||||
private lastWarning: number = 0;
|
||||
|
||||
start() {
|
||||
// Check token expiry every 30 seconds
|
||||
this.checkInterval = setInterval(() => {
|
||||
this.checkTokenExpiry();
|
||||
}, 30000);
|
||||
|
||||
// Initial check
|
||||
this.checkTokenExpiry();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
this.checkInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private checkTokenExpiry() {
|
||||
try {
|
||||
// Get token from Zustand store
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (!authStorage) return;
|
||||
|
||||
const parsed = JSON.parse(authStorage);
|
||||
const token = parsed?.state?.token;
|
||||
const refreshToken = parsed?.state?.refreshToken;
|
||||
|
||||
if (!token) {
|
||||
console.warn('[TOKEN-MONITOR] No access token found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode token
|
||||
const payload = decodeJWT(token);
|
||||
if (!payload || !payload.exp) {
|
||||
console.warn('[TOKEN-MONITOR] Invalid token format');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = payload.exp - now;
|
||||
const minutesUntilExpiry = Math.floor(expiresIn / 60);
|
||||
|
||||
// Log token status every check
|
||||
console.log(
|
||||
`[TOKEN-MONITOR] Access token expires in ${minutesUntilExpiry} minutes ` +
|
||||
`(${expiresIn} seconds)`
|
||||
);
|
||||
|
||||
// Warn if token is expiring soon (< 5 minutes)
|
||||
if (expiresIn < 300 && expiresIn > 0) {
|
||||
const now = Date.now();
|
||||
// Only warn once per minute
|
||||
if (now - this.lastWarning > 60000) {
|
||||
this.lastWarning = now;
|
||||
console.group('%c⚠️ TOKEN EXPIRING SOON', 'color: orange; font-size: 16px; font-weight: bold;');
|
||||
console.log(`Expires in: ${minutesUntilExpiry} minutes (${expiresIn} seconds)`);
|
||||
console.log('Refresh should happen automatically on next API call');
|
||||
console.log('Has refresh token:', !!refreshToken);
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// Critical warning if already expired
|
||||
if (expiresIn <= 0) {
|
||||
console.group('%c🚨 TOKEN EXPIRED', 'color: red; font-size: 18px; font-weight: bold;');
|
||||
console.log(`Expired ${Math.abs(minutesUntilExpiry)} minutes ago`);
|
||||
console.log('Next API call will trigger refresh');
|
||||
console.log('Has refresh token:', !!refreshToken);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Check refresh token expiry too
|
||||
if (refreshToken) {
|
||||
const refreshPayload = decodeJWT(refreshToken);
|
||||
if (refreshPayload && refreshPayload.exp) {
|
||||
const refreshExpiresIn = refreshPayload.exp - now;
|
||||
const refreshMinutes = Math.floor(refreshExpiresIn / 60);
|
||||
const refreshHours = Math.floor(refreshMinutes / 60);
|
||||
const refreshDays = Math.floor(refreshHours / 24);
|
||||
|
||||
console.log(
|
||||
`[TOKEN-MONITOR] Refresh token expires in ${refreshDays}d ${refreshHours % 24}h ${refreshMinutes % 60}m`
|
||||
);
|
||||
|
||||
// Warn if refresh token is expiring soon (< 1 day)
|
||||
if (refreshExpiresIn < 86400 && refreshExpiresIn > 0) {
|
||||
console.warn(
|
||||
`[TOKEN-MONITOR] ⚠️ Refresh token expires in ${refreshHours} hours! ` +
|
||||
`User will be logged out when it expires.`
|
||||
);
|
||||
}
|
||||
|
||||
// Critical if refresh token expired
|
||||
if (refreshExpiresIn <= 0) {
|
||||
console.error(
|
||||
`[TOKEN-MONITOR] 🚨 REFRESH TOKEN EXPIRED! ` +
|
||||
`User will be logged out on next refresh attempt.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('[TOKEN-MONITOR] Error checking token expiry:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed token status for debugging
|
||||
*/
|
||||
getTokenStatus() {
|
||||
try {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (!authStorage) {
|
||||
return {
|
||||
hasToken: false,
|
||||
hasRefreshToken: false,
|
||||
accessTokenExpired: true,
|
||||
refreshTokenExpired: true,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(authStorage);
|
||||
const token = parsed?.state?.token;
|
||||
const refreshToken = parsed?.state?.refreshToken;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
let accessTokenExpired = true;
|
||||
let accessExpiresIn = 0;
|
||||
if (token) {
|
||||
const payload = decodeJWT(token);
|
||||
if (payload && payload.exp) {
|
||||
accessExpiresIn = payload.exp - now;
|
||||
accessTokenExpired = accessExpiresIn <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
let refreshTokenExpired = true;
|
||||
let refreshExpiresIn = 0;
|
||||
if (refreshToken) {
|
||||
const payload = decodeJWT(refreshToken);
|
||||
if (payload && payload.exp) {
|
||||
refreshExpiresIn = payload.exp - now;
|
||||
refreshTokenExpired = refreshExpiresIn <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasToken: !!token,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
accessTokenExpired,
|
||||
refreshTokenExpired,
|
||||
accessExpiresInMinutes: Math.floor(accessExpiresIn / 60),
|
||||
refreshExpiresInHours: Math.floor(refreshExpiresIn / 3600),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[TOKEN-MONITOR] Error getting token status:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const tokenExpiryMonitor = new TokenExpiryMonitor();
|
||||
|
||||
// Auto-start monitoring when module loads
|
||||
if (typeof window !== 'undefined') {
|
||||
tokenExpiryMonitor.start();
|
||||
|
||||
// Expose to window for debugging
|
||||
(window as any).__tokenMonitor = tokenExpiryMonitor;
|
||||
|
||||
console.log(
|
||||
'%c📊 Token Expiry Monitor Started',
|
||||
'color: green; font-weight: bold;',
|
||||
'\nUse window.__tokenMonitor.getTokenStatus() to check token status'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user