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

@@ -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 <Navigate to="/login" replace />
}
return <>{children}</>
}
import ProtectedRoute from '@/components/ProtectedRoute'
function App() {
return (
@@ -44,6 +33,24 @@ function App() {
<Route path="export" element={<ExportPage />} />
<Route path="tasks" element={<TaskHistoryPage />} />
<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>
{/* Catch all */}

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

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

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

View File

@@ -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<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

View File

@@ -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<string, number>
recent_actions: AuditLog[]
}