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:
egg
2025-11-16 18:01:50 +08:00
parent fd98018ddd
commit 8f94191914
13 changed files with 1554 additions and 45 deletions

View 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>
)
}