Revert "messy logout fixing"

This reverts commit 4fb3a144d7.
This commit is contained in:
alorig
2025-12-15 17:24:07 +05:00
parent 4fb3a144d7
commit 25f1c32366
27 changed files with 95 additions and 4396 deletions

View File

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

View File

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

View File

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