/** * API V2 Client - External Authentication & Task Management * * Features: * - External Azure AD authentication * - Task history and management * - User task isolation * - Session management */ import axios, { AxiosError, AxiosInstance } from 'axios' import type { LoginRequest, ApiError, } from '@/types/api' import type { LoginResponseV2, UserInfo, TaskCreate, TaskUpdate, Task, TaskDetail, TaskListResponse, TaskStats, SessionInfo, } from '@/types/apiV2' /** * API Client Configuration * - In Docker: VITE_API_BASE_URL is empty string, use relative path * - In development: Use VITE_API_BASE_URL from .env or default to localhost:8000 */ const envApiBaseUrl = import.meta.env.VITE_API_BASE_URL const API_BASE_URL = envApiBaseUrl !== undefined ? envApiBaseUrl : 'http://localhost:8000' const API_VERSION = 'v2' class ApiClientV2 { private client: AxiosInstance private token: string | null = null private userInfo: UserInfo | null = null private tokenExpiresAt: number | null = null private refreshTimer: NodeJS.Timeout | null = null constructor() { this.client = axios.create({ baseURL: `${API_BASE_URL}/api/${API_VERSION}`, timeout: 30000, headers: { 'Content-Type': 'application/json', }, }) // Request interceptor to add auth token this.client.interceptors.request.use( (config) => { if (this.token) { config.headers.Authorization = `Bearer ${this.token}` } return config }, (error) => Promise.reject(error) ) // Response interceptor for error handling this.client.interceptors.response.use( (response) => response, async (error: AxiosError) => { if (error.response?.status === 401) { // Token expired or invalid const detail = error.response?.data?.detail if (detail?.includes('Session expired') || detail?.includes('Invalid session')) { console.warn('Session expired, attempting refresh') // Try to refresh token once try { await this.refreshToken() // Retry the original request if (error.config) { return this.client.request(error.config) } } catch (refreshError) { console.error('Token refresh failed, redirecting to login') this.clearAuth() window.location.href = '/login' } } else { this.clearAuth() window.location.href = '/login' } } return Promise.reject(error) } ) // Load auth data from localStorage this.loadAuth() } /** * Set authentication data */ setAuth(token: string, user: UserInfo, expiresIn?: number) { this.token = token this.userInfo = user localStorage.setItem('auth_token_v2', token) localStorage.setItem('user_info_v2', JSON.stringify(user)) // Schedule token refresh if expiresIn is provided if (expiresIn) { this.tokenExpiresAt = Date.now() + expiresIn * 1000 localStorage.setItem('token_expires_at', this.tokenExpiresAt.toString()) this.scheduleTokenRefresh(expiresIn) } } /** * Clear authentication data */ clearAuth() { this.token = null this.userInfo = null this.tokenExpiresAt = null // Clear refresh timer if (this.refreshTimer) { clearTimeout(this.refreshTimer) this.refreshTimer = null } localStorage.removeItem('auth_token_v2') localStorage.removeItem('user_info_v2') localStorage.removeItem('token_expires_at') } /** * Load auth data from localStorage */ private loadAuth() { const token = localStorage.getItem('auth_token_v2') const userInfoStr = localStorage.getItem('user_info_v2') const expiresAtStr = localStorage.getItem('token_expires_at') if (token && userInfoStr) { try { this.token = token this.userInfo = JSON.parse(userInfoStr) // Load and check token expiry if (expiresAtStr) { this.tokenExpiresAt = parseInt(expiresAtStr, 10) const timeUntilExpiry = this.tokenExpiresAt - Date.now() // If token is expired, clear auth if (timeUntilExpiry <= 0) { console.warn('Token expired, clearing auth') this.clearAuth() return } // Schedule refresh if token is expiring soon const refreshBuffer = 5 * 60 * 1000 // 5 minutes if (timeUntilExpiry < refreshBuffer) { console.log('Token expiring soon, refreshing immediately') this.refreshToken().catch(() => this.clearAuth()) } else { // Schedule refresh for later this.scheduleTokenRefresh(Math.floor(timeUntilExpiry / 1000)) } } } catch (error) { console.error('Failed to parse user info from localStorage:', error) this.clearAuth() } } } /** * Check if user is authenticated */ isAuthenticated(): boolean { return this.token !== null && this.userInfo !== null } /** * Get current user info */ getCurrentUser(): UserInfo | null { return this.userInfo } /** * Schedule token refresh before expiration * @param expiresIn - Token expiry time in seconds */ private scheduleTokenRefresh(expiresIn: number): void { // Clear existing timer if (this.refreshTimer) { clearTimeout(this.refreshTimer) } // Schedule refresh 5 minutes before expiry const refreshBuffer = 5 * 60 // 5 minutes in seconds const refreshTime = Math.max(0, expiresIn - refreshBuffer) * 1000 // Convert to milliseconds console.log(`Scheduling token refresh in ${refreshTime / 1000} seconds`) this.refreshTimer = setTimeout(() => { console.log('Auto-refreshing token') this.refreshToken().catch((error) => { console.error('Auto token refresh failed:', error) // Don't redirect on auto-refresh failure, let user continue // Redirect will happen on next API call with 401 }) }, refreshTime) } /** * Refresh access token */ private async refreshToken(): Promise { try { const response = await this.client.post('/auth/refresh') // Update token and schedule next refresh this.setAuth(response.data.access_token, response.data.user, response.data.expires_in) console.log('Token refreshed successfully') } catch (error) { console.error('Token refresh failed:', error) throw error } } // ==================== Authentication ==================== /** * Login via external Azure AD API */ async login(data: LoginRequest): Promise { const response = await this.client.post('/auth/login', { username: data.username, password: data.password, }) // Store token and user info with auto-refresh this.setAuth(response.data.access_token, response.data.user, response.data.expires_in) return response.data } /** * Logout (invalidate session) */ async logout(sessionId?: number): Promise { try { await this.client.post('/auth/logout', { session_id: sessionId }) } finally { // Always clear local auth data this.clearAuth() } } /** * Get current user info from server */ async getMe(): Promise { const response = await this.client.get('/auth/me') this.userInfo = response.data localStorage.setItem('user_info_v2', JSON.stringify(response.data)) return response.data } /** * List user sessions */ async listSessions(): Promise { const response = await this.client.get<{ sessions: SessionInfo[] }>('/auth/sessions') return response.data.sessions } // ==================== Task Management ==================== /** * Create a new task */ async createTask(data: TaskCreate): Promise { const response = await this.client.post('/tasks/', data) return response.data } /** * List tasks with pagination and filtering */ async listTasks(params: { status?: 'pending' | 'processing' | 'completed' | 'failed' filename?: string date_from?: string date_to?: string page?: number page_size?: number order_by?: string order_desc?: boolean } = {}): Promise { const response = await this.client.get('/tasks/', { params }) return response.data } /** * Get task statistics */ async getTaskStats(): Promise { const response = await this.client.get('/tasks/stats') return response.data } /** * Get task details by ID */ async getTask(taskId: string): Promise { const response = await this.client.get(`/tasks/${taskId}`) return response.data } /** * Update task */ async updateTask(taskId: string, data: TaskUpdate): Promise { const response = await this.client.patch(`/tasks/${taskId}`, data) return response.data } /** * Delete task */ async deleteTask(taskId: string): Promise { await this.client.delete(`/tasks/${taskId}`) } /** * Start task processing */ async startTask(taskId: string): Promise { const response = await this.client.post(`/tasks/${taskId}/start`) return response.data } /** * Cancel task */ async cancelTask(taskId: string): Promise { const response = await this.client.post(`/tasks/${taskId}/cancel`) return response.data } /** * Retry failed task */ async retryTask(taskId: string): Promise { const response = await this.client.post(`/tasks/${taskId}/retry`) return response.data } // ==================== Helper Methods ==================== /** * Download file from task result */ async downloadTaskFile(url: string, filename: string): Promise { const response = await this.client.get(url, { responseType: 'blob', }) // Create download link const blob = new Blob([response.data]) const link = document.createElement('a') link.href = window.URL.createObjectURL(blob) link.download = filename link.click() window.URL.revokeObjectURL(link.href) } /** * Download task result as JSON */ async downloadJSON(taskId: string): Promise { const response = await this.client.get(`/tasks/${taskId}/download/json`, { responseType: 'blob', }) const blob = new Blob([response.data], { type: 'application/json' }) const link = document.createElement('a') link.href = window.URL.createObjectURL(blob) link.download = `${taskId}_result.json` link.click() window.URL.revokeObjectURL(link.href) } /** * Download task result as Markdown */ async downloadMarkdown(taskId: string): Promise { const response = await this.client.get(`/tasks/${taskId}/download/markdown`, { responseType: 'blob', }) const blob = new Blob([response.data], { type: 'text/markdown' }) const link = document.createElement('a') link.href = window.URL.createObjectURL(blob) link.download = `${taskId}_result.md` link.click() window.URL.revokeObjectURL(link.href) } /** * Download task result as PDF */ async downloadPDF(taskId: string): Promise { const response = await this.client.get(`/tasks/${taskId}/download/pdf`, { responseType: 'blob', }) const blob = new Blob([response.data], { type: 'application/pdf' }) const link = document.createElement('a') link.href = window.URL.createObjectURL(blob) link.download = `${taskId}_result.pdf` link.click() window.URL.revokeObjectURL(link.href) } } // Export singleton instance export const apiClientV2 = new ApiClientV2()