feat: complete external auth V2 migration with advanced features

This commit implements comprehensive external Azure AD authentication
with complete task management, file download, and admin monitoring systems.

## Core Features Implemented (80% Complete)

### 1. Token Auto-Refresh Mechanism 
- Backend: POST /api/v2/auth/refresh endpoint
- Frontend: Auto-refresh 5 minutes before expiration
- Auto-retry on 401 errors with seamless token refresh

### 2. File Download System 
- Three format support: JSON / Markdown / PDF
- Endpoints: GET /api/v2/tasks/{id}/download/{format}
- File access control with ownership validation
- Frontend download buttons in TaskHistoryPage

### 3. Complete Task Management 
Backend Endpoints:
- POST /api/v2/tasks/{id}/start - Start task
- POST /api/v2/tasks/{id}/cancel - Cancel task
- POST /api/v2/tasks/{id}/retry - Retry failed task
- GET /api/v2/tasks - List with filters (status, filename, date range)
- GET /api/v2/tasks/stats - User statistics

Frontend Features:
- Status-based action buttons (Start/Cancel/Retry)
- Advanced search and filtering (status, filename, date range)
- Pagination and sorting
- Task statistics dashboard (5 stat cards)

### 4. Admin Monitoring System  (Backend)
Admin APIs:
- GET /api/v2/admin/stats - System statistics
- GET /api/v2/admin/users - User list with stats
- GET /api/v2/admin/users/top - User leaderboard
- GET /api/v2/admin/audit-logs - Audit log query system
- GET /api/v2/admin/audit-logs/user/{id}/summary

Admin Features:
- Email-based admin check (ymirliu@panjit.com.tw)
- Comprehensive system metrics (users, tasks, sessions, activity)
- Audit logging service for security tracking

### 5. User Isolation & Security 
- Row-level security on all task queries
- File access control with ownership validation
- Strict user_id filtering on all operations
- Session validation and expiry checking
- Admin privilege verification

## New Files Created

Backend:
- backend/app/models/user_v2.py - User model for external auth
- backend/app/models/task.py - Task model with user isolation
- backend/app/models/session.py - Session management
- backend/app/models/audit_log.py - Audit log model
- backend/app/services/external_auth_service.py - External API client
- backend/app/services/task_service.py - Task CRUD with isolation
- backend/app/services/file_access_service.py - File access control
- backend/app/services/admin_service.py - Admin operations
- backend/app/services/audit_service.py - Audit logging
- backend/app/routers/auth_v2.py - V2 auth endpoints
- backend/app/routers/tasks.py - Task management endpoints
- backend/app/routers/admin.py - Admin endpoints
- backend/alembic/versions/5e75a59fb763_*.py - DB migration

Frontend:
- frontend/src/services/apiV2.ts - Complete V2 API client
- frontend/src/types/apiV2.ts - V2 type definitions
- frontend/src/pages/TaskHistoryPage.tsx - Task history UI

Modified Files:
- backend/app/core/deps.py - Added get_current_admin_user_v2
- backend/app/main.py - Registered admin router
- frontend/src/pages/LoginPage.tsx - V2 login integration
- frontend/src/components/Layout.tsx - User display and logout
- frontend/src/App.tsx - Added /tasks route

## Documentation
- openspec/changes/.../PROGRESS_UPDATE.md - Detailed progress report

## Pending Items (20%)
1. Database migration execution for audit_logs table
2. Frontend admin dashboard page
3. Frontend audit log viewer

## Testing Status
- Manual testing:  Authentication flow verified
- Unit tests:  Pending
- Integration tests:  Pending

## Security Enhancements
-  User isolation (row-level security)
-  File access control
-  Token expiry validation
-  Admin privilege verification
-  Audit logging infrastructure
-  Token encryption (noted, low priority)
-  Rate limiting (noted, low priority)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-11-14 17:19:43 +08:00
parent 470fa96428
commit ad2b832fb6
32 changed files with 6450 additions and 26 deletions

View File

