diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..ba64dc2 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,258 @@ +# Tool_OCR Testing Guide + +## 測試架構 + +本專案包含完整的測試套件,包括單元測試和集成測試。 + +--- + +## 後端測試 + +### 安裝測試依賴 + +```bash +cd backend +pip install pytest pytest-cov httpx +``` + +### 運行所有測試 + +```bash +# 運行所有測試 +pytest + +# 運行並顯示詳細輸出 +pytest -v + +# 運行並生成覆蓋率報告 +pytest --cov=app --cov-report=html +``` + +### 運行特定測試 + +```bash +# 僅運行單元測試 +pytest tests/test_auth.py +pytest tests/test_tasks.py +pytest tests/test_admin.py + +# 僅運行集成測試 +pytest tests/test_integration.py + +# 運行特定測試類 +pytest tests/test_tasks.py::TestTasks + +# 運行特定測試方法 +pytest tests/test_tasks.py::TestTasks::test_create_task +``` + +### 測試覆蓋 + +**單元測試** (`tests/test_*.py`): +- `test_auth.py` - 認證端點測試 + - 登入成功/失敗 + - Token 驗證 + - 登出功能 +- `test_tasks.py` - 任務管理測試 + - 任務 CRUD 操作 + - 用戶隔離驗證 + - 統計數據 +- `test_admin.py` - 管理員功能測試 + - 系統統計 + - 用戶列表 + - 審計日誌 + +**集成測試** (`tests/test_integration.py`): +- 完整認證和任務流程 +- 管理員工作流程 +- 任務生命週期 + +--- + +## 測試資料庫 + +測試使用 SQLite 記憶體資料庫,每次測試後自動清理: +- 不影響開發或生產資料庫 +- 快速執行 +- 完全隔離 + +--- + +## Fixtures (測試夾具) + +在 `conftest.py` 中定義: + +- `db` - 測試資料庫 session +- `client` - FastAPI 測試客戶端 +- `test_user` - 一般測試用戶 +- `admin_user` - 管理員測試用戶 +- `auth_token` - 測試用戶的認證 token +- `admin_token` - 管理員的認證 token +- `test_task` - 測試任務 + +--- + +## 測試範例 + +### 編寫新的單元測試 + +```python +# tests/test_my_feature.py + +import pytest + + +class TestMyFeature: + """Test my new feature""" + + def test_feature_works(self, client, auth_token): + """Test that feature works correctly""" + response = client.get( + '/api/v2/my-endpoint', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert 'expected_field' in data +``` + +### 編寫新的集成測試 + +```python +# tests/test_integration.py + +class TestIntegration: + + def test_complete_workflow(self, client, db): + """Test complete user workflow""" + # Step 1: Login + # Step 2: Perform actions + # Step 3: Verify results + pass +``` + +--- + +## CI/CD 整合 + +### GitHub Actions 範例 + +```yaml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + cd backend + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run tests + run: | + cd backend + pytest --cov=app --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v2 +``` + +--- + +## 前端測試 (未來計劃) + +### 建議測試框架 +- **單元測試**: Vitest +- **元件測試**: React Testing Library +- **E2E 測試**: Playwright + +### 範例配置 + +```bash +# 安裝測試依賴 +npm install --save-dev vitest @testing-library/react @testing-library/jest-dom + +# 運行測試 +npm test + +# 運行 E2E 測試 +npm run test:e2e +``` + +--- + +## 測試最佳實踐 + +### 1. 測試命名規範 +- 使用描述性名稱: `test_user_can_create_task` +- 遵循 AAA 模式: Arrange, Act, Assert + +### 2. 測試隔離 +- 每個測試獨立執行 +- 使用 fixtures 提供測試數據 +- 不依賴其他測試的狀態 + +### 3. Mock 外部服務 +- Mock 外部 API 呼叫 +- Mock 檔案系統操作 +- Mock 第三方服務 + +### 4. 測試覆蓋率目標 +- 核心業務邏輯: >90% +- API 端點: >80% +- 工具函數: >70% + +--- + +## 故障排除 + +### 常見問題 + +**問題**: `ImportError: cannot import name 'XXX'` +**解決**: 確保 PYTHONPATH 正確設定 +```bash +export PYTHONPATH=$PYTHONPATH:$(pwd) +``` + +**問題**: 資料庫連接錯誤 +**解決**: 測試使用記憶體資料庫,不需要實際資料庫連接 + +**問題**: Token 驗證失敗 +**解決**: 檢查 JWT secret 設定,使用測試用 fixtures + +--- + +## 測試報告 + +執行測試後生成的報告: + +1. **終端輸出**: 測試結果概覽 +2. **HTML 報告**: `htmlcov/index.html` (需要 --cov-report=html) +3. **覆蓋率報告**: 顯示未測試的代碼行 + +--- + +## 持續改進 + +- 定期運行測試套件 +- 新功能必須包含測試 +- 維護測試覆蓋率在 80% 以上 +- Bug 修復時添加回歸測試 + +--- + +**最後更新**: 2025-11-16 +**維護者**: Development Team diff --git a/backend/pytest.ini b/backend/pytest.ini index 73a18a6..ed69978 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,32 +1,13 @@ [pytest] -# Pytest configuration for Tool_OCR backend tests - -# Test discovery patterns +testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* - -# Directories to search for tests -testpaths = tests - -# Output options addopts = -v --strict-markers --tb=short - --color=yes - --maxfail=5 - -# Markers for categorizing tests markers = - unit: Unit tests for individual components - integration: Integration tests for service interactions - slow: Tests that take longer to run - requires_models: Tests that require PaddleOCR models - -# Coverage options (optional) -# addopts = --cov=app --cov-report=html --cov-report=term - -# Logging -log_cli = false -log_cli_level = INFO + unit: Unit tests + integration: Integration tests + slow: Slow running tests diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..f83f0ec --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,56 @@ +""" +Unit tests for admin endpoints +""" + +import pytest + + +class TestAdmin: + """Test admin endpoints""" + + def test_get_system_stats(self, client, admin_token): + """Test get system statistics""" + response = client.get( + '/api/v2/admin/stats', + headers={'Authorization': f'Bearer {admin_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert 'total_users' in data + assert 'total_tasks' in data + assert 'task_stats' in data + + def test_get_system_stats_non_admin(self, client, auth_token): + """Test that non-admin cannot access admin endpoints""" + response = client.get( + '/api/v2/admin/stats', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 403 + + def test_list_users(self, client, admin_token): + """Test list all users""" + response = client.get( + '/api/v2/admin/users', + headers={'Authorization': f'Bearer {admin_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert 'users' in data + assert 'total' in data + + def test_get_audit_logs(self, client, admin_token): + """Test get audit logs""" + response = client.get( + '/api/v2/admin/audit-logs', + headers={'Authorization': f'Bearer {admin_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert 'logs' in data + assert 'total' in data + assert 'page' in data diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..fef9ea5 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,75 @@ +""" +Unit tests for authentication endpoints +""" + +import pytest +from unittest.mock import patch, MagicMock + + +class TestAuth: + """Test authentication endpoints""" + + def test_login_success(self, client, db): + """Test successful login""" + # Mock external auth service + with patch('app.routers.auth.external_auth_service.authenticate_user') as mock_auth: + mock_auth.return_value = (True, { + 'access_token': 'test-token', + 'expires_in': 3600, + 'user_info': { + 'email': 'test@example.com', + 'name': 'Test User' + } + }, None) + + response = client.post('/api/v2/auth/login', json={ + 'username': 'test@example.com', + 'password': 'password123' + }) + + assert response.status_code == 200 + data = response.json() + assert 'access_token' in data + assert data['token_type'] == 'bearer' + assert 'user' in data + + def test_login_invalid_credentials(self, client): + """Test login with invalid credentials""" + with patch('app.routers.auth.external_auth_service.authenticate_user') as mock_auth: + mock_auth.return_value = (False, None, 'Invalid credentials') + + response = client.post('/api/v2/auth/login', json={ + 'username': 'test@example.com', + 'password': 'wrongpassword' + }) + + assert response.status_code == 401 + assert 'detail' in response.json() + + def test_get_me(self, client, auth_token): + """Test get current user info""" + response = client.get( + '/api/v2/auth/me', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert 'email' in data + assert 'display_name' in data + + def test_get_me_unauthorized(self, client): + """Test get current user without token""" + response = client.get('/api/v2/auth/me') + assert response.status_code == 403 + + def test_logout(self, client, auth_token): + """Test logout""" + response = client.post( + '/api/v2/auth/logout', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert data['message'] == 'Logged out successfully' diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py new file mode 100644 index 0000000..07c4a51 --- /dev/null +++ b/backend/tests/test_integration.py @@ -0,0 +1,161 @@ +""" +Integration tests for Tool_OCR +Tests the complete flow of authentication, task creation, and file operations +""" + +import pytest +from unittest.mock import patch + + +class TestIntegration: + """Integration tests for end-to-end workflows""" + + def test_complete_auth_and_task_flow(self, client, db): + """Test complete flow: login -> create task -> get task -> delete task""" + + # Step 1: Login + with patch('app.routers.auth.external_auth_service.authenticate_user') as mock_auth: + mock_auth.return_value = (True, { + 'access_token': 'test-token', + 'expires_in': 3600, + 'user_info': { + 'email': 'integration@example.com', + 'name': 'Integration Test User' + } + }, None) + + login_response = client.post('/api/v2/auth/login', json={ + 'username': 'integration@example.com', + 'password': 'password123' + }) + + assert login_response.status_code == 200 + token = login_response.json()['access_token'] + headers = {'Authorization': f'Bearer {token}'} + + # Step 2: Create task + create_response = client.post( + '/api/v2/tasks/', + headers=headers, + json={ + 'filename': 'integration_test.pdf', + 'file_type': 'application/pdf' + } + ) + + assert create_response.status_code == 201 + task_data = create_response.json() + task_id = task_data['task_id'] + + # Step 3: Get task + get_response = client.get( + f'/api/v2/tasks/{task_id}', + headers=headers + ) + + assert get_response.status_code == 200 + assert get_response.json()['task_id'] == task_id + + # Step 4: List tasks + list_response = client.get( + '/api/v2/tasks/', + headers=headers + ) + + assert list_response.status_code == 200 + assert len(list_response.json()['tasks']) > 0 + + # Step 5: Get stats + stats_response = client.get( + '/api/v2/tasks/stats', + headers=headers + ) + + assert stats_response.status_code == 200 + stats = stats_response.json() + assert stats['total'] > 0 + assert stats['pending'] > 0 + + # Step 6: Delete task + delete_response = client.delete( + f'/api/v2/tasks/{task_id}', + headers=headers + ) + + assert delete_response.status_code == 200 + + # Step 7: Verify deletion + get_after_delete = client.get( + f'/api/v2/tasks/{task_id}', + headers=headers + ) + + assert get_after_delete.status_code == 404 + + def test_admin_workflow(self, client, db): + """Test admin workflow: login as admin -> access admin endpoints""" + + # Login as admin + with patch('app.routers.auth.external_auth_service.authenticate_user') as mock_auth: + mock_auth.return_value = (True, { + 'access_token': 'admin-token', + 'expires_in': 3600, + 'user_info': { + 'email': 'ymirliu@panjit.com.tw', + 'name': 'Admin User' + } + }, None) + + login_response = client.post('/api/v2/auth/login', json={ + 'username': 'ymirliu@panjit.com.tw', + 'password': 'adminpass' + }) + + assert login_response.status_code == 200 + token = login_response.json()['access_token'] + headers = {'Authorization': f'Bearer {token}'} + + # Access admin endpoints + stats_response = client.get('/api/v2/admin/stats', headers=headers) + assert stats_response.status_code == 200 + + users_response = client.get('/api/v2/admin/users', headers=headers) + assert users_response.status_code == 200 + + logs_response = client.get('/api/v2/admin/audit-logs', headers=headers) + assert logs_response.status_code == 200 + + def test_task_lifecycle(self, client, auth_token, test_task, db): + """Test complete task lifecycle: pending -> processing -> completed""" + + headers = {'Authorization': f'Bearer {auth_token}'} + + # Check initial status + response = client.get(f'/api/v2/tasks/{test_task.task_id}', headers=headers) + assert response.json()['status'] == 'pending' + + # Start task + start_response = client.post( + f'/api/v2/tasks/{test_task.task_id}/start', + headers=headers + ) + assert start_response.status_code == 200 + assert start_response.json()['status'] == 'processing' + + # Update task to completed + update_response = client.patch( + f'/api/v2/tasks/{test_task.task_id}', + headers=headers, + json={ + 'status': 'completed', + 'processing_time_ms': 1500 + } + ) + assert update_response.status_code == 200 + assert update_response.json()['status'] == 'completed' + + # Verify final state + final_response = client.get(f'/api/v2/tasks/{test_task.task_id}', headers=headers) + final_data = final_response.json() + assert final_data['status'] == 'completed' + assert final_data['processing_time_ms'] == 1500 diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py new file mode 100644 index 0000000..5a63757 --- /dev/null +++ b/backend/tests/test_tasks.py @@ -0,0 +1,105 @@ +""" +Unit tests for task management endpoints +""" + +import pytest +from app.models.task import Task + + +class TestTasks: + """Test task management endpoints""" + + def test_create_task(self, client, auth_token): + """Test task creation""" + response = client.post( + '/api/v2/tasks/', + headers={'Authorization': f'Bearer {auth_token}'}, + json={ + 'filename': 'test.pdf', + 'file_type': 'application/pdf' + } + ) + + assert response.status_code == 201 + data = response.json() + assert 'task_id' in data + assert data['filename'] == 'test.pdf' + assert data['status'] == 'pending' + + def test_list_tasks(self, client, auth_token, test_task): + """Test listing user tasks""" + response = client.get( + '/api/v2/tasks/', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert 'tasks' in data + assert 'total' in data + assert len(data['tasks']) > 0 + + def test_get_task(self, client, auth_token, test_task): + """Test get single task""" + response = client.get( + f'/api/v2/tasks/{test_task.task_id}', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert data['task_id'] == test_task.task_id + + def test_get_task_stats(self, client, auth_token, test_task): + """Test get task statistics""" + response = client.get( + '/api/v2/tasks/stats', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 200 + data = response.json() + assert 'total' in data + assert 'pending' in data + assert 'processing' in data + assert 'completed' in data + assert 'failed' in data + + def test_delete_task(self, client, auth_token, test_task): + """Test task deletion""" + response = client.delete( + f'/api/v2/tasks/{test_task.task_id}', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 200 + + def test_user_isolation(self, client, db, test_user): + """Test that users can only access their own tasks""" + # Create another user + from app.models.user import User + other_user = User(email="other@example.com", display_name="Other User") + db.add(other_user) + db.commit() + + # Create task for other user + other_task = Task( + user_id=other_user.id, + task_id="other-task-123", + filename="other.pdf", + status="pending" + ) + db.add(other_task) + db.commit() + + # Create token for test_user + from app.core.security import create_access_token + token = create_access_token({"sub": str(test_user.id)}) + + # Try to access other user's task + response = client.get( + f'/api/v2/tasks/{other_task.task_id}', + headers={'Authorization': f'Bearer {token}'} + ) + + assert response.status_code == 404 # Task not found (user isolation) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d150264..1fde0bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,4 @@ import { Routes, Route, Navigate } from 'react-router-dom' -import { useAuthStore } from '@/store/authStore' import LoginPage from '@/pages/LoginPage' import UploadPage from '@/pages/UploadPage' import ProcessingPage from '@/pages/ProcessingPage' @@ -7,20 +6,10 @@ import ResultsPage from '@/pages/ResultsPage' import ExportPage from '@/pages/ExportPage' import SettingsPage from '@/pages/SettingsPage' import TaskHistoryPage from '@/pages/TaskHistoryPage' +import AdminDashboardPage from '@/pages/AdminDashboardPage' +import AuditLogsPage from '@/pages/AuditLogsPage' import Layout from '@/components/Layout' - -/** - * Protected Route Component - */ -function ProtectedRoute({ children }: { children: React.ReactNode }) { - const isAuthenticated = useAuthStore((state) => state.isAuthenticated) - - if (!isAuthenticated) { - return - } - - return <>{children}> -} +import ProtectedRoute from '@/components/ProtectedRoute' function App() { return ( @@ -44,6 +33,24 @@ function App() { } /> } /> } /> + + {/* Admin routes - require admin privileges */} + + + + } + /> + + + + } + /> {/* Catch all */} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 277536f..80c29ce 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -14,7 +14,8 @@ import { ChevronRight, Bell, Search, - History + History, + Shield } from 'lucide-react' export default function Layout() { @@ -22,6 +23,9 @@ export default function Layout() { const logout = useAuthStore((state) => state.logout) const user = useAuthStore((state) => state.user) + // Check if user is admin + const isAdmin = user?.email === 'ymirliu@panjit.com.tw' + const handleLogout = async () => { try { // Use V2 API if authenticated with V2 @@ -38,14 +42,18 @@ export default function Layout() { } 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: '系統設定' }, + { to: '/upload', label: t('nav.upload'), icon: Upload, description: '上傳檔案', adminOnly: false }, + { to: '/processing', label: t('nav.processing'), icon: Activity, description: '處理進度', adminOnly: false }, + { to: '/results', label: t('nav.results'), icon: FileText, description: '查看結果', adminOnly: false }, + { to: '/tasks', label: '任務歷史', icon: History, description: '查看任務記錄', adminOnly: false }, + { to: '/export', label: t('nav.export'), icon: Download, description: '導出文件', adminOnly: false }, + { to: '/settings', label: t('nav.settings'), icon: Settings, description: '系統設定', adminOnly: false }, + { to: '/admin', label: '管理員儀表板', icon: Shield, description: '系統管理', adminOnly: true }, ] + // Filter nav links based on admin status + const visibleNavLinks = navLinks.filter(link => !link.adminOnly || isAdmin) + return ( {/* Sidebar */} @@ -65,7 +73,7 @@ export default function Layout() { {/* Navigation */} - {navLinks.map((link) => ( + {visibleNavLinks.map((link) => ( { + const checkAuth = async () => { + try { + // Check if user is authenticated + if (!apiClientV2.isAuthenticated()) { + console.warn('User not authenticated, redirecting to login') + setIsValid(false) + setIsChecking(false) + return + } + + // Verify token with backend + const user = await apiClientV2.getMe() + + // Check if user has admin privileges (if required) + if (requireAdmin) { + const adminEmails = ['ymirliu@panjit.com.tw'] // Admin email list + const userIsAdmin = adminEmails.includes(user.email) + setIsAdmin(userIsAdmin) + + if (!userIsAdmin) { + console.warn('User is not admin, access denied') + setIsValid(false) + setIsChecking(false) + return + } + } + + setIsValid(true) + setIsChecking(false) + } catch (error) { + console.error('Authentication check failed:', error) + apiClientV2.clearAuth() + setIsValid(false) + setIsChecking(false) + } + } + + checkAuth() + }, [requireAdmin, location.pathname]) + + // Show loading while checking + if (isChecking) { + return ( + + + + 驗證中... + + + ) + } + + // Redirect to login if not authenticated + if (!isValid) { + if (requireAdmin && apiClientV2.isAuthenticated() && !isAdmin) { + // User is authenticated but not admin + return ( + + + 訪問被拒絕 + 您沒有權限訪問此頁面 + 返回首頁 + + + ) + } + + return + } + + return <>{children}> +} diff --git a/frontend/src/pages/AdminDashboardPage.tsx b/frontend/src/pages/AdminDashboardPage.tsx new file mode 100644 index 0000000..c63b381 --- /dev/null +++ b/frontend/src/pages/AdminDashboardPage.tsx @@ -0,0 +1,306 @@ +/** + * Admin Dashboard Page + * System statistics and user management for administrators + */ + +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { apiClientV2 } from '@/services/apiV2' +import type { SystemStats, UserWithStats, TopUser } from '@/types/apiV2' +import { + Users, + ClipboardList, + Activity, + TrendingUp, + RefreshCw, + Shield, + CheckCircle2, + XCircle, + Clock, + Loader2, +} from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' + +export default function AdminDashboardPage() { + const navigate = useNavigate() + const [stats, setStats] = useState(null) + const [users, setUsers] = useState([]) + const [topUsers, setTopUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // Fetch all data + const fetchData = async () => { + try { + setLoading(true) + setError('') + + const [statsData, usersData, topUsersData] = await Promise.all([ + apiClientV2.getSystemStats(), + apiClientV2.listUsers({ page: 1, page_size: 10 }), + apiClientV2.getTopUsers({ metric: 'tasks', limit: 5 }), + ]) + + setStats(statsData) + setUsers(usersData.users) + setTopUsers(topUsersData) + } catch (err: any) { + console.error('Failed to fetch admin data:', err) + setError(err.response?.data?.detail || '載入管理員資料失敗') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + // Format date + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + const date = new Date(dateStr) + return date.toLocaleString('zh-TW') + } + + if (loading) { + return ( + + + + 載入管理員儀表板... + + + ) + } + + if (error) { + return ( + + + + + 載入失敗 + {error} + + + + ) + } + + return ( + + {/* Header */} + + + + + 管理員儀表板 + + 系統統計與用戶管理 + + + navigate('/admin/audit-logs')} variant="outline"> + + 審計日誌 + + + + 刷新 + + + + + {/* System Statistics */} + {stats && ( + + + + + + 總用戶數 + + + + {stats.total_users} + + 活躍: {stats.active_users} + + + + + + + + + 總任務數 + + + + {stats.total_tasks} + + + + + + + + 待處理 + + + + + {stats.task_stats.pending} + + + + + + + + + 處理中 + + + + + {stats.task_stats.processing} + + + + + + + + + 已完成 + + + + + {stats.task_stats.completed} + + + 失敗: {stats.task_stats.failed} + + + + + )} + + {/* Top Users */} + {topUsers.length > 0 && ( + + + + + 活躍用戶排行 + + 任務數量最多的用戶 + + + + + + # + Email + 顯示名稱 + 總任務 + 已完成 + + + + {topUsers.map((user, index) => ( + + + + {index + 1} + + + {user.email} + {user.display_name || '-'} + + {user.task_count} + + + {user.completed_tasks} + + + ))} + + + + + )} + + {/* Recent Users */} + + + + + 最近用戶 + + 最新註冊的用戶列表 + + + {users.length === 0 ? ( + + + 暫無用戶 + + ) : ( + + + + Email + 顯示名稱 + 註冊時間 + 最後登入 + 狀態 + 任務數 + + + + {users.map((user) => ( + + {user.email} + {user.display_name || '-'} + + {formatDate(user.created_at)} + + + {formatDate(user.last_login)} + + + + {user.is_active ? '活躍' : '停用'} + + + + + {user.task_count} + + 完成: {user.completed_tasks} | 失敗: {user.failed_tasks} + + + + + ))} + + + )} + + + + ) +} diff --git a/frontend/src/pages/AuditLogsPage.tsx b/frontend/src/pages/AuditLogsPage.tsx new file mode 100644 index 0000000..dcf6e62 --- /dev/null +++ b/frontend/src/pages/AuditLogsPage.tsx @@ -0,0 +1,324 @@ +/** + * Audit Logs Page + * View and filter system audit logs (admin only) + */ + +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { apiClientV2 } from '@/services/apiV2' +import type { AuditLog } from '@/types/apiV2' +import { + FileText, + RefreshCw, + Filter, + CheckCircle2, + XCircle, + Loader2, + AlertCircle, + Shield, + ChevronLeft, +} from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Select } from '@/components/ui/select' + +export default function AuditLogsPage() { + const navigate = useNavigate() + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // Filters + const [categoryFilter, setCategoryFilter] = useState('all') + const [successFilter, setSuccessFilter] = useState('all') + const [page, setPage] = useState(1) + const [pageSize] = useState(50) + const [total, setTotal] = useState(0) + const [hasMore, setHasMore] = useState(false) + + // Fetch logs + const fetchLogs = async () => { + try { + setLoading(true) + setError('') + + const params: any = { + page, + page_size: pageSize, + } + + if (categoryFilter !== 'all') { + params.category = categoryFilter + } + + if (successFilter !== 'all') { + params.success = successFilter === 'true' + } + + const response = await apiClientV2.getAuditLogs(params) + + setLogs(response.logs) + setTotal(response.total) + setHasMore(response.has_more) + } catch (err: any) { + console.error('Failed to fetch audit logs:', err) + setError(err.response?.data?.detail || '載入審計日誌失敗') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchLogs() + }, [categoryFilter, successFilter, page]) + + // Reset to page 1 when filters change + const handleFilterChange = () => { + setPage(1) + } + + // Format date + const formatDate = (dateStr: string) => { + const date = new Date(dateStr) + return date.toLocaleString('zh-TW') + } + + // Get category badge + const getCategoryBadge = (category: string) => { + const variants: Record = { + auth: { variant: 'default', label: '認證' }, + task: { variant: 'secondary', label: '任務' }, + file: { variant: 'secondary', label: '檔案' }, + admin: { variant: 'destructive', label: '管理' }, + system: { variant: 'secondary', label: '系統' }, + } + + const config = variants[category] || { variant: 'secondary', label: category } + + return {config.label} + } + + return ( + + {/* Header */} + + + + navigate('/admin')} + className="mr-2" + > + + 返回 + + + 審計日誌 + + 系統操作記錄與審計追蹤 + + fetchLogs()} variant="outline"> + + 刷新 + + + + {/* Filters */} + + + + + 篩選條件 + + + + + + 類別 + { + setCategoryFilter(e.target.value) + handleFilterChange() + }} + options={[ + { value: 'all', label: '全部' }, + { value: 'auth', label: '認證' }, + { value: 'task', label: '任務' }, + { value: 'file', label: '檔案' }, + { value: 'admin', label: '管理' }, + { value: 'system', label: '系統' }, + ]} + /> + + + + 狀態 + { + setSuccessFilter(e.target.value) + handleFilterChange() + }} + options={[ + { value: 'all', label: '全部' }, + { value: 'true', label: '成功' }, + { value: 'false', label: '失敗' }, + ]} + /> + + + + {(categoryFilter !== 'all' || successFilter !== 'all') && ( + + { + setCategoryFilter('all') + setSuccessFilter('all') + handleFilterChange() + }} + > + 清除篩選 + + + )} + + + + {/* Error Alert */} + {error && ( + + + {error} + + )} + + {/* Audit Logs List */} + + + 審計日誌記錄 + + 共 {total} 筆記錄 {hasMore && `(顯示第 ${page} 頁)`} + + + + {loading ? ( + + + + ) : logs.length === 0 ? ( + + + 暫無審計日誌 + + ) : ( + <> + + + + 時間 + 用戶 + 類別 + 操作 + 資源 + 狀態 + 錯誤訊息 + + + + {logs.map((log) => ( + + + {formatDate(log.created_at)} + + + + {log.user_email} + ID: {log.user_id} + + + {getCategoryBadge(log.category)} + {log.action} + + {log.resource_type ? ( + + {log.resource_type} + {log.resource_id && ( + + {log.resource_id} + + )} + + ) : ( + - + )} + + + {log.success ? ( + + + 成功 + + ) : ( + + + 失敗 + + )} + + + {log.error_message ? ( + {log.error_message} + ) : ( + - + )} + + + ))} + + + + {/* Pagination */} + + + 顯示 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} / 共{' '} + {total} 筆 + + + setPage((p) => Math.max(1, p - 1))} + disabled={page === 1} + > + 上一頁 + + setPage((p) => p + 1)} + disabled={!hasMore} + > + 下一頁 + + + + > + )} + + + + ) +} diff --git a/frontend/src/services/apiV2.ts b/frontend/src/services/apiV2.ts index ffcfcdf..9a26720 100644 --- a/frontend/src/services/apiV2.ts +++ b/frontend/src/services/apiV2.ts @@ -24,6 +24,12 @@ import type { TaskListResponse, TaskStats, SessionInfo, + SystemStats, + UserWithStats, + TopUser, + AuditLog, + AuditLogListResponse, + UserActivitySummary, } from '@/types/apiV2' /** @@ -426,6 +432,66 @@ class ApiClientV2 { link.click() window.URL.revokeObjectURL(link.href) } + + // ==================== 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 + } } // Export singleton instance diff --git a/frontend/src/types/apiV2.ts b/frontend/src/types/apiV2.ts index ad594e6..f635743 100644 --- a/frontend/src/types/apiV2.ts +++ b/frontend/src/types/apiV2.ts @@ -115,3 +115,72 @@ export interface TaskFilters { order_by: string order_desc: boolean } + +// ==================== Admin Types ==================== + +export interface SystemStats { + total_users: number + active_users: number + total_tasks: number + total_sessions: number + recent_activity_count: number + task_stats: { + pending: number + processing: number + completed: number + failed: number + } +} + +export interface UserWithStats { + id: number + email: string + display_name: string | null + created_at: string + last_login: string | null + is_active: boolean + task_count: number + completed_tasks: number + failed_tasks: number +} + +export interface TopUser { + user_id: number + email: string + display_name: string | null + task_count: number + completed_tasks: number +} + +export interface AuditLog { + id: number + user_id: number + user_email: string + category: 'auth' | 'task' | 'file' | 'admin' | 'system' + action: string + resource_type: string | null + resource_id: string | null + success: boolean + error_message: string | null + extra_data: string | null + created_at: string +} + +export interface AuditLogListResponse { + logs: AuditLog[] + total: number + page: number + page_size: number + has_more: boolean +} + +export interface UserActivitySummary { + user_id: number + email: string + display_name: string | null + total_actions: number + successful_actions: number + failed_actions: number + categories: Record + recent_actions: AuditLog[] +}
驗證中...
您沒有權限訪問此頁面
載入管理員儀表板...
載入失敗
{error}
系統統計與用戶管理
+ 活躍: {stats.active_users} +
+ 失敗: {stats.task_stats.failed} +
暫無用戶
系統操作記錄與審計追蹤
暫無審計日誌