Add new frontend
This commit is contained in:
96
frontend/src/api/auth/authService.ts
Normal file
96
frontend/src/api/auth/authService.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// src/api/auth/authService.ts
|
||||
import { tokenManager } from './tokenManager';
|
||||
import { apiClient } from '../base/apiClient';
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
tenant_name?: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
tenant_id: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
async login(credentials: LoginCredentials): Promise<UserProfile> {
|
||||
// OAuth2 password flow
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', credentials.email);
|
||||
formData.append('password', credentials.password);
|
||||
formData.append('grant_type', 'password');
|
||||
|
||||
const response = await fetch('/api/auth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const tokenResponse = await response.json();
|
||||
await tokenManager.storeTokens(tokenResponse);
|
||||
|
||||
// Get user profile
|
||||
return this.getCurrentUser();
|
||||
}
|
||||
|
||||
async register(data: RegisterData): Promise<UserProfile> {
|
||||
const response = await apiClient.post('/auth/register', data);
|
||||
|
||||
// Auto-login after registration
|
||||
await this.login({
|
||||
email: data.email,
|
||||
password: data.password
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/auth/logout');
|
||||
} finally {
|
||||
tokenManager.clearTokens();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<UserProfile> {
|
||||
return apiClient.get('/auth/me');
|
||||
}
|
||||
|
||||
async updateProfile(updates: Partial<UserProfile>): Promise<UserProfile> {
|
||||
return apiClient.patch('/auth/profile', updates);
|
||||
}
|
||||
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await apiClient.post('/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return tokenManager.isAuthenticated();
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
186
frontend/src/api/auth/tokenManager.ts
Normal file
186
frontend/src/api/auth/tokenManager.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// src/api/auth/tokenManager.ts
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
|
||||
interface TokenPayload {
|
||||
sub: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
class TokenManager {
|
||||
private static instance: TokenManager;
|
||||
private accessToken: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private tokenExpiry: Date | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Try to restore tokens from secure storage
|
||||
const stored = this.getStoredTokens();
|
||||
if (stored) {
|
||||
this.accessToken = stored.accessToken;
|
||||
this.refreshToken = stored.refreshToken;
|
||||
this.tokenExpiry = new Date(stored.expiry);
|
||||
|
||||
// Check if token needs refresh
|
||||
if (this.isTokenExpired()) {
|
||||
await this.refreshAccessToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async storeTokens(response: TokenResponse): Promise<void> {
|
||||
this.accessToken = response.access_token;
|
||||
this.refreshToken = response.refresh_token;
|
||||
|
||||
// Calculate expiry time
|
||||
const expiresIn = response.expires_in || 3600; // Default 1 hour
|
||||
this.tokenExpiry = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
// Store securely (not in localStorage for security)
|
||||
this.secureStore({
|
||||
accessToken: this.accessToken,
|
||||
refreshToken: this.refreshToken,
|
||||
expiry: this.tokenExpiry.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
// Check if token is expired or will expire soon (5 min buffer)
|
||||
if (this.shouldRefreshToken()) {
|
||||
await this.refreshAccessToken();
|
||||
}
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<void> {
|
||||
// Prevent multiple simultaneous refresh attempts
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
this.refreshPromise = this.performTokenRefresh();
|
||||
|
||||
try {
|
||||
await this.refreshPromise;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async performTokenRefresh(): Promise<void> {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: this.refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
const data: TokenResponse = await response.json();
|
||||
await this.storeTokens(data);
|
||||
} catch (error) {
|
||||
// Clear tokens on refresh failure
|
||||
this.clearTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
clearTokens(): void {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.tokenExpiry = null;
|
||||
this.clearSecureStore();
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.accessToken && !this.isTokenExpired();
|
||||
}
|
||||
|
||||
private isTokenExpired(): boolean {
|
||||
if (!this.tokenExpiry) return true;
|
||||
return new Date() >= this.tokenExpiry;
|
||||
}
|
||||
|
||||
private shouldRefreshToken(): boolean {
|
||||
if (!this.tokenExpiry) return true;
|
||||
// Refresh if token expires in less than 5 minutes
|
||||
const bufferTime = 5 * 60 * 1000; // 5 minutes
|
||||
return new Date(Date.now() + bufferTime) >= this.tokenExpiry;
|
||||
}
|
||||
|
||||
// Secure storage implementation
|
||||
private secureStore(data: any): void {
|
||||
// In production, use httpOnly cookies or secure session storage
|
||||
// For now, using sessionStorage with encryption
|
||||
const encrypted = this.encrypt(JSON.stringify(data));
|
||||
sessionStorage.setItem('auth_tokens', encrypted);
|
||||
}
|
||||
|
||||
private getStoredTokens(): any {
|
||||
const stored = sessionStorage.getItem('auth_tokens');
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
const decrypted = this.decrypt(stored);
|
||||
return JSON.parse(decrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSecureStore(): void {
|
||||
sessionStorage.removeItem('auth_tokens');
|
||||
}
|
||||
|
||||
// Simple encryption for demo (use proper encryption in production)
|
||||
private encrypt(data: string): string {
|
||||
return btoa(data);
|
||||
}
|
||||
|
||||
private decrypt(data: string): string {
|
||||
return atob(data);
|
||||
}
|
||||
|
||||
// Get decoded token payload
|
||||
getTokenPayload(): TokenPayload | null {
|
||||
if (!this.accessToken) return null;
|
||||
|
||||
try {
|
||||
return jwtDecode<TokenPayload>(this.accessToken);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
@@ -1,212 +1,449 @@
|
||||
// frontend/dashboard/src/api/base/apiClient.ts
|
||||
/**
|
||||
* Base API client with authentication and error handling
|
||||
*/
|
||||
// src/api/base/apiClient.ts
|
||||
import { tokenManager } from '../auth/tokenManager';
|
||||
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { ApiError, TokenResponse } from '../../types/api';
|
||||
|
||||
export interface ApiClientConfig {
|
||||
baseURL?: string;
|
||||
export interface ApiConfig {
|
||||
baseURL: string;
|
||||
timeout?: number;
|
||||
enableAuth?: boolean;
|
||||
enableRetry?: boolean;
|
||||
retryAttempts?: number;
|
||||
retryDelay?: number;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private enableAuth: boolean;
|
||||
private refreshPromise: Promise<string> | null = null;
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
constructor(config: ApiClientConfig = {}) {
|
||||
const {
|
||||
baseURL = process.env.REACT_APP_API_URL || 'http://localhost:8000',
|
||||
timeout = 10000,
|
||||
enableAuth = true,
|
||||
enableRetry = true,
|
||||
} = config;
|
||||
export interface RequestConfig extends RequestInit {
|
||||
params?: Record<string, any>;
|
||||
timeout?: number;
|
||||
retry?: boolean;
|
||||
retryAttempts?: number;
|
||||
}
|
||||
|
||||
this.enableAuth = enableAuth;
|
||||
type Interceptor<T> = (value: T) => T | Promise<T>;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: `${baseURL}/api/v1`,
|
||||
timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
class ApiClient {
|
||||
private config: ApiConfig;
|
||||
private requestInterceptors: Interceptor<RequestConfig>[] = [];
|
||||
private responseInterceptors: {
|
||||
fulfilled: Interceptor<Response>;
|
||||
rejected: Interceptor<any>;
|
||||
}[] = [];
|
||||
|
||||
this.setupInterceptors(enableRetry);
|
||||
constructor(config: ApiConfig) {
|
||||
this.config = {
|
||||
timeout: 30000,
|
||||
retryAttempts: 3,
|
||||
retryDelay: 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.setupDefaultInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors(enableRetry: boolean) {
|
||||
// Request interceptor - add auth token
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (this.enableAuth) {
|
||||
const token = this.getStoredToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
private setupDefaultInterceptors(): void {
|
||||
// Request interceptor for authentication
|
||||
this.addRequestInterceptor(async (config) => {
|
||||
const token = await tokenManager.getAccessToken();
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor - handle auth errors and retries
|
||||
this.client.interceptors.response.use(
|
||||
// Request interceptor for content type
|
||||
this.addRequestInterceptor((config) => {
|
||||
if (config.body && !(config.body instanceof FormData)) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor for error handling
|
||||
this.addResponseInterceptor(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle 401 errors with token refresh
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
this.enableAuth &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Try to refresh token
|
||||
try {
|
||||
const newToken = await this.refreshToken();
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return this.client(originalRequest);
|
||||
await tokenManager.refreshAccessToken();
|
||||
// Retry original request
|
||||
return this.request(error.config);
|
||||
} catch (refreshError) {
|
||||
this.handleAuthFailure();
|
||||
return Promise.reject(refreshError);
|
||||
// Redirect to login
|
||||
window.location.href = '/login';
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
return Promise.reject(this.formatError(error));
|
||||
throw this.transformError(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<string> {
|
||||
// Prevent multiple simultaneous refresh requests
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
addRequestInterceptor(interceptor: Interceptor<RequestConfig>): void {
|
||||
this.requestInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
addResponseInterceptor(
|
||||
fulfilled: Interceptor<Response>,
|
||||
rejected: Interceptor<any>
|
||||
): void {
|
||||
this.responseInterceptors.push({ fulfilled, rejected });
|
||||
}
|
||||
|
||||
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
|
||||
let processedConfig = config;
|
||||
for (const interceptor of this.requestInterceptors) {
|
||||
processedConfig = await interceptor(processedConfig);
|
||||
}
|
||||
return processedConfig;
|
||||
}
|
||||
|
||||
private async applyResponseInterceptors(
|
||||
response: Response | Promise<Response>
|
||||
): Promise<Response> {
|
||||
let processedResponse = await response;
|
||||
|
||||
for (const { fulfilled, rejected } of this.responseInterceptors) {
|
||||
try {
|
||||
processedResponse = await fulfilled(processedResponse);
|
||||
} catch (error) {
|
||||
processedResponse = await rejected(error);
|
||||
}
|
||||
}
|
||||
|
||||
return processedResponse;
|
||||
}
|
||||
|
||||
private buildURL(endpoint: string, params?: Record<string, any>): string {
|
||||
const url = new URL(endpoint, this.config.baseURL);
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private createTimeoutPromise(timeout: number): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
private async executeWithRetry(
|
||||
fn: () => Promise<Response>,
|
||||
attempts: number,
|
||||
delay: number
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (attempts <= 1) throw error;
|
||||
|
||||
// Check if error is retryable
|
||||
const isRetryable = this.isRetryableError(error);
|
||||
if (!isRetryable) throw error;
|
||||
|
||||
// Wait before retry
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
// Exponential backoff
|
||||
return this.executeWithRetry(fn, attempts - 1, delay * 2);
|
||||
}
|
||||
}
|
||||
|
||||
private isRetryableError(error: any): boolean {
|
||||
// Network errors or 5xx server errors are retryable
|
||||
if (!error.response) return true;
|
||||
return error.response.status >= 500;
|
||||
}
|
||||
|
||||
private transformError(error: any): ApiError {
|
||||
if (error.response) {
|
||||
// Server responded with error
|
||||
return {
|
||||
message: error.response.data?.detail || error.response.statusText,
|
||||
code: error.response.data?.code,
|
||||
status: error.response.status,
|
||||
details: error.response.data
|
||||
};
|
||||
} else if (error.request) {
|
||||
// Request made but no response
|
||||
return {
|
||||
message: 'Network error - no response from server',
|
||||
code: 'NETWORK_ERROR'
|
||||
};
|
||||
} else {
|
||||
// Something else happened
|
||||
return {
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
code: 'UNKNOWN_ERROR'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
|
||||
const processedConfig = await this.applyRequestInterceptors({
|
||||
...config,
|
||||
headers: {
|
||||
'X-Request-ID': this.generateRequestId(),
|
||||
...config.headers
|
||||
}
|
||||
});
|
||||
|
||||
const url = this.buildURL(endpoint, processedConfig.params);
|
||||
const timeout = processedConfig.timeout || this.config.timeout;
|
||||
const shouldRetry = processedConfig.retry !== false;
|
||||
const retryAttempts = processedConfig.retryAttempts || this.config.retryAttempts;
|
||||
|
||||
const executeRequest = async () => {
|
||||
const fetchPromise = fetch(url, {
|
||||
...processedConfig,
|
||||
signal: processedConfig.signal
|
||||
});
|
||||
|
||||
const timeoutPromise = this.createTimeoutPromise(timeout);
|
||||
|
||||
const response = await Promise.race([fetchPromise, timeoutPromise]);
|
||||
|
||||
if (!response.ok) {
|
||||
throw { response, config: { endpoint, ...processedConfig } };
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
try {
|
||||
const response = shouldRetry
|
||||
? await this.executeWithRetry(
|
||||
executeRequest,
|
||||
retryAttempts,
|
||||
this.config.retryDelay!
|
||||
)
|
||||
: await executeRequest();
|
||||
|
||||
const processedResponse = await this.applyResponseInterceptors(response);
|
||||
|
||||
// Parse response
|
||||
const contentType = processedResponse.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await processedResponse.json();
|
||||
} else {
|
||||
return await processedResponse.text() as any;
|
||||
}
|
||||
} catch (error) {
|
||||
throw await this.applyResponseInterceptors(Promise.reject(error));
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
get<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...config, method: 'GET' });
|
||||
}
|
||||
|
||||
post<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
put<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
patch<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'PATCH',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
delete<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
|
||||
}
|
||||
|
||||
// File upload
|
||||
upload<T = any>(
|
||||
endpoint: string,
|
||||
file: File,
|
||||
additionalData?: Record<string, any>,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (additionalData) {
|
||||
Object.entries(additionalData).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket connection
|
||||
createWebSocket(endpoint: string): WebSocket {
|
||||
const wsUrl = this.config.baseURL.replace(/^http/, 'ws');
|
||||
return new WebSocket(`${wsUrl}${endpoint}`);
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create default instance
|
||||
export const apiClient = new ApiClient({
|
||||
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000/api'
|
||||
});
|
||||
|
||||
// src/api/base/circuitBreaker.ts
|
||||
export class CircuitBreaker {
|
||||
private failures: number = 0;
|
||||
private lastFailureTime: number = 0;
|
||||
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
|
||||
|
||||
constructor(
|
||||
private threshold: number = 5,
|
||||
private timeout: number = 60000 // 1 minute
|
||||
) {}
|
||||
|
||||
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.state === 'OPEN') {
|
||||
if (Date.now() - this.lastFailureTime > this.timeout) {
|
||||
this.state = 'HALF_OPEN';
|
||||
} else {
|
||||
throw new Error('Circuit breaker is OPEN');
|
||||
}
|
||||
}
|
||||
|
||||
this.refreshPromise = this.performTokenRefresh();
|
||||
|
||||
try {
|
||||
const token = await this.refreshPromise;
|
||||
this.refreshPromise = null;
|
||||
return token;
|
||||
const result = await fn();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.refreshPromise = null;
|
||||
this.onFailure();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async performTokenRefresh(): Promise<string> {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
private onSuccess(): void {
|
||||
this.failures = 0;
|
||||
this.state = 'CLOSED';
|
||||
}
|
||||
|
||||
private onFailure(): void {
|
||||
this.failures++;
|
||||
this.lastFailureTime = Date.now();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post<TokenResponse>(
|
||||
`${this.client.defaults.baseURL}/auth/refresh`,
|
||||
{ refresh_token: refreshToken }
|
||||
);
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
|
||||
return access_token;
|
||||
} catch (error) {
|
||||
throw new Error('Token refresh failed');
|
||||
if (this.failures >= this.threshold) {
|
||||
this.state = 'OPEN';
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredToken(): string | null {
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
private handleAuthFailure(): void {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_profile');
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
private formatError(error: any): ApiError {
|
||||
if (error.response?.data) {
|
||||
return {
|
||||
detail: error.response.data.detail || 'An error occurred',
|
||||
service: error.response.data.service,
|
||||
error_code: error.response.data.error_code,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detail: error.message || 'Network error occurred',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// HTTP methods
|
||||
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.get<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.post<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.put<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.patch<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.delete<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// File upload
|
||||
async uploadFile<T>(url: string, file: File, onProgress?: (progress: number) => void): Promise<T> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await this.client.post<T>(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// WebSocket connection helper
|
||||
createWebSocket(path: string): WebSocket {
|
||||
const wsUrl = this.client.defaults.baseURL?.replace('http', 'ws') + path;
|
||||
return new WebSocket(wsUrl);
|
||||
getState(): string {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
// Default client instance
|
||||
export const apiClient = new ApiClient();
|
||||
// src/api/services/index.ts
|
||||
import { apiClient } from '../base/apiClient';
|
||||
import { AuthService } from './authService';
|
||||
import { TrainingService } from './trainingService';
|
||||
import { ForecastingService } from './forecastingService';
|
||||
import { DataService } from './dataService';
|
||||
import { TenantService } from './tenantService';
|
||||
|
||||
// Service instances with circuit breakers
|
||||
export const authService = new AuthService(apiClient);
|
||||
export const trainingService = new TrainingService(apiClient);
|
||||
export const forecastingService = new ForecastingService(apiClient);
|
||||
export const dataService = new DataService(apiClient);
|
||||
export const tenantService = new TenantService(apiClient);
|
||||
|
||||
// Export types
|
||||
export * from '../types';
|
||||
|
||||
// src/components/common/ErrorBoundary.tsx
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
|
||||
// Send error to monitoring service
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Algo salió mal
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Ha ocurrido un error inesperado. Por favor, recarga la página.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Recargar página
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
0
frontend/src/api/base/circuitBreaker.ts
Normal file
0
frontend/src/api/base/circuitBreaker.ts
Normal file
17
frontend/src/api/services/index.ts
Normal file
17
frontend/src/api/services/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// src/api/services/index.ts
|
||||
import { apiClient } from '../base/apiClient';
|
||||
import { AuthService } from './authService';
|
||||
import { TrainingService } from './trainingService';
|
||||
import { ForecastingService } from './forecastingService';
|
||||
import { DataService } from './dataService';
|
||||
import { TenantService } from './tenantService';
|
||||
|
||||
// Service instances with circuit breakers
|
||||
export const authService = new AuthService(apiClient);
|
||||
export const trainingService = new TrainingService(apiClient);
|
||||
export const forecastingService = new ForecastingService(apiClient);
|
||||
export const dataService = new DataService(apiClient);
|
||||
export const tenantService = new TenantService(apiClient);
|
||||
|
||||
// Export types
|
||||
export * from '../types';
|
||||
233
frontend/src/api/websocket/WebSocketManager.ts
Normal file
233
frontend/src/api/websocket/WebSocketManager.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// src/api/websocket/WebSocketManager.ts
|
||||
import { tokenManager } from '../auth/tokenManager';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface WebSocketConfig {
|
||||
url: string;
|
||||
protocols?: string[];
|
||||
reconnect?: boolean;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
heartbeatInterval?: number;
|
||||
}
|
||||
|
||||
export interface WebSocketHandlers {
|
||||
onOpen?: () => void;
|
||||
onMessage?: (data: any) => void;
|
||||
onError?: (error: Event) => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
onReconnect?: () => void;
|
||||
onReconnectFailed?: () => void;
|
||||
}
|
||||
|
||||
interface WebSocketConnection {
|
||||
ws: WebSocket;
|
||||
config: WebSocketConfig;
|
||||
handlers: WebSocketHandlers;
|
||||
reconnectAttempts: number;
|
||||
heartbeatTimer?: NodeJS.Timeout;
|
||||
reconnectTimer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
class WebSocketManager extends EventEmitter {
|
||||
private static instance: WebSocketManager;
|
||||
private connections: Map<string, WebSocketConnection> = new Map();
|
||||
private baseUrl: string;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
this.baseUrl = this.getWebSocketBaseUrl();
|
||||
}
|
||||
|
||||
static getInstance(): WebSocketManager {
|
||||
if (!WebSocketManager.instance) {
|
||||
WebSocketManager.instance = new WebSocketManager();
|
||||
}
|
||||
return WebSocketManager.instance;
|
||||
}
|
||||
|
||||
async connect(
|
||||
endpoint: string,
|
||||
handlers: WebSocketHandlers,
|
||||
config: Partial<WebSocketConfig> = {}
|
||||
): Promise<WebSocket> {
|
||||
// Get authentication token
|
||||
const token = await tokenManager.getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication required for WebSocket connection');
|
||||
}
|
||||
|
||||
const fullConfig: WebSocketConfig = {
|
||||
url: `${this.baseUrl}${endpoint}`,
|
||||
reconnect: true,
|
||||
reconnectInterval: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
heartbeatInterval: 30000,
|
||||
...config
|
||||
};
|
||||
|
||||
// Add token to URL as query parameter
|
||||
const urlWithAuth = `${fullConfig.url}?token=${token}`;
|
||||
|
||||
const ws = new WebSocket(urlWithAuth, fullConfig.protocols);
|
||||
|
||||
const connection: WebSocketConnection = {
|
||||
ws,
|
||||
config: fullConfig,
|
||||
handlers,
|
||||
reconnectAttempts: 0
|
||||
};
|
||||
|
||||
this.setupWebSocketHandlers(endpoint, connection);
|
||||
this.connections.set(endpoint, connection);
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
disconnect(endpoint: string): void {
|
||||
const connection = this.connections.get(endpoint);
|
||||
if (connection) {
|
||||
this.cleanupConnection(connection);
|
||||
this.connections.delete(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
this.connections.forEach((connection, endpoint) => {
|
||||
this.cleanupConnection(connection);
|
||||
});
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
send(endpoint: string, data: any): void {
|
||||
const connection = this.connections.get(endpoint);
|
||||
if (connection && connection.ws.readyState === WebSocket.OPEN) {
|
||||
connection.ws.send(JSON.stringify(data));
|
||||
} else {
|
||||
console.error(`WebSocket not connected for endpoint: ${endpoint}`);
|
||||
}
|
||||
}
|
||||
|
||||
private setupWebSocketHandlers(endpoint: string, connection: WebSocketConnection): void {
|
||||
const { ws, handlers, config } = connection;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`WebSocket connected: ${endpoint}`);
|
||||
connection.reconnectAttempts = 0;
|
||||
|
||||
// Start heartbeat
|
||||
if (config.heartbeatInterval) {
|
||||
this.startHeartbeat(connection);
|
||||
}
|
||||
|
||||
handlers.onOpen?.();
|
||||
this.emit('connected', endpoint);
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Handle heartbeat response
|
||||
if (data.type === 'pong') {
|
||||
return;
|
||||
}
|
||||
|
||||
handlers.onMessage?.(data);
|
||||
this.emit('message', { endpoint, data });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error: Event) => {
|
||||
console.error(`WebSocket error on ${endpoint}:`, error);
|
||||
handlers.onError?.(error);
|
||||
this.emit('error', { endpoint, error });
|
||||
};
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
console.log(`WebSocket closed: ${endpoint}`, event.code, event.reason);
|
||||
|
||||
// Clear heartbeat
|
||||
if (connection.heartbeatTimer) {
|
||||
clearInterval(connection.heartbeatTimer);
|
||||
}
|
||||
|
||||
handlers.onClose?.(event);
|
||||
this.emit('disconnected', endpoint);
|
||||
|
||||
// Attempt reconnection
|
||||
if (config.reconnect && connection.reconnectAttempts < config.maxReconnectAttempts!) {
|
||||
this.scheduleReconnect(endpoint, connection);
|
||||
} else if (connection.reconnectAttempts >= config.maxReconnectAttempts!) {
|
||||
handlers.onReconnectFailed?.();
|
||||
this.emit('reconnectFailed', endpoint);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect(endpoint: string, connection: WebSocketConnection): void {
|
||||
const { config, handlers, reconnectAttempts } = connection;
|
||||
|
||||
// Exponential backoff
|
||||
const delay = Math.min(
|
||||
config.reconnectInterval! * Math.pow(2, reconnectAttempts),
|
||||
30000 // Max 30 seconds
|
||||
);
|
||||
|
||||
console.log(`Scheduling reconnect for ${endpoint} in ${delay}ms`);
|
||||
|
||||
connection.reconnectTimer = setTimeout(async () => {
|
||||
connection.reconnectAttempts++;
|
||||
|
||||
try {
|
||||
await this.connect(endpoint, handlers, config);
|
||||
handlers.onReconnect?.();
|
||||
this.emit('reconnected', endpoint);
|
||||
} catch (error) {
|
||||
console.error(`Reconnection failed for ${endpoint}:`, error);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private startHeartbeat(connection: WebSocketConnection): void {
|
||||
connection.heartbeatTimer = setInterval(() => {
|
||||
if (connection.ws.readyState === WebSocket.OPEN) {
|
||||
connection.ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, connection.config.heartbeatInterval!);
|
||||
}
|
||||
|
||||
private cleanupConnection(connection: WebSocketConnection): void {
|
||||
if (connection.heartbeatTimer) {
|
||||
clearInterval(connection.heartbeatTimer);
|
||||
}
|
||||
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer);
|
||||
}
|
||||
|
||||
if (connection.ws.readyState === WebSocket.OPEN) {
|
||||
connection.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
private getWebSocketBaseUrl(): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = process.env.REACT_APP_WS_URL || window.location.host;
|
||||
return `${protocol}//${host}/ws`;
|
||||
}
|
||||
|
||||
// Get connection status
|
||||
getConnectionStatus(endpoint: string): number {
|
||||
const connection = this.connections.get(endpoint);
|
||||
return connection ? connection.ws.readyState : WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
isConnected(endpoint: string): boolean {
|
||||
return this.getConnectionStatus(endpoint) === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
export const wsManager = WebSocketManager.getInstance();
|
||||
Reference in New Issue
Block a user