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>
570 lines
19 KiB
TypeScript
570 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|