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>
This commit is contained in:
258
TESTING.md
Normal file
258
TESTING.md
Normal file
@@ -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
|
||||||
@@ -1,32 +1,13 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
# Pytest configuration for Tool_OCR backend tests
|
testpaths = tests
|
||||||
|
|
||||||
# Test discovery patterns
|
|
||||||
python_files = test_*.py
|
python_files = test_*.py
|
||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
|
|
||||||
# Directories to search for tests
|
|
||||||
testpaths = tests
|
|
||||||
|
|
||||||
# Output options
|
|
||||||
addopts =
|
addopts =
|
||||||
-v
|
-v
|
||||||
--strict-markers
|
--strict-markers
|
||||||
--tb=short
|
--tb=short
|
||||||
--color=yes
|
|
||||||
--maxfail=5
|
|
||||||
|
|
||||||
# Markers for categorizing tests
|
|
||||||
markers =
|
markers =
|
||||||
unit: Unit tests for individual components
|
unit: Unit tests
|
||||||
integration: Integration tests for service interactions
|
integration: Integration tests
|
||||||
slow: Tests that take longer to run
|
slow: Slow running tests
|
||||||
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
|
|
||||||
|
|||||||
56
backend/tests/test_admin.py
Normal file
56
backend/tests/test_admin.py
Normal file
@@ -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
|
||||||
75
backend/tests/test_auth.py
Normal file
75
backend/tests/test_auth.py
Normal file
@@ -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'
|
||||||
161
backend/tests/test_integration.py
Normal file
161
backend/tests/test_integration.py
Normal file
@@ -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
|
||||||
105
backend/tests/test_tasks.py
Normal file
105
backend/tests/test_tasks.py
Normal file
@@ -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)
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
|
||||||
import LoginPage from '@/pages/LoginPage'
|
import LoginPage from '@/pages/LoginPage'
|
||||||
import UploadPage from '@/pages/UploadPage'
|
import UploadPage from '@/pages/UploadPage'
|
||||||
import ProcessingPage from '@/pages/ProcessingPage'
|
import ProcessingPage from '@/pages/ProcessingPage'
|
||||||
@@ -7,20 +6,10 @@ import ResultsPage from '@/pages/ResultsPage'
|
|||||||
import ExportPage from '@/pages/ExportPage'
|
import ExportPage from '@/pages/ExportPage'
|
||||||
import SettingsPage from '@/pages/SettingsPage'
|
import SettingsPage from '@/pages/SettingsPage'
|
||||||
import TaskHistoryPage from '@/pages/TaskHistoryPage'
|
import TaskHistoryPage from '@/pages/TaskHistoryPage'
|
||||||
|
import AdminDashboardPage from '@/pages/AdminDashboardPage'
|
||||||
|
import AuditLogsPage from '@/pages/AuditLogsPage'
|
||||||
import Layout from '@/components/Layout'
|
import Layout from '@/components/Layout'
|
||||||
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
/**
|
|
||||||
* Protected Route Component
|
|
||||||
*/
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to="/login" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -44,6 +33,24 @@ function App() {
|
|||||||
<Route path="export" element={<ExportPage />} />
|
<Route path="export" element={<ExportPage />} />
|
||||||
<Route path="tasks" element={<TaskHistoryPage />} />
|
<Route path="tasks" element={<TaskHistoryPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
|
{/* Admin routes - require admin privileges */}
|
||||||
|
<Route
|
||||||
|
path="admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminDashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/audit-logs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AuditLogsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch all */}
|
{/* Catch all */}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Bell,
|
Bell,
|
||||||
Search,
|
Search,
|
||||||
History
|
History,
|
||||||
|
Shield
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -22,6 +23,9 @@ export default function Layout() {
|
|||||||
const logout = useAuthStore((state) => state.logout)
|
const logout = useAuthStore((state) => state.logout)
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const isAdmin = user?.email === 'ymirliu@panjit.com.tw'
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
// Use V2 API if authenticated with V2
|
// Use V2 API if authenticated with V2
|
||||||
@@ -38,14 +42,18 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: '/upload', label: t('nav.upload'), icon: Upload, description: '上傳檔案' },
|
{ to: '/upload', label: t('nav.upload'), icon: Upload, description: '上傳檔案', adminOnly: false },
|
||||||
{ to: '/processing', label: t('nav.processing'), icon: Activity, description: '處理進度' },
|
{ to: '/processing', label: t('nav.processing'), icon: Activity, description: '處理進度', adminOnly: false },
|
||||||
{ to: '/results', label: t('nav.results'), icon: FileText, description: '查看結果' },
|
{ to: '/results', label: t('nav.results'), icon: FileText, description: '查看結果', adminOnly: false },
|
||||||
{ to: '/tasks', label: '任務歷史', icon: History, description: '查看任務記錄' },
|
{ to: '/tasks', label: '任務歷史', icon: History, description: '查看任務記錄', adminOnly: false },
|
||||||
{ to: '/export', label: t('nav.export'), icon: Download, description: '導出文件' },
|
{ to: '/export', label: t('nav.export'), icon: Download, description: '導出文件', adminOnly: false },
|
||||||
{ to: '/settings', label: t('nav.settings'), icon: Settings, description: '系統設定' },
|
{ 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 (
|
return (
|
||||||
<div className="flex h-screen bg-background overflow-hidden">
|
<div className="flex h-screen bg-background overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -65,7 +73,7 @@ export default function Layout() {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto scrollbar-thin">
|
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto scrollbar-thin">
|
||||||
{navLinks.map((link) => (
|
{visibleNavLinks.map((link) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={link.to}
|
key={link.to}
|
||||||
to={link.to}
|
to={link.to}
|
||||||
|
|||||||
93
frontend/src/components/ProtectedRoute.tsx
Normal file
93
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Protected Route Component
|
||||||
|
* Checks authentication and token validity before rendering protected content
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { apiClientV2 } from '@/services/apiV2'
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
requireAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const [isChecking, setIsChecking] = useState(true)
|
||||||
|
const [isValid, setIsValid] = useState(false)
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">驗證中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
if (!isValid) {
|
||||||
|
if (requireAdmin && apiClientV2.isAuthenticated() && !isAdmin) {
|
||||||
|
// User is authenticated but not admin
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-4">訪問被拒絕</h1>
|
||||||
|
<p className="text-gray-600 mb-4">您沒有權限訪問此頁面</p>
|
||||||
|
<a href="/" className="text-blue-600 hover:underline">返回首頁</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
306
frontend/src/pages/AdminDashboardPage.tsx
Normal file
306
frontend/src/pages/AdminDashboardPage.tsx
Normal file
@@ -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<SystemStats | null>(null)
|
||||||
|
const [users, setUsers] = useState<UserWithStats[]>([])
|
||||||
|
const [topUsers, setTopUsers] = useState<TopUser[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600">載入管理員儀表板...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<XCircle className="w-5 h-5 text-red-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-red-600 font-semibold">載入失敗</p>
|
||||||
|
<p className="text-red-500 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-8 h-8 text-blue-600" />
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">管理員儀表板</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 mt-1">系統統計與用戶管理</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => navigate('/admin/audit-logs')} variant="outline">
|
||||||
|
<Activity className="w-4 h-4 mr-2" />
|
||||||
|
審計日誌
|
||||||
|
</Button>
|
||||||
|
<Button onClick={fetchData} variant="outline">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System 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 flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
總用戶數
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.total_users}</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
活躍: {stats.active_users}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<ClipboardList className="w-4 h-4" />
|
||||||
|
總任務數
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.total_tasks}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
待處理
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-gray-600">
|
||||||
|
{stats.task_stats.pending}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4" />
|
||||||
|
處理中
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{stats.task_stats.processing}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
已完成
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{stats.task_stats.completed}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
失敗: {stats.task_stats.failed}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Users */}
|
||||||
|
{topUsers.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
活躍用戶排行
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>任務數量最多的用戶</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">#</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>顯示名稱</TableHead>
|
||||||
|
<TableHead className="text-right">總任務</TableHead>
|
||||||
|
<TableHead className="text-right">已完成</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{topUsers.map((user, index) => (
|
||||||
|
<TableRow key={user.user_id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Badge variant={index === 0 ? 'default' : 'secondary'}>
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>{user.display_name || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right font-semibold">
|
||||||
|
{user.task_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-green-600">
|
||||||
|
{user.completed_tasks}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Users */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
最近用戶
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>最新註冊的用戶列表</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>暫無用戶</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>顯示名稱</TableHead>
|
||||||
|
<TableHead>註冊時間</TableHead>
|
||||||
|
<TableHead>最後登入</TableHead>
|
||||||
|
<TableHead>狀態</TableHead>
|
||||||
|
<TableHead className="text-right">任務數</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.email}</TableCell>
|
||||||
|
<TableCell>{user.display_name || '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm text-gray-600">
|
||||||
|
{formatDate(user.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-gray-600">
|
||||||
|
{formatDate(user.last_login)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.is_active ? 'default' : 'secondary'}>
|
||||||
|
{user.is_active ? '活躍' : '停用'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{user.task_count}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
完成: {user.completed_tasks} | 失敗: {user.failed_tasks}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
324
frontend/src/pages/AuditLogsPage.tsx
Normal file
324
frontend/src/pages/AuditLogsPage.tsx
Normal file
@@ -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<AuditLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||||
|
const [successFilter, setSuccessFilter] = useState<string>('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<string, { variant: any; label: string }> = {
|
||||||
|
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 <Badge variant={config.variant}>{config.label}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<Shield className="w-8 h-8 text-blue-600" />
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">審計日誌</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 mt-1">系統操作記錄與審計追蹤</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => fetchLogs()} variant="outline">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</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-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">類別</label>
|
||||||
|
<Select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCategoryFilter(e.target.value)
|
||||||
|
handleFilterChange()
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'auth', label: '認證' },
|
||||||
|
{ value: 'task', label: '任務' },
|
||||||
|
{ value: 'file', label: '檔案' },
|
||||||
|
{ value: 'admin', label: '管理' },
|
||||||
|
{ value: 'system', label: '系統' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">狀態</label>
|
||||||
|
<Select
|
||||||
|
value={successFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSuccessFilter(e.target.value)
|
||||||
|
handleFilterChange()
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'true', label: '成功' },
|
||||||
|
{ value: 'false', label: '失敗' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(categoryFilter !== 'all' || successFilter !== 'all') && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCategoryFilter('all')
|
||||||
|
setSuccessFilter('all')
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audit Logs 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>
|
||||||
|
) : logs.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>狀態</TableHead>
|
||||||
|
<TableHead>錯誤訊息</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell className="text-sm text-gray-600">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{log.user_email}</div>
|
||||||
|
<div className="text-xs text-gray-500">ID: {log.user_id}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getCategoryBadge(log.category)}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{log.action}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.resource_type ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-gray-700">{log.resource_type}</div>
|
||||||
|
{log.resource_id && (
|
||||||
|
<div className="text-xs text-gray-500 font-mono">
|
||||||
|
{log.resource_id}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.success ? (
|
||||||
|
<Badge variant="default" className="flex items-center gap-1 w-fit">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
成功
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive" className="flex items-center gap-1 w-fit">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
失敗
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.error_message ? (
|
||||||
|
<span className="text-sm text-red-600">{log.error_message}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,12 @@ import type {
|
|||||||
TaskListResponse,
|
TaskListResponse,
|
||||||
TaskStats,
|
TaskStats,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
|
SystemStats,
|
||||||
|
UserWithStats,
|
||||||
|
TopUser,
|
||||||
|
AuditLog,
|
||||||
|
AuditLogListResponse,
|
||||||
|
UserActivitySummary,
|
||||||
} from '@/types/apiV2'
|
} from '@/types/apiV2'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -426,6 +432,66 @@ class ApiClientV2 {
|
|||||||
link.click()
|
link.click()
|
||||||
window.URL.revokeObjectURL(link.href)
|
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 singleton instance
|
||||||
|
|||||||
@@ -115,3 +115,72 @@ export interface TaskFilters {
|
|||||||
order_by: string
|
order_by: string
|
||||||
order_desc: boolean
|
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<string, number>
|
||||||
|
recent_actions: AuditLog[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user