@@ -6,6 +6,7 @@ import ProcessingPage from '@/pages/ProcessingPage'
import ResultsPage from '@/pages/ResultsPage'
import ExportPage from '@/pages/ExportPage'
import SettingsPage from '@/pages/SettingsPage'
import TaskHistoryPage from '@/pages/TaskHistoryPage'
import Layout from '@/components/Layout'
/**
@@ -41,6 +42,7 @@ function App() {
<Route path="processing" element={<ProcessingPage />} />
<Route path="results" element={<ResultsPage />} />
<Route path="export" element={<ExportPage />} />
<Route path="tasks" element={<TaskHistoryPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>

View File

@@ -2,6 +2,7 @@ import { Outlet, NavLink } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/store/authStore'
import { apiClient } from '@/services/api'
import { apiClientV2 } from '@/services/apiV2'
import {
Upload,
Settings,
@@ -12,7 +13,8 @@ import {
LayoutDashboard,
ChevronRight,
Bell,
Search
Search,
History
} from 'lucide-react'
export default function Layout() {
@@ -20,15 +22,26 @@ export default function Layout() {
const logout = useAuthStore((state) => state.logout)
const user = useAuthStore((state) => state.user)
const handleLogout = () => {
apiClient.logout()
logout()
const handleLogout = async () => {
try {
// Use V2 API if authenticated with V2
if (apiClientV2.isAuthenticated()) {
await apiClientV2.logout()
} else {
apiClient.logout()
}
} catch (error) {
console.error('Logout error:', error)
} finally {
logout()
}
}
const navLinks = [
{ to: '/upload', label: t('nav.upload'), icon: Upload, description: '上傳檔案' },
{ to: '/processing', label: t('nav.processing'), icon: Activity, description: '處理進度' },
{ to: '/results', label: t('nav.results'), icon: FileText, description: '查看結果' },
{ to: '/tasks', label: '任務歷史', icon: History, description: '查看任務記錄' },
{ to: '/export', label: t('nav.export'), icon: Download, description: '導出文件' },
{ to: '/settings', label: t('nav.settings'), icon: Settings, description: '系統設定' },
]
@@ -86,8 +99,8 @@ export default function Layout() {
{user.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.username}</div>
<div className="text-xs text-sidebar-foreground/60"></div>
<div className="text-sm font-medium truncate">{user.displayName || user.username}</div>
<div className="text-xs text-sidebar-foreground/60 truncate">{user.email || user.username}</div>
</div>
</div>
)}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/store/authStore'
import { apiClient } from '@/services/api'
import { apiClientV2 } from '@/services/apiV2'
import { Lock, User, LayoutDashboard, AlertCircle, Loader2, Sparkles, Zap, Shield } from 'lucide-react'
export default function LoginPage() {
@@ -20,8 +20,17 @@ export default function LoginPage() {
setLoading(true)
try {
await apiClient.login({ username, password })
setUser({ id: 1, username })
// Use V2 API with external authentication
const response = await apiClientV2.login({ username, password })
// Store user info from V2 API response
setUser({
id: response.user.id,
username: response.user.email,
email: response.user.email,
displayName: response.user.display_name
})
navigate('/upload')
} catch (err: any) {
const errorDetail = err.response?.data?.detail

View File

@@ -0,0 +1,569 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiClientV2 } from '@/services/apiV2'
import type { Task, TaskStats, TaskStatus } from '@/types/apiV2'
import {
Clock,
CheckCircle2,
XCircle,
Loader2,
Download,
Trash2,
Eye,
FileText,
AlertCircle,
RefreshCw,
Filter,
Play,
X,
RotateCcw,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export default function TaskHistoryPage() {
const navigate = useNavigate()
const [tasks, setTasks] = useState<Task[]>([])
const [stats, setStats] = useState<TaskStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Filters
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all')
const [filenameSearch, setFilenameSearch] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
const [hasMore, setHasMore] = useState(false)
// Fetch tasks
const fetchTasks = async () => {
try {
setLoading(true)
setError('')
const response = await apiClientV2.listTasks({
status: statusFilter === 'all' ? undefined : statusFilter,
filename: filenameSearch || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
page,
page_size: pageSize,
order_by: 'created_at',
order_desc: true,
})
setTasks(response.tasks)
setTotal(response.total)
setHasMore(response.has_more)
} catch (err: any) {
setError(err.response?.data?.detail || '載入任務失敗')
} finally {
setLoading(false)
}
}
// Reset to page 1 when filters change
const handleFilterChange = () => {
setPage(1)
}
// Fetch stats
const fetchStats = async () => {
try {
const statsData = await apiClientV2.getTaskStats()
setStats(statsData)
} catch (err) {
console.error('Failed to fetch stats:', err)
}
}
// Initial load
useEffect(() => {
fetchTasks()
}, [statusFilter, filenameSearch, dateFrom, dateTo, page])
useEffect(() => {
fetchStats()
}, [])
// Delete task
const handleDelete = async (taskId: string) => {
if (!confirm('確定要刪除此任務嗎?')) return
try {
await apiClientV2.deleteTask(taskId)
fetchTasks()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.detail || '刪除任務失敗')
}
}
// View task details
const handleViewDetails = (taskId: string) => {
navigate(`/tasks/${taskId}`)
}
// Download handlers
const handleDownload = async (taskId: string, format: 'json' | 'markdown' | 'pdf') => {
try {
if (format === 'json') {
await apiClientV2.downloadJSON(taskId)
} else if (format === 'markdown') {
await apiClientV2.downloadMarkdown(taskId)
} else if (format === 'pdf') {
await apiClientV2.downloadPDF(taskId)
}
} catch (err: any) {
alert(err.response?.data?.detail || `下載 ${format.toUpperCase()} 檔案失敗`)
}
}
// Task management handlers
const handleStartTask = async (taskId: string) => {
try {
await apiClientV2.startTask(taskId)
fetchTasks()
} catch (err: any) {
alert(err.response?.data?.detail || '啟動任務失敗')
}
}
const handleCancelTask = async (taskId: string) => {
if (!confirm('確定要取消此任務嗎?')) return
try {
await apiClientV2.cancelTask(taskId)
fetchTasks()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.detail || '取消任務失敗')
}
}
const handleRetryTask = async (taskId: string) => {
try {
await apiClientV2.retryTask(taskId)
fetchTasks()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.detail || '重試任務失敗')
}
}
// Format date
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-TW')
}
// Format processing time
const formatProcessingTime = (ms: number | null) => {
if (!ms) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
// Get status badge
const getStatusBadge = (status: TaskStatus) => {
const variants: Record<TaskStatus, { variant: any; icon: any; label: string }> = {
pending: {
variant: 'secondary',
icon: Clock,
label: '待處理',
},
processing: {
variant: 'default',
icon: Loader2,
label: '處理中',
},
completed: {
variant: 'default',
icon: CheckCircle2,
label: '已完成',
},
failed: {
variant: 'destructive',
icon: XCircle,
label: '失敗',
},
}
const config = variants[status]
const Icon = config.icon
return (
<Badge variant={config.variant} className="flex items-center gap-1">
<Icon className={`w-3 h-3 ${status === 'processing' ? 'animate-spin' : ''}`} />
{config.label}
</Badge>
)
}
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"> OCR </p>
</div>
<Button onClick={() => fetchTasks()} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
{/* Statistics */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{stats.processing}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{stats.failed}</div>
</CardContent>
</Card>
</div>
)}
{/* Filters */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<Select
value={statusFilter}
onValueChange={(value) => {
setStatusFilter(value as any)
handleFilterChange()
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="processing"></SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<input
type="text"
value={filenameSearch}
onChange={(e) => {
setFilenameSearch(e.target.value)
handleFilterChange()
}}
placeholder="搜尋檔案名稱"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<input
type="date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value)
handleFilterChange()
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<input
type="date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value)
handleFilterChange()
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{(statusFilter !== 'all' || filenameSearch || dateFrom || dateTo) && (
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={() => {
setStatusFilter('all')
setFilenameSearch('')
setDateFrom('')
setDateTo('')
handleFilterChange()
}}
>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600" />
<p className="text-red-600">{error}</p>
</div>
)}
{/* Task List */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
{total} {hasMore && `(顯示第 ${page} 頁)`}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : tasks.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p></p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-medium">
{task.filename || '未命名檔案'}
</TableCell>
<TableCell>{getStatusBadge(task.status)}</TableCell>
<TableCell className="text-sm text-gray-600">
{formatDate(task.created_at)}
</TableCell>
<TableCell className="text-sm text-gray-600">
{task.completed_at ? formatDate(task.completed_at) : '-'}
</TableCell>
<TableCell className="text-sm text-gray-600">
{formatProcessingTime(task.processing_time_ms)}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{/* Task management actions */}
{task.status === 'pending' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleStartTask(task.task_id)}
title="開始處理"
>
<Play className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleCancelTask(task.task_id)}
title="取消"
>
<X className="w-4 h-4" />
</Button>
</>
)}
{task.status === 'processing' && (
<Button
variant="outline"
size="sm"
onClick={() => handleCancelTask(task.task_id)}
title="取消"
>
<X className="w-4 h-4" />
</Button>
)}
{task.status === 'failed' && (
<Button
variant="outline"
size="sm"
onClick={() => handleRetryTask(task.task_id)}
title="重試"
>
<RotateCcw className="w-4 h-4" />
</Button>
)}
{/* Download actions for completed tasks */}
{task.status === 'completed' && (
<>
{task.result_json_path && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(task.task_id, 'json')}
title="下載 JSON"
>
<Download className="w-3 h-3 mr-1" />
JSON
</Button>
)}
{task.result_markdown_path && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(task.task_id, 'markdown')}
title="下載 Markdown"
>
<Download className="w-3 h-3 mr-1" />
MD
</Button>
)}
{task.result_pdf_path && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(task.task_id, 'pdf')}
title="下載 PDF"
>
<Download className="w-3 h-3 mr-1" />
PDF
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleViewDetails(task.task_id)}
title="查看詳情"
>
<Eye className="w-4 h-4" />
</Button>
</>
)}
{/* Delete button for all statuses */}
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(task.task_id)}
title="刪除"
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-600">
{(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} / {' '}
{total}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
>
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,431 @@
/**
* 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<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)
}
}
// Export singleton instance
export const apiClientV2 = new ApiClientV2()

View File

@@ -18,6 +18,8 @@ export interface LoginResponse {
export interface User {
id: number
username: string
email?: string
displayName?: string | null
}
// File Upload

117
frontend/src/types/apiV2.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* API V2 Type Definitions
* External Authentication & Task Management
*/
// ==================== Authentication ====================
export interface UserInfo {
id: number
email: string
display_name: string | null
}
export interface LoginResponseV2 {
access_token: string
token_type: string
expires_in: number
user: UserInfo
}
export interface UserResponse {
id: number
email: string
display_name: string | null
created_at: string
last_login: string | null
is_active: boolean
}
export interface SessionInfo {
id: number
token_type: string
expires_at: string
issued_at: string
ip_address: string | null
user_agent: string | null
created_at: string
last_accessed_at: string
is_expired: boolean
time_until_expiry: number
}
// ==================== Task Management ====================
export type TaskStatus = 'pending' | 'processing' | 'completed' | 'failed'
export interface TaskCreate {
filename?: string
file_type?: string
}
export interface TaskUpdate {
status?: TaskStatus
error_message?: string
processing_time_ms?: number
result_json_path?: string
result_markdown_path?: string
result_pdf_path?: string
}
export interface Task {
id: number
user_id: number
task_id: string
filename: string | null
file_type: string | null
status: TaskStatus
result_json_path: string | null
result_markdown_path: string | null
result_pdf_path: string | null
error_message: string | null
processing_time_ms: number | null
created_at: string
updated_at: string
completed_at: string | null
file_deleted: boolean
}
export interface TaskFile {
id: number
original_name: string | null
stored_path: string | null
file_size: number | null
mime_type: string | null
file_hash: string | null
created_at: string
}
export interface TaskDetail extends Task {
files: TaskFile[]
}
export interface TaskListResponse {
tasks: Task[]
total: number
page: number
page_size: number
has_more: boolean
}
export interface TaskStats {
total: number
pending: number
processing: number
completed: number
failed: number
}
// ==================== Task Filters ====================
export interface TaskFilters {
status?: TaskStatus
page: number
page_size: number
order_by: string
order_desc: boolean
}