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:
@@ -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}
|
||||
|
||||
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}</>
|
||||
}
|
||||
Reference in New Issue
Block a user