/** * API V2 Client - External Authentication & Task Management * * Features: * - External Azure AD authentication * - Task history and management * - User task isolation * - Session management */ import axios, { AxiosError } from 'axios' import type { AxiosInstance } from 'axios' import type { LoginRequest, ApiError, LoginResponseV2, UserInfo, TaskCreate, TaskUpdate, Task, TaskDetail, TaskListResponse, TaskStats, SessionInfo, SystemStats, UserWithStats, TopUser, AuditLogListResponse, UserActivitySummary, TranslationStats, ProcessingOptions, ProcessingMetadata, DocumentAnalysisResponse, PreprocessingPreviewRequest, PreprocessingPreviewResponse, TranslationRequest, TranslationStartResponse, TranslationStatusResponse, TranslationListResponse, TranslationResult, ExportRule, StorageStats, CleanupResult, AdminTaskListResponse, } from '@/types/apiV2' /** * API Client Configuration * - In Docker: VITE_API_BASE_URL is empty string, use relative path * - In development: Prefer relative path + Vite proxy; optionally set VITE_API_BASE_URL for non-proxied setups */ const envApiBaseUrl = import.meta.env.VITE_API_BASE_URL const API_BASE_URL = envApiBaseUrl !== undefined ? envApiBaseUrl : '' 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: ReturnType | null = null private isRefreshing: boolean = false private refreshFailed: boolean = false 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) { // If refresh has already failed, don't try again - just redirect if (this.refreshFailed) { console.warn('Refresh already failed, redirecting to login') this.clearAuth() window.location.href = '/login' return Promise.reject(error) } // If already refreshing, reject immediately to prevent retry storm if (this.isRefreshing) { console.warn('Token refresh already in progress, rejecting request') return Promise.reject(error) } // 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') this.isRefreshing = true try { await this.refreshToken() this.isRefreshing = false // Retry the original request only if refresh succeeded if (error.config) { return this.client.request(error.config) } } catch (refreshError) { console.error('Token refresh failed, redirecting to login') this.isRefreshing = false this.refreshFailed = true this.clearAuth() window.location.href = '/login' return Promise.reject(error) } } 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 this.isRefreshing = false this.refreshFailed = false // 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 } // ==================== File Upload ==================== /** * Upload a file */ async uploadFile(file: File): Promise<{ task_id: string; filename: string; file_size: number; file_type: string; status: string }> { const formData = new FormData() formData.append('file', file) const response = await this.client.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, }) return response.data } // ==================== 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 with optional dual-track settings and PP-StructureV3 parameters */ async startTask(taskId: string, options?: ProcessingOptions): Promise { // Send full options object in request body (not query params) // Backend will use defaults for any unspecified fields const body = options || { use_dual_track: true, language: 'ch' } const response = await this.client.post(`/tasks/${taskId}/start`, body) return response.data } /** * Analyze document to get recommended processing track */ async analyzeDocument(taskId: string): Promise { const response = await this.client.post(`/tasks/${taskId}/analyze`) return response.data } /** * Get processing metadata for a completed task */ async getProcessingMetadata(taskId: string): Promise { const response = await this.client.get(`/tasks/${taskId}/metadata`) 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 * @param taskId - The task ID * @param format - PDF format: 'layout' (default) or 'reflow' */ async downloadPDF(taskId: string, format: 'layout' | 'reflow' = 'layout'): Promise { const response = await this.client.get(`/tasks/${taskId}/download/pdf`, { params: format === 'reflow' ? { format: 'reflow' } : undefined, responseType: 'blob', }) const blob = new Blob([response.data], { type: 'application/pdf' }) const link = document.createElement('a') link.href = window.URL.createObjectURL(blob) const formatSuffix = format === 'reflow' ? '_reflow' : '_layout' link.download = `${taskId}${formatSuffix}.pdf` link.click() window.URL.revokeObjectURL(link.href) } /** * Download task result as UnifiedDocument JSON */ async downloadUnified(taskId: string): Promise { const response = await this.client.get(`/tasks/${taskId}/download/unified`, { 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}_unified.json` link.click() window.URL.revokeObjectURL(link.href) } /** * Download visualization images as ZIP (OCR Track only) */ async downloadVisualization(taskId: string): Promise { const response = await this.client.get(`/tasks/${taskId}/download/visualization`, { responseType: 'blob', }) const blob = new Blob([response.data], { type: 'application/zip' }) const link = document.createElement('a') link.href = window.URL.createObjectURL(blob) link.download = `${taskId}_visualization.zip` link.click() window.URL.revokeObjectURL(link.href) } // ==================== Preprocessing Preview APIs ==================== /** * Preview preprocessing effect on a page */ async previewPreprocessing( taskId: string, request: PreprocessingPreviewRequest ): Promise { const response = await this.client.post( `/tasks/${taskId}/preview/preprocessing`, request ) return response.data } /** * Get preprocessing preview image URL (with auth token) */ getPreviewImageUrl(taskId: string, type: 'original' | 'preprocessed', page: number): string { const baseUrl = `${API_BASE_URL}/api/${API_VERSION}/tasks/${taskId}/preview/image` return `${baseUrl}?type=${type}&page=${page}` } /** * Get preview image as blob (with authentication) */ async getPreviewImage(taskId: string, type: 'original' | 'preprocessed', page: number): Promise { const response = await this.client.get(`/tasks/${taskId}/preview/image`, { params: { type, page }, responseType: 'blob', }) return response.data } // ==================== Admin APIs ==================== /** * Get system statistics (admin only) */ async getSystemStats(): Promise { const response = await this.client.get('/admin/stats') return response.data } /** * Get user list with statistics (admin only) */ async listUsers(params: { page?: number page_size?: number } = {}): Promise<{ users: UserWithStats[]; total: number; page: number; page_size: number }> { const response = await this.client.get('/admin/users', { params }) return response.data } /** * Get top users by metric (admin only) */ async getTopUsers(params: { metric?: 'tasks' | 'completed_tasks' limit?: number } = {}): Promise { const response = await this.client.get('/admin/users/top', { params }) return response.data } /** * Get audit logs (admin only) */ async getAuditLogs(params: { user_id?: number category?: string action?: string success?: boolean date_from?: string date_to?: string page?: number page_size?: number } = {}): Promise { const response = await this.client.get('/admin/audit-logs', { params }) return response.data } /** * Get user activity summary (admin only) */ async getUserActivitySummary(userId: number, days: number = 30): Promise { const response = await this.client.get( `/admin/audit-logs/user/${userId}/summary`, { params: { days } } ) return response.data } /** * Get translation statistics (admin only) */ async getTranslationStats(): Promise { const response = await this.client.get('/admin/translation-stats') return response.data } // ==================== Translation APIs ==================== /** * Start a translation job */ async startTranslation(taskId: string, request: TranslationRequest): Promise { const response = await this.client.post( `/translate/${taskId}`, request ) return response.data } /** * Get translation status */ async getTranslationStatus(taskId: string): Promise { const response = await this.client.get( `/translate/${taskId}/status` ) return response.data } /** * Get translation result */ async getTranslationResult(taskId: string, lang: string): Promise { const response = await this.client.get( `/translate/${taskId}/result`, { params: { lang } } ) return response.data } /** * Download translation result as JSON file */ async downloadTranslation(taskId: string, lang: string): Promise { const response = await this.client.get(`/translate/${taskId}/result`, { params: { lang }, 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}_translated_${lang}.json` link.click() window.URL.revokeObjectURL(link.href) } /** * List available translations for a task */ async listTranslations(taskId: string): Promise { const response = await this.client.get( `/translate/${taskId}/translations` ) return response.data } /** * Delete a translation */ async deleteTranslation(taskId: string, lang: string): Promise { await this.client.delete(`/translate/${taskId}/translations/${lang}`) } /** * Download translated PDF * @param taskId - The task ID * @param lang - Target language code * @param format - PDF format: 'layout' or 'reflow' (default: 'reflow') */ async downloadTranslatedPdf( taskId: string, lang: string, format: 'layout' | 'reflow' = 'reflow' ): Promise { const response = await this.client.post(`/translate/${taskId}/pdf`, null, { params: { lang, format }, responseType: 'blob', }) const formatSuffix = format === 'layout' ? '_layout' : '_reflow' const blob = new Blob([response.data], { type: 'application/pdf' }) const link = document.createElement('a') link.href = window.URL.createObjectURL(blob) link.download = `${taskId}_translated_${lang}${formatSuffix}.pdf` link.click() window.URL.revokeObjectURL(link.href) } // ==================== Export Rules APIs ==================== /** * Get export rules */ async getExportRules(): Promise { const response = await this.client.get('/export/rules') return response.data } /** * Create export rule */ async createExportRule(rule: Omit): Promise { const response = await this.client.post('/export/rules', rule) return response.data } /** * Update export rule */ async updateExportRule(ruleId: number, rule: Partial): Promise { const response = await this.client.put(`/export/rules/${ruleId}`, rule) return response.data } /** * Delete export rule */ async deleteExportRule(ruleId: number): Promise { await this.client.delete(`/export/rules/${ruleId}`) } // ==================== Admin Storage Management ==================== /** * Get storage statistics (admin only) */ async getStorageStats(): Promise { const response = await this.client.get('/admin/storage/stats') return response.data } /** * Trigger file cleanup (admin only) */ async triggerCleanup(maxFilesPerUser?: number): Promise { const params = maxFilesPerUser ? { max_files_per_user: maxFilesPerUser } : {} const response = await this.client.post('/admin/cleanup/trigger', null, { params }) return response.data } /** * List all tasks (admin only) */ async listAllTasksAdmin(params: { user_id?: number status_filter?: string include_deleted?: boolean include_files_deleted?: boolean page?: number page_size?: number }): Promise { const response = await this.client.get('/admin/tasks', { params }) return response.data } /** * Get task details (admin only, can view any task including deleted) */ async getTaskAdmin(taskId: string): Promise { const response = await this.client.get(`/admin/tasks/${taskId}`) return response.data } } // Export singleton instance export const apiClientV2 = new ApiClientV2()