Files
OCR/frontend/src/services/apiV2.ts
egg 8f94191914 feat: add admin dashboard, audit logs, token expiry check and test suite
Frontend Features:
- Add ProtectedRoute component with token expiry validation
- Create AdminDashboardPage with system statistics and user management
- Create AuditLogsPage with filtering and pagination
- Add admin-only navigation (Shield icon) for ymirliu@panjit.com.tw
- Add admin API methods to apiV2 service
- Add admin type definitions (SystemStats, AuditLog, etc.)

Token Management:
- Auto-redirect to login on token expiry
- Check authentication on route change
- Show loading state during auth check
- Admin privilege verification

Backend Testing:
- Add pytest configuration (pytest.ini)
- Create test fixtures (conftest.py)
- Add unit tests for auth, tasks, and admin endpoints
- Add integration tests for complete workflows
- Test user isolation and admin access control

Documentation:
- Add TESTING.md with comprehensive testing guide
- Include test running instructions
- Document fixtures and best practices

Routes:
- /admin - Admin dashboard (admin only)
- /admin/audit-logs - Audit logs viewer (admin only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:01:50 +08:00

499 lines
13 KiB
TypeScript

/**
* 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,
} from '@/types/api'
import type {
LoginResponseV2,
UserInfo,
TaskCreate,
TaskUpdate,
Task,
TaskDetail,
TaskListResponse,
TaskStats,
SessionInfo,
SystemStats,
UserWithStats,
TopUser,
AuditLog,
AuditLogListResponse,
UserActivitySummary,
} 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<ApiError>) => {
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<void> {
try {
const response = await this.client.post<LoginResponseV2>('/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<LoginResponseV2> {
const response = await this.client.post<LoginResponseV2>('/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<void> {
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<UserInfo> {
const response = await this.client.get<UserInfo>('/auth/me')
this.userInfo = response.data
localStorage.setItem('user_info_v2', JSON.stringify(response.data))
return response.data
}
/**
* List user sessions
*/
async listSessions(): Promise<SessionInfo[]> {
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<Task> {
const response = await this.client.post<Task>('/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<TaskListResponse> {
const response = await this.client.get<TaskListResponse>('/tasks/', { params })
return response.data
}
/**
* Get task statistics
*/
async getTaskStats(): Promise<TaskStats> {
const response = await this.client.get<TaskStats>('/tasks/stats')
return response.data
}
/**
* Get task details by ID
*/
async getTask(taskId: string): Promise<TaskDetail> {
const response = await this.client.get<TaskDetail>(`/tasks/${taskId}`)
return response.data
}
/**
* Update task
*/
async updateTask(taskId: string, data: TaskUpdate): Promise<Task> {
const response = await this.client.patch<Task>(`/tasks/${taskId}`, data)
return response.data
}
/**
* Delete task
*/
async deleteTask(taskId: string): Promise<void> {
await this.client.delete(`/tasks/${taskId}`)
}
/**
* Start task processing
*/
async startTask(taskId: string): Promise<Task> {
const response = await this.client.post<Task>(`/tasks/${taskId}/start`)
return response.data
}
/**
* Cancel task
*/
async cancelTask(taskId: string): Promise<Task> {
const response = await this.client.post<Task>(`/tasks/${taskId}/cancel`)
return response.data
}
/**
* Retry failed task
*/
async retryTask(taskId: string): Promise<Task> {
const response = await this.client.post<Task>(`/tasks/${taskId}/retry`)
return response.data
}
// ==================== Helper Methods ====================
/**
* Download file from task result
*/
async downloadTaskFile(url: string, filename: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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)
}
// ==================== Admin APIs ====================
/**
* Get system statistics (admin only)
*/
async getSystemStats(): Promise<SystemStats> {
const response = await this.client.get<SystemStats>('/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<TopUser[]> {
const response = await this.client.get<TopUser[]>('/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<AuditLogListResponse> {
const response = await this.client.get<AuditLogListResponse>('/admin/audit-logs', { params })
return response.data
}
/**
* Get user activity summary (admin only)
*/
async getUserActivitySummary(userId: number, days: number = 30): Promise<UserActivitySummary> {
const response = await this.client.get<UserActivitySummary>(
`/admin/audit-logs/user/${userId}/summary`,
{ params: { days } }
)
return response.data
}
}
// Export singleton instance
export const apiClientV2 = new ApiClientV2()