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

@@ -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 (
<div className="flex h-screen bg-background overflow-hidden">
{/* Sidebar */}
@@ -65,7 +73,7 @@ export default function Layout() {
{/* Navigation */}
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto scrollbar-thin">
{navLinks.map((link) => (
{visibleNavLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}

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