2025-09-05 17:49:48 +02:00
|
|
|
/**
|
|
|
|
|
* Core HTTP client for React Query integration
|
|
|
|
|
*
|
|
|
|
|
* Architecture:
|
|
|
|
|
* - Axios: HTTP client for making requests
|
|
|
|
|
* - This Client: Handles auth tokens, tenant context, and error formatting
|
|
|
|
|
* - Services: Business logic that uses this client
|
|
|
|
|
* - React Query Hooks: Data fetching layer that uses services
|
|
|
|
|
*
|
|
|
|
|
* React Query doesn't replace HTTP clients - it manages data fetching/caching/sync
|
|
|
|
|
*/
|
|
|
|
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
2025-09-27 12:10:43 +02:00
|
|
|
import { getApiUrl } from '../../config/runtime';
|
2025-09-05 17:49:48 +02:00
|
|
|
|
|
|
|
|
export interface ApiError {
|
|
|
|
|
message: string;
|
|
|
|
|
status?: number;
|
|
|
|
|
code?: string;
|
|
|
|
|
details?: any;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 13:27:50 +02:00
|
|
|
export interface SubscriptionError {
|
|
|
|
|
error: string;
|
|
|
|
|
message: string;
|
|
|
|
|
code: string;
|
|
|
|
|
details: {
|
|
|
|
|
required_feature: string;
|
|
|
|
|
required_level: string;
|
|
|
|
|
current_plan: string;
|
|
|
|
|
upgrade_url: string;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Subscription error event emitter
|
|
|
|
|
class SubscriptionErrorEmitter extends EventTarget {
|
|
|
|
|
emitSubscriptionError(error: SubscriptionError) {
|
|
|
|
|
this.dispatchEvent(new CustomEvent('subscriptionError', { detail: error }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const subscriptionErrorEmitter = new SubscriptionErrorEmitter();
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
class ApiClient {
|
|
|
|
|
private client: AxiosInstance;
|
|
|
|
|
private baseURL: string;
|
|
|
|
|
private authToken: string | null = null;
|
|
|
|
|
private tenantId: string | null = null;
|
2025-10-03 14:09:34 +02:00
|
|
|
private demoSessionId: string | null = null;
|
2025-09-17 16:06:30 +02:00
|
|
|
private refreshToken: string | null = null;
|
|
|
|
|
private isRefreshing: boolean = false;
|
2025-09-19 11:44:38 +02:00
|
|
|
private refreshAttempts: number = 0;
|
|
|
|
|
private maxRefreshAttempts: number = 3;
|
|
|
|
|
private lastRefreshAttempt: number = 0;
|
2025-09-17 16:06:30 +02:00
|
|
|
private failedQueue: Array<{
|
|
|
|
|
resolve: (value?: any) => void;
|
|
|
|
|
reject: (error?: any) => void;
|
|
|
|
|
config: AxiosRequestConfig;
|
|
|
|
|
}> = [];
|
2025-09-05 17:49:48 +02:00
|
|
|
|
2025-09-27 22:55:42 +02:00
|
|
|
constructor(baseURL: string = getApiUrl() + '/v1') {
|
2025-09-05 17:49:48 +02:00
|
|
|
this.baseURL = baseURL;
|
|
|
|
|
|
|
|
|
|
this.client = axios.create({
|
|
|
|
|
baseURL: this.baseURL,
|
|
|
|
|
timeout: 30000,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.setupInterceptors();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setupInterceptors() {
|
|
|
|
|
// Request interceptor to add auth headers
|
|
|
|
|
this.client.interceptors.request.use(
|
|
|
|
|
(config) => {
|
2025-10-03 14:09:34 +02:00
|
|
|
// Public endpoints that don't require authentication
|
|
|
|
|
const publicEndpoints = [
|
|
|
|
|
'/demo/accounts',
|
|
|
|
|
'/demo/session/create',
|
|
|
|
|
];
|
|
|
|
|
|
Make backend robust with comprehensive onboarding steps
Backend Changes (services/auth/app/api/onboarding_progress.py):
- Expanded ONBOARDING_STEPS to include all 19 frontend steps
- Phase 0: user_registered (system)
- Phase 1: bakery-type-selection, data-source-choice (discovery)
- Phase 2: setup, smart-inventory-setup, product-categorization, initial-stock-entry (core setup & AI path)
- Phase 2b: suppliers-setup, inventory-setup, recipes-setup, production-processes (manual path)
- Phase 3: quality-setup, team-setup (advanced config)
- Phase 4: ml-training, setup-review, completion (finalization)
- Updated STEP_DEPENDENCIES with granular requirements
- AI path: smart-inventory-setup → product-categorization → initial-stock-entry
- Manual path: Independent setup for suppliers, inventory, recipes, processes
- Flexible ML training: accepts either AI or manual inventory path
- Enhanced ML training validation
- Supports both AI-assisted path (sales data) and manual inventory path
- More flexible validation logic for multi-path onboarding
Frontend Changes (UnifiedOnboardingWizard.tsx):
- Fixed auto-complete step name: 'suppliers' → 'suppliers-setup'
- All step IDs now match backend ONBOARDING_STEPS exactly
- Removed temporary step mapping workarounds
Frontend Changes (apiClient.ts):
- Fixed tenant ID requirement warnings for onboarding endpoints
- Added noTenantEndpoints list for user-level endpoints:
- /auth/me/onboarding (tenant created during onboarding)
- /auth/me (user profile)
- /auth/register, /auth/login
- Eliminated false warnings during onboarding flow
This makes the onboarding system fully functional with:
✅ Backend validates all 19 onboarding steps
✅ Proper dependency tracking for multi-path onboarding
✅ No more "Invalid step name" errors
✅ No more tenant ID warnings for onboarding
✅ Robust state tracking for complete user journey
2025-11-06 13:38:06 +00:00
|
|
|
// Endpoints that require authentication but not a tenant ID (user-level endpoints)
|
|
|
|
|
const noTenantEndpoints = [
|
|
|
|
|
'/auth/me/onboarding', // Onboarding endpoints - tenant is created during onboarding
|
|
|
|
|
'/auth/me', // User profile endpoints
|
|
|
|
|
'/auth/register', // Registration
|
|
|
|
|
'/auth/login', // Login
|
2025-11-12 15:34:10 +01:00
|
|
|
'/geocoding', // Geocoding/address search - utility service, no tenant context
|
2025-11-13 16:01:08 +01:00
|
|
|
'/tenants/register', // Tenant registration - creating new tenant, no existing tenant context
|
Make backend robust with comprehensive onboarding steps
Backend Changes (services/auth/app/api/onboarding_progress.py):
- Expanded ONBOARDING_STEPS to include all 19 frontend steps
- Phase 0: user_registered (system)
- Phase 1: bakery-type-selection, data-source-choice (discovery)
- Phase 2: setup, smart-inventory-setup, product-categorization, initial-stock-entry (core setup & AI path)
- Phase 2b: suppliers-setup, inventory-setup, recipes-setup, production-processes (manual path)
- Phase 3: quality-setup, team-setup (advanced config)
- Phase 4: ml-training, setup-review, completion (finalization)
- Updated STEP_DEPENDENCIES with granular requirements
- AI path: smart-inventory-setup → product-categorization → initial-stock-entry
- Manual path: Independent setup for suppliers, inventory, recipes, processes
- Flexible ML training: accepts either AI or manual inventory path
- Enhanced ML training validation
- Supports both AI-assisted path (sales data) and manual inventory path
- More flexible validation logic for multi-path onboarding
Frontend Changes (UnifiedOnboardingWizard.tsx):
- Fixed auto-complete step name: 'suppliers' → 'suppliers-setup'
- All step IDs now match backend ONBOARDING_STEPS exactly
- Removed temporary step mapping workarounds
Frontend Changes (apiClient.ts):
- Fixed tenant ID requirement warnings for onboarding endpoints
- Added noTenantEndpoints list for user-level endpoints:
- /auth/me/onboarding (tenant created during onboarding)
- /auth/me (user profile)
- /auth/register, /auth/login
- Eliminated false warnings during onboarding flow
This makes the onboarding system fully functional with:
✅ Backend validates all 19 onboarding steps
✅ Proper dependency tracking for multi-path onboarding
✅ No more "Invalid step name" errors
✅ No more tenant ID warnings for onboarding
✅ Robust state tracking for complete user journey
2025-11-06 13:38:06 +00:00
|
|
|
];
|
|
|
|
|
|
2025-10-03 14:09:34 +02:00
|
|
|
const isPublicEndpoint = publicEndpoints.some(endpoint =>
|
|
|
|
|
config.url?.includes(endpoint)
|
|
|
|
|
);
|
|
|
|
|
|
Make backend robust with comprehensive onboarding steps
Backend Changes (services/auth/app/api/onboarding_progress.py):
- Expanded ONBOARDING_STEPS to include all 19 frontend steps
- Phase 0: user_registered (system)
- Phase 1: bakery-type-selection, data-source-choice (discovery)
- Phase 2: setup, smart-inventory-setup, product-categorization, initial-stock-entry (core setup & AI path)
- Phase 2b: suppliers-setup, inventory-setup, recipes-setup, production-processes (manual path)
- Phase 3: quality-setup, team-setup (advanced config)
- Phase 4: ml-training, setup-review, completion (finalization)
- Updated STEP_DEPENDENCIES with granular requirements
- AI path: smart-inventory-setup → product-categorization → initial-stock-entry
- Manual path: Independent setup for suppliers, inventory, recipes, processes
- Flexible ML training: accepts either AI or manual inventory path
- Enhanced ML training validation
- Supports both AI-assisted path (sales data) and manual inventory path
- More flexible validation logic for multi-path onboarding
Frontend Changes (UnifiedOnboardingWizard.tsx):
- Fixed auto-complete step name: 'suppliers' → 'suppliers-setup'
- All step IDs now match backend ONBOARDING_STEPS exactly
- Removed temporary step mapping workarounds
Frontend Changes (apiClient.ts):
- Fixed tenant ID requirement warnings for onboarding endpoints
- Added noTenantEndpoints list for user-level endpoints:
- /auth/me/onboarding (tenant created during onboarding)
- /auth/me (user profile)
- /auth/register, /auth/login
- Eliminated false warnings during onboarding flow
This makes the onboarding system fully functional with:
✅ Backend validates all 19 onboarding steps
✅ Proper dependency tracking for multi-path onboarding
✅ No more "Invalid step name" errors
✅ No more tenant ID warnings for onboarding
✅ Robust state tracking for complete user journey
2025-11-06 13:38:06 +00:00
|
|
|
const isNoTenantEndpoint = noTenantEndpoints.some(endpoint =>
|
|
|
|
|
config.url?.includes(endpoint)
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-03 14:09:34 +02:00
|
|
|
// Only add auth token for non-public endpoints
|
|
|
|
|
if (this.authToken && !isPublicEndpoint) {
|
2025-09-05 17:49:48 +02:00
|
|
|
config.headers.Authorization = `Bearer ${this.authToken}`;
|
|
|
|
|
}
|
2025-10-03 14:09:34 +02:00
|
|
|
|
Make backend robust with comprehensive onboarding steps
Backend Changes (services/auth/app/api/onboarding_progress.py):
- Expanded ONBOARDING_STEPS to include all 19 frontend steps
- Phase 0: user_registered (system)
- Phase 1: bakery-type-selection, data-source-choice (discovery)
- Phase 2: setup, smart-inventory-setup, product-categorization, initial-stock-entry (core setup & AI path)
- Phase 2b: suppliers-setup, inventory-setup, recipes-setup, production-processes (manual path)
- Phase 3: quality-setup, team-setup (advanced config)
- Phase 4: ml-training, setup-review, completion (finalization)
- Updated STEP_DEPENDENCIES with granular requirements
- AI path: smart-inventory-setup → product-categorization → initial-stock-entry
- Manual path: Independent setup for suppliers, inventory, recipes, processes
- Flexible ML training: accepts either AI or manual inventory path
- Enhanced ML training validation
- Supports both AI-assisted path (sales data) and manual inventory path
- More flexible validation logic for multi-path onboarding
Frontend Changes (UnifiedOnboardingWizard.tsx):
- Fixed auto-complete step name: 'suppliers' → 'suppliers-setup'
- All step IDs now match backend ONBOARDING_STEPS exactly
- Removed temporary step mapping workarounds
Frontend Changes (apiClient.ts):
- Fixed tenant ID requirement warnings for onboarding endpoints
- Added noTenantEndpoints list for user-level endpoints:
- /auth/me/onboarding (tenant created during onboarding)
- /auth/me (user profile)
- /auth/register, /auth/login
- Eliminated false warnings during onboarding flow
This makes the onboarding system fully functional with:
✅ Backend validates all 19 onboarding steps
✅ Proper dependency tracking for multi-path onboarding
✅ No more "Invalid step name" errors
✅ No more tenant ID warnings for onboarding
✅ Robust state tracking for complete user journey
2025-11-06 13:38:06 +00:00
|
|
|
// Add tenant ID only for endpoints that require it
|
|
|
|
|
if (this.tenantId && !isPublicEndpoint && !isNoTenantEndpoint) {
|
2025-09-05 17:49:48 +02:00
|
|
|
config.headers['X-Tenant-ID'] = this.tenantId;
|
2025-10-12 18:47:33 +02:00
|
|
|
console.log('🔍 [API Client] Adding X-Tenant-ID header:', this.tenantId, 'for URL:', config.url);
|
Make backend robust with comprehensive onboarding steps
Backend Changes (services/auth/app/api/onboarding_progress.py):
- Expanded ONBOARDING_STEPS to include all 19 frontend steps
- Phase 0: user_registered (system)
- Phase 1: bakery-type-selection, data-source-choice (discovery)
- Phase 2: setup, smart-inventory-setup, product-categorization, initial-stock-entry (core setup & AI path)
- Phase 2b: suppliers-setup, inventory-setup, recipes-setup, production-processes (manual path)
- Phase 3: quality-setup, team-setup (advanced config)
- Phase 4: ml-training, setup-review, completion (finalization)
- Updated STEP_DEPENDENCIES with granular requirements
- AI path: smart-inventory-setup → product-categorization → initial-stock-entry
- Manual path: Independent setup for suppliers, inventory, recipes, processes
- Flexible ML training: accepts either AI or manual inventory path
- Enhanced ML training validation
- Supports both AI-assisted path (sales data) and manual inventory path
- More flexible validation logic for multi-path onboarding
Frontend Changes (UnifiedOnboardingWizard.tsx):
- Fixed auto-complete step name: 'suppliers' → 'suppliers-setup'
- All step IDs now match backend ONBOARDING_STEPS exactly
- Removed temporary step mapping workarounds
Frontend Changes (apiClient.ts):
- Fixed tenant ID requirement warnings for onboarding endpoints
- Added noTenantEndpoints list for user-level endpoints:
- /auth/me/onboarding (tenant created during onboarding)
- /auth/me (user profile)
- /auth/register, /auth/login
- Eliminated false warnings during onboarding flow
This makes the onboarding system fully functional with:
✅ Backend validates all 19 onboarding steps
✅ Proper dependency tracking for multi-path onboarding
✅ No more "Invalid step name" errors
✅ No more tenant ID warnings for onboarding
✅ Robust state tracking for complete user journey
2025-11-06 13:38:06 +00:00
|
|
|
} else if (!isPublicEndpoint && !isNoTenantEndpoint) {
|
|
|
|
|
console.warn('⚠️ [API Client] No tenant ID set for endpoint:', config.url);
|
2025-09-05 17:49:48 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-03 14:09:34 +02:00
|
|
|
// Check demo session ID from memory OR localStorage
|
|
|
|
|
const demoSessionId = this.demoSessionId || localStorage.getItem('demo_session_id');
|
|
|
|
|
if (demoSessionId) {
|
|
|
|
|
config.headers['X-Demo-Session-Id'] = demoSessionId;
|
2025-10-12 18:47:33 +02:00
|
|
|
console.log('🔍 [API Client] Adding X-Demo-Session-Id header:', demoSessionId);
|
2025-10-03 14:09:34 +02:00
|
|
|
}
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
return config;
|
|
|
|
|
},
|
|
|
|
|
(error) => {
|
|
|
|
|
return Promise.reject(this.handleError(error));
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
// Response interceptor for error handling and automatic token refresh
|
2025-09-05 17:49:48 +02:00
|
|
|
this.client.interceptors.response.use(
|
2025-09-29 07:54:25 +02:00
|
|
|
(response) => {
|
|
|
|
|
// Enhanced logging for token refresh header detection
|
|
|
|
|
const refreshSuggested = response.headers['x-token-refresh-suggested'];
|
|
|
|
|
if (refreshSuggested) {
|
|
|
|
|
console.log('🔍 TOKEN REFRESH HEADER DETECTED:', {
|
|
|
|
|
url: response.config?.url,
|
|
|
|
|
method: response.config?.method,
|
|
|
|
|
status: response.status,
|
|
|
|
|
refreshSuggested,
|
|
|
|
|
hasRefreshToken: !!this.refreshToken,
|
|
|
|
|
currentTokenLength: this.authToken?.length || 0
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if server suggests token refresh
|
|
|
|
|
if (refreshSuggested === 'true' && this.refreshToken) {
|
|
|
|
|
console.log('🔄 Server suggests token refresh - refreshing proactively');
|
|
|
|
|
this.proactiveTokenRefresh();
|
|
|
|
|
}
|
|
|
|
|
return response;
|
|
|
|
|
},
|
2025-09-17 16:06:30 +02:00
|
|
|
async (error) => {
|
|
|
|
|
const originalRequest = error.config;
|
|
|
|
|
|
|
|
|
|
// Check if error is 401 and we have a refresh token
|
|
|
|
|
if (error.response?.status === 401 && this.refreshToken && !originalRequest._retry) {
|
2025-09-19 11:44:38 +02:00
|
|
|
// Check if we've exceeded max refresh attempts in a short time
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (this.refreshAttempts >= this.maxRefreshAttempts && (now - this.lastRefreshAttempt) < 30000) {
|
|
|
|
|
console.log('Max refresh attempts exceeded, logging out');
|
|
|
|
|
await this.handleAuthFailure();
|
|
|
|
|
return Promise.reject(this.handleError(error));
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
if (this.isRefreshing) {
|
|
|
|
|
// If already refreshing, queue this request
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
this.failedQueue.push({ resolve, reject, config: originalRequest });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
originalRequest._retry = true;
|
|
|
|
|
this.isRefreshing = true;
|
2025-09-19 11:44:38 +02:00
|
|
|
this.refreshAttempts++;
|
|
|
|
|
this.lastRefreshAttempt = now;
|
2025-09-17 16:06:30 +02:00
|
|
|
|
|
|
|
|
try {
|
2025-09-19 11:44:38 +02:00
|
|
|
console.log(`Attempting token refresh (attempt ${this.refreshAttempts})...`);
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
// Attempt to refresh the token
|
|
|
|
|
const response = await this.client.post('/auth/refresh', {
|
|
|
|
|
refresh_token: this.refreshToken
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { access_token, refresh_token } = response.data;
|
|
|
|
|
|
2025-09-19 11:44:38 +02:00
|
|
|
console.log('Token refresh successful');
|
|
|
|
|
|
|
|
|
|
// Reset refresh attempts on success
|
|
|
|
|
this.refreshAttempts = 0;
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
// Update tokens
|
|
|
|
|
this.setAuthToken(access_token);
|
|
|
|
|
if (refresh_token) {
|
|
|
|
|
this.setRefreshToken(refresh_token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update auth store if available
|
|
|
|
|
await this.updateAuthStore(access_token, refresh_token);
|
|
|
|
|
|
|
|
|
|
// Process failed queue
|
|
|
|
|
this.processQueue(null, access_token);
|
|
|
|
|
|
|
|
|
|
// Retry original request with new token
|
|
|
|
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
|
|
|
|
return this.client(originalRequest);
|
|
|
|
|
|
|
|
|
|
} catch (refreshError) {
|
2025-09-19 11:44:38 +02:00
|
|
|
console.error(`Token refresh failed (attempt ${this.refreshAttempts}):`, refreshError);
|
2025-09-17 16:06:30 +02:00
|
|
|
// Refresh failed, clear tokens and redirect to login
|
|
|
|
|
this.processQueue(refreshError, null);
|
|
|
|
|
await this.handleAuthFailure();
|
|
|
|
|
return Promise.reject(this.handleError(refreshError as AxiosError));
|
|
|
|
|
} finally {
|
|
|
|
|
this.isRefreshing = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
return Promise.reject(this.handleError(error));
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleError(error: AxiosError): ApiError {
|
|
|
|
|
if (error.response) {
|
|
|
|
|
// Server responded with error status
|
|
|
|
|
const { status, data } = error.response;
|
2025-09-21 13:27:50 +02:00
|
|
|
|
|
|
|
|
// Check for subscription errors
|
|
|
|
|
if (status === 403 && (data as any)?.code === 'SUBSCRIPTION_UPGRADE_REQUIRED') {
|
|
|
|
|
const subscriptionError = data as SubscriptionError;
|
|
|
|
|
subscriptionErrorEmitter.emitSubscriptionError(subscriptionError);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
return {
|
|
|
|
|
message: (data as any)?.detail || (data as any)?.message || `Request failed with status ${status}`,
|
|
|
|
|
status,
|
|
|
|
|
code: (data as any)?.code,
|
|
|
|
|
details: data,
|
|
|
|
|
};
|
|
|
|
|
} else if (error.request) {
|
|
|
|
|
// Network error
|
|
|
|
|
return {
|
|
|
|
|
message: 'Network error - please check your connection',
|
|
|
|
|
status: 0,
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
// Other error
|
|
|
|
|
return {
|
|
|
|
|
message: error.message || 'Unknown error occurred',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
private processQueue(error: any, token: string | null = null) {
|
|
|
|
|
this.failedQueue.forEach(({ resolve, reject, config }) => {
|
|
|
|
|
if (error) {
|
|
|
|
|
reject(error);
|
|
|
|
|
} else {
|
|
|
|
|
if (token) {
|
|
|
|
|
config.headers = config.headers || {};
|
|
|
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
}
|
|
|
|
|
resolve(this.client(config));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.failedQueue = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async updateAuthStore(accessToken: string, refreshToken?: string) {
|
|
|
|
|
try {
|
|
|
|
|
// Dynamically import to avoid circular dependency
|
|
|
|
|
const { useAuthStore } = await import('../../stores/auth.store');
|
2025-09-19 11:44:38 +02:00
|
|
|
const setState = useAuthStore.setState;
|
2025-09-17 16:06:30 +02:00
|
|
|
|
|
|
|
|
// Update the store with new tokens
|
2025-09-19 11:44:38 +02:00
|
|
|
setState(state => ({
|
|
|
|
|
...state,
|
|
|
|
|
token: accessToken,
|
|
|
|
|
refreshToken: refreshToken || state.refreshToken,
|
|
|
|
|
}));
|
2025-09-17 16:06:30 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to update auth store:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-29 07:54:25 +02:00
|
|
|
private async proactiveTokenRefresh() {
|
|
|
|
|
// Avoid multiple simultaneous proactive refreshes
|
|
|
|
|
if (this.isRefreshing) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.isRefreshing = true;
|
|
|
|
|
console.log('🔄 Proactively refreshing token...');
|
|
|
|
|
|
|
|
|
|
const response = await this.client.post('/auth/refresh', {
|
|
|
|
|
refresh_token: this.refreshToken
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { access_token, refresh_token } = response.data;
|
|
|
|
|
|
|
|
|
|
// Update tokens
|
|
|
|
|
this.setAuthToken(access_token);
|
|
|
|
|
if (refresh_token) {
|
|
|
|
|
this.setRefreshToken(refresh_token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update auth store
|
|
|
|
|
await this.updateAuthStore(access_token, refresh_token);
|
|
|
|
|
|
|
|
|
|
console.log('✅ Proactive token refresh successful');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('⚠️ Proactive token refresh failed:', error);
|
|
|
|
|
// Don't handle as auth failure here - let the next 401 handle it
|
|
|
|
|
} finally {
|
|
|
|
|
this.isRefreshing = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
private async handleAuthFailure() {
|
|
|
|
|
try {
|
|
|
|
|
// Clear tokens
|
|
|
|
|
this.setAuthToken(null);
|
|
|
|
|
this.setRefreshToken(null);
|
|
|
|
|
|
|
|
|
|
// Dynamically import to avoid circular dependency
|
|
|
|
|
const { useAuthStore } = await import('../../stores/auth.store');
|
|
|
|
|
const store = useAuthStore.getState();
|
|
|
|
|
|
|
|
|
|
// Logout user
|
|
|
|
|
store.logout();
|
|
|
|
|
|
|
|
|
|
// Redirect to login if not already there
|
|
|
|
|
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
|
|
|
|
window.location.href = '/login';
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to handle auth failure:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
// Configuration methods
|
|
|
|
|
setAuthToken(token: string | null) {
|
|
|
|
|
this.authToken = token;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
setRefreshToken(token: string | null) {
|
|
|
|
|
this.refreshToken = token;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
setTenantId(tenantId: string | null) {
|
|
|
|
|
this.tenantId = tenantId;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-03 14:09:34 +02:00
|
|
|
setDemoSessionId(sessionId: string | null) {
|
|
|
|
|
this.demoSessionId = sessionId;
|
|
|
|
|
if (sessionId) {
|
|
|
|
|
localStorage.setItem('demo_session_id', sessionId);
|
|
|
|
|
} else {
|
|
|
|
|
localStorage.removeItem('demo_session_id');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getDemoSessionId(): string | null {
|
|
|
|
|
return this.demoSessionId || localStorage.getItem('demo_session_id');
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
getAuthToken(): string | null {
|
|
|
|
|
return this.authToken;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 16:06:30 +02:00
|
|
|
getRefreshToken(): string | null {
|
|
|
|
|
return this.refreshToken;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
getTenantId(): string | null {
|
|
|
|
|
return this.tenantId;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-29 07:54:25 +02:00
|
|
|
// Token synchronization methods for WebSocket connections
|
|
|
|
|
getCurrentValidToken(): string | null {
|
|
|
|
|
return this.authToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ensureValidToken(): Promise<string | null> {
|
|
|
|
|
const originalToken = this.authToken;
|
|
|
|
|
const originalTokenShort = originalToken ? `${originalToken.slice(0, 20)}...${originalToken.slice(-10)}` : 'null';
|
|
|
|
|
|
|
|
|
|
console.log('🔍 ensureValidToken() called:', {
|
|
|
|
|
hasToken: !!this.authToken,
|
|
|
|
|
tokenPreview: originalTokenShort,
|
|
|
|
|
isRefreshing: this.isRefreshing,
|
|
|
|
|
hasRefreshToken: !!this.refreshToken
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// If we have a valid token, return it
|
|
|
|
|
if (this.authToken && !this.isTokenNearExpiry(this.authToken)) {
|
|
|
|
|
const expiryInfo = this.getTokenExpiryInfo(this.authToken);
|
|
|
|
|
console.log('✅ Token is valid, returning current token:', {
|
|
|
|
|
tokenPreview: originalTokenShort,
|
|
|
|
|
expiryInfo
|
|
|
|
|
});
|
|
|
|
|
return this.authToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If token is near expiry or expired, try to refresh
|
|
|
|
|
if (this.refreshToken && !this.isRefreshing) {
|
|
|
|
|
console.log('🔄 Token needs refresh, attempting proactive refresh:', {
|
|
|
|
|
reason: this.authToken ? 'near expiry' : 'no token',
|
|
|
|
|
expiryInfo: this.authToken ? this.getTokenExpiryInfo(this.authToken) : 'N/A'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await this.proactiveTokenRefresh();
|
|
|
|
|
const newTokenShort = this.authToken ? `${this.authToken.slice(0, 20)}...${this.authToken.slice(-10)}` : 'null';
|
|
|
|
|
const tokenChanged = originalToken !== this.authToken;
|
|
|
|
|
|
|
|
|
|
console.log('✅ Token refresh completed:', {
|
|
|
|
|
tokenChanged,
|
|
|
|
|
oldTokenPreview: originalTokenShort,
|
|
|
|
|
newTokenPreview: newTokenShort,
|
|
|
|
|
newExpiryInfo: this.authToken ? this.getTokenExpiryInfo(this.authToken) : 'N/A'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return this.authToken;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('❌ Failed to refresh token in ensureValidToken:', error);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('⚠️ Returning current token without refresh:', {
|
|
|
|
|
reason: this.isRefreshing ? 'already refreshing' : 'no refresh token',
|
|
|
|
|
tokenPreview: originalTokenShort
|
|
|
|
|
});
|
|
|
|
|
return this.authToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getTokenExpiryInfo(token: string): any {
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
|
|
|
const exp = payload.exp;
|
|
|
|
|
const iat = payload.iat;
|
|
|
|
|
if (!exp) return { error: 'No expiry in token' };
|
|
|
|
|
|
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
|
const timeUntilExpiry = exp - now;
|
|
|
|
|
const tokenLifetime = exp - iat;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
issuedAt: new Date(iat * 1000).toISOString(),
|
|
|
|
|
expiresAt: new Date(exp * 1000).toISOString(),
|
|
|
|
|
lifetimeMinutes: Math.floor(tokenLifetime / 60),
|
|
|
|
|
secondsUntilExpiry: timeUntilExpiry,
|
|
|
|
|
minutesUntilExpiry: Math.floor(timeUntilExpiry / 60),
|
|
|
|
|
isNearExpiry: timeUntilExpiry < 300,
|
|
|
|
|
isExpired: timeUntilExpiry <= 0
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return { error: 'Failed to parse token', details: error };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isTokenNearExpiry(token: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
|
|
|
const exp = payload.exp;
|
|
|
|
|
if (!exp) return false;
|
|
|
|
|
|
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
|
const timeUntilExpiry = exp - now;
|
|
|
|
|
|
|
|
|
|
// Consider token near expiry if less than 5 minutes remaining
|
|
|
|
|
const isNear = timeUntilExpiry < 300;
|
|
|
|
|
|
|
|
|
|
if (isNear) {
|
|
|
|
|
console.log('⏰ Token is near expiry:', {
|
|
|
|
|
secondsUntilExpiry: timeUntilExpiry,
|
|
|
|
|
minutesUntilExpiry: Math.floor(timeUntilExpiry / 60),
|
|
|
|
|
expiresAt: new Date(exp * 1000).toISOString()
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return isNear;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to parse token for expiry check:', error);
|
|
|
|
|
return true; // Assume expired if we can't parse
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 17:49:48 +02:00
|
|
|
// HTTP Methods - Return direct data for React Query
|
|
|
|
|
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
|
|
|
const response: AxiosResponse<T> = await this.client.get(url, config);
|
|
|
|
|
return response.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async post<T = any, D = any>(
|
|
|
|
|
url: string,
|
|
|
|
|
data?: D,
|
|
|
|
|
config?: AxiosRequestConfig
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const response: AxiosResponse<T> = await this.client.post(url, data, config);
|
|
|
|
|
return response.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async put<T = any, D = any>(
|
|
|
|
|
url: string,
|
|
|
|
|
data?: D,
|
|
|
|
|
config?: AxiosRequestConfig
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const response: AxiosResponse<T> = await this.client.put(url, data, config);
|
|
|
|
|
return response.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async patch<T = any, D = any>(
|
|
|
|
|
url: string,
|
|
|
|
|
data?: D,
|
|
|
|
|
config?: AxiosRequestConfig
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const response: AxiosResponse<T> = await this.client.patch(url, data, config);
|
|
|
|
|
return response.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
|
|
|
const response: AxiosResponse<T> = await this.client.delete(url, config);
|
|
|
|
|
return response.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// File upload helper
|
|
|
|
|
async uploadFile<T = any>(
|
|
|
|
|
url: string,
|
|
|
|
|
file: File | FormData,
|
|
|
|
|
config?: AxiosRequestConfig
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const formData = file instanceof FormData ? file : new FormData();
|
|
|
|
|
if (file instanceof File) {
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.post<T>(url, formData, {
|
|
|
|
|
...config,
|
|
|
|
|
headers: {
|
|
|
|
|
...config?.headers,
|
|
|
|
|
'Content-Type': 'multipart/form-data',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Raw axios instance for advanced usage
|
|
|
|
|
getAxiosInstance(): AxiosInstance {
|
|
|
|
|
return this.client;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create and export singleton instance
|
|
|
|
|
export const apiClient = new ApiClient();
|
|
|
|
|
export default apiClient;
|