Added admin panel.
This commit is contained in:
173
frontend/src/components/admin/AdminAuditLog.tsx
Normal file
173
frontend/src/components/admin/AdminAuditLog.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { Loading } from '@/components/common/Loading'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { ClipboardList, ChevronLeft, ChevronRight, Filter, Clock } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
DATA_WIPE: { label: 'Data Wipe', color: 'bg-red-100 text-red-800' },
|
||||
DATA_SEED: { label: 'Data Seed', color: 'bg-blue-100 text-blue-800' },
|
||||
SIMULATION_START: { label: 'Simulation Start', color: 'bg-green-100 text-green-800' },
|
||||
SIMULATION_STOP: { label: 'Simulation Stop', color: 'bg-yellow-100 text-yellow-800' },
|
||||
USER_STATUS_CHANGE: { label: 'User Status', color: 'bg-purple-100 text-purple-800' },
|
||||
USER_BALANCE_ADJUST: { label: 'Balance Adjust', color: 'bg-indigo-100 text-indigo-800' },
|
||||
USER_ADMIN_GRANT: { label: 'Admin Grant', color: 'bg-blue-100 text-blue-800' },
|
||||
USER_ADMIN_REVOKE: { label: 'Admin Revoke', color: 'bg-orange-100 text-orange-800' },
|
||||
USER_UPDATE: { label: 'User Update', color: 'bg-gray-100 text-gray-800' },
|
||||
SETTINGS_UPDATE: { label: 'Settings Update', color: 'bg-cyan-100 text-cyan-800' },
|
||||
EVENT_CREATE: { label: 'Event Create', color: 'bg-green-100 text-green-800' },
|
||||
EVENT_UPDATE: { label: 'Event Update', color: 'bg-blue-100 text-blue-800' },
|
||||
EVENT_DELETE: { label: 'Event Delete', color: 'bg-red-100 text-red-800' },
|
||||
}
|
||||
|
||||
export const AdminAuditLog = () => {
|
||||
const [page, setPage] = useState(1)
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
const [expandedLog, setExpandedLog] = useState<number | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-audit-logs', page, actionFilter],
|
||||
queryFn: () =>
|
||||
adminApi.getAuditLogs({
|
||||
page,
|
||||
page_size: 25,
|
||||
action: actionFilter || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / data.page_size) : 1
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardList className="text-gray-400" size={24} />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Audit Log</h2>
|
||||
<p className="text-sm text-gray-500">Track all admin actions on the platform</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={18} className="text-gray-400" />
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => {
|
||||
setActionFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{Object.entries(ACTION_LABELS).map(([value, { label }]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs List */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{data?.logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No audit logs found</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{data?.logs.map((log) => {
|
||||
const actionInfo = ACTION_LABELS[log.action] || {
|
||||
label: log.action,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
const isExpanded = expandedLog === log.id
|
||||
let details: Record<string, any> | null = null
|
||||
try {
|
||||
details = log.details ? JSON.parse(log.details) : null
|
||||
} catch {
|
||||
details = null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`p-4 hover:bg-gray-50 cursor-pointer transition-colors ${
|
||||
isExpanded ? 'bg-gray-50' : ''
|
||||
}`}
|
||||
onClick={() => setExpandedLog(isExpanded ? null : log.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${actionInfo.color}`}
|
||||
>
|
||||
{actionInfo.label}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-900">{log.description}</p>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500">
|
||||
<span>By: {log.admin_username}</span>
|
||||
{log.target_type && (
|
||||
<span>
|
||||
Target: {log.target_type} #{log.target_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Clock size={14} />
|
||||
<span>{format(new Date(log.created_at), 'MMM d, yyyy h:mm a')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && details && (
|
||||
<div className="mt-4 p-3 bg-gray-100 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-600 mb-2">Details:</p>
|
||||
<pre className="text-xs text-gray-700 overflow-x-auto">
|
||||
{JSON.stringify(details, null, 2)}
|
||||
</pre>
|
||||
{log.ip_address && (
|
||||
<p className="mt-2 text-xs text-gray-500">IP: {log.ip_address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
|
||||
<p className="text-sm text-gray-600">
|
||||
Page {page} of {totalPages} ({data?.total || 0} total logs)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setPage(page - 1)} disabled={page === 1}>
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
282
frontend/src/components/admin/AdminDataTools.tsx
Normal file
282
frontend/src/components/admin/AdminDataTools.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Loading } from '@/components/common/Loading'
|
||||
import { AlertTriangle, Database, Trash2, RefreshCw } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import type { SeedRequest } from '@/types/admin'
|
||||
|
||||
export const AdminDataTools = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [showWipeModal, setShowWipeModal] = useState(false)
|
||||
const [confirmPhrase, setConfirmPhrase] = useState('')
|
||||
const [preserveAdmins, setPreserveAdmins] = useState(true)
|
||||
const [preserveEvents, setPreserveEvents] = useState(false)
|
||||
|
||||
const [seedConfig, setSeedConfig] = useState<SeedRequest>({
|
||||
num_users: 10,
|
||||
num_events: 5,
|
||||
num_bets_per_event: 3,
|
||||
starting_balance: 1000,
|
||||
create_admin: true,
|
||||
})
|
||||
|
||||
const { data: wipePreview, isLoading: isLoadingPreview, refetch: refetchPreview } = useQuery({
|
||||
queryKey: ['wipe-preview'],
|
||||
queryFn: adminApi.getWipePreview,
|
||||
})
|
||||
|
||||
const wipeMutation = useMutation({
|
||||
mutationFn: adminApi.executeWipe,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
setShowWipeModal(false)
|
||||
setConfirmPhrase('')
|
||||
queryClient.invalidateQueries()
|
||||
refetchPreview()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Wipe failed')
|
||||
},
|
||||
})
|
||||
|
||||
const seedMutation = useMutation({
|
||||
mutationFn: adminApi.seedDatabase,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
if (data.test_admin) {
|
||||
toast.success(`Test admin created: ${data.test_admin.username} / ${data.test_admin.password}`, {
|
||||
duration: 10000,
|
||||
})
|
||||
}
|
||||
queryClient.invalidateQueries()
|
||||
refetchPreview()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Seed failed')
|
||||
},
|
||||
})
|
||||
|
||||
const handleWipe = () => {
|
||||
wipeMutation.mutate({
|
||||
confirmation_phrase: confirmPhrase,
|
||||
preserve_admin_users: preserveAdmins,
|
||||
preserve_events: preserveEvents,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSeed = () => {
|
||||
seedMutation.mutate(seedConfig)
|
||||
}
|
||||
|
||||
if (isLoadingPreview) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Wipe Preview */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Trash2 className="text-red-500" size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Data Wiper</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="text-red-800 font-medium">Danger Zone</p>
|
||||
<p className="text-red-600 text-sm">
|
||||
This action will permanently delete data from the database. Admin users and settings are preserved by default.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{wipePreview && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-gray-600 text-sm">Users</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{wipePreview.users_count}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-gray-600 text-sm">Spread Bets</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{wipePreview.spread_bets_count}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-gray-600 text-sm">Events</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{wipePreview.events_count}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-gray-600 text-sm">Transactions</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{wipePreview.transactions_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wipePreview && !wipePreview.can_wipe && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-yellow-800">
|
||||
Cooldown active. Please wait {wipePreview.cooldown_remaining_seconds} seconds before wiping again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowWipeModal(true)}
|
||||
disabled={!wipePreview?.can_wipe}
|
||||
className="bg-red-600 hover:bg-red-700 text-white border-red-600"
|
||||
>
|
||||
<Trash2 size={18} className="mr-2" />
|
||||
Wipe Database
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Wipe Confirmation Modal */}
|
||||
{showWipeModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 className="text-xl font-bold text-red-600 mb-4">Confirm Database Wipe</h3>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveAdmins}
|
||||
onChange={(e) => setPreserveAdmins(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-gray-700">Preserve admin users</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveEvents}
|
||||
onChange={(e) => setPreserveEvents(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-gray-700">Preserve events (delete bets only)</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-medium mb-2">
|
||||
Type <span className="font-mono bg-gray-100 px-2 py-1 rounded">CONFIRM WIPE</span> to proceed:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmPhrase}
|
||||
onChange={(e) => setConfirmPhrase(e.target.value)}
|
||||
placeholder="CONFIRM WIPE"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowWipeModal(false)
|
||||
setConfirmPhrase('')
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleWipe}
|
||||
disabled={confirmPhrase !== 'CONFIRM WIPE' || wipeMutation.isPending}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{wipeMutation.isPending ? 'Wiping...' : 'Wipe Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seeder */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Database className="text-blue-500" size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Data Seeder</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
Populate the database with test data for development and testing.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-medium mb-1">Number of Users</label>
|
||||
<input
|
||||
type="number"
|
||||
value={seedConfig.num_users}
|
||||
onChange={(e) => setSeedConfig({ ...seedConfig, num_users: parseInt(e.target.value) || 0 })}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-medium mb-1">Number of Events</label>
|
||||
<input
|
||||
type="number"
|
||||
value={seedConfig.num_events}
|
||||
onChange={(e) => setSeedConfig({ ...seedConfig, num_events: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
max={50}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-medium mb-1">Bets per Event</label>
|
||||
<input
|
||||
type="number"
|
||||
value={seedConfig.num_bets_per_event}
|
||||
onChange={(e) => setSeedConfig({ ...seedConfig, num_bets_per_event: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
max={20}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-medium mb-1">Starting Balance</label>
|
||||
<input
|
||||
type="number"
|
||||
value={seedConfig.starting_balance}
|
||||
onChange={(e) => setSeedConfig({ ...seedConfig, starting_balance: parseInt(e.target.value) || 0 })}
|
||||
min={100}
|
||||
max={10000}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seedConfig.create_admin}
|
||||
onChange={(e) => setSeedConfig({ ...seedConfig, create_admin: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-gray-700">Create test admin</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSeed} disabled={seedMutation.isPending}>
|
||||
<RefreshCw size={18} className={`mr-2 ${seedMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{seedMutation.isPending ? 'Seeding...' : 'Seed Database'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
226
frontend/src/components/admin/AdminSimulation.tsx
Normal file
226
frontend/src/components/admin/AdminSimulation.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Loading } from '@/components/common/Loading'
|
||||
import { Play, Square, Settings, Activity } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import type { SimulationConfig } from '@/types/admin'
|
||||
|
||||
export const AdminSimulation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [config, setConfig] = useState<SimulationConfig>({
|
||||
delay_seconds: 2.0,
|
||||
actions_per_iteration: 3,
|
||||
create_users: true,
|
||||
create_bets: true,
|
||||
take_bets: true,
|
||||
add_comments: true,
|
||||
cancel_bets: true,
|
||||
})
|
||||
|
||||
const { data: status, isLoading, refetch } = useQuery({
|
||||
queryKey: ['simulation-status'],
|
||||
queryFn: adminApi.getSimulationStatus,
|
||||
refetchInterval: (query) => (query.state.data?.is_running ? 2000 : false),
|
||||
})
|
||||
|
||||
// Update config when status loads
|
||||
useEffect(() => {
|
||||
if (status?.config) {
|
||||
setConfig(status.config)
|
||||
}
|
||||
}, [status?.config])
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => adminApi.startSimulation(config),
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
queryClient.invalidateQueries({ queryKey: ['simulation-status'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to start simulation')
|
||||
},
|
||||
})
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: adminApi.stopSimulation,
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.message} (${data.total_iterations} iterations, ${data.ran_for_seconds.toFixed(1)}s)`)
|
||||
queryClient.invalidateQueries({ queryKey: ['simulation-status'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to stop simulation')
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className={status?.is_running ? 'text-green-500 animate-pulse' : 'text-gray-400'} size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Activity Simulation</h2>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
status?.is_running ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{status?.is_running ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Simulate random user activity including creating users, placing bets, matching bets, and adding comments.
|
||||
</p>
|
||||
|
||||
{status?.is_running && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">Started By</p>
|
||||
<p className="font-semibold text-gray-900">{status.started_by}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">Iterations</p>
|
||||
<p className="font-semibold text-gray-900">{status.iterations_completed}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">Started At</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{status.started_at ? new Date(status.started_at).toLocaleTimeString() : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">Last Activity</p>
|
||||
<p className="font-semibold text-gray-900 truncate">{status.last_activity || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{status?.is_running ? (
|
||||
<Button
|
||||
onClick={() => stopMutation.mutate()}
|
||||
disabled={stopMutation.isPending}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<Square size={18} className="mr-2" />
|
||||
{stopMutation.isPending ? 'Stopping...' : 'Stop Simulation'}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>
|
||||
<Play size={18} className="mr-2" />
|
||||
{startMutation.isPending ? 'Starting...' : 'Start Simulation'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowConfig(!showConfig)}>
|
||||
<Settings size={18} className="mr-2" />
|
||||
Configure
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
{showConfig && !status?.is_running && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Simulation Configuration</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-medium mb-1">Delay (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.delay_seconds}
|
||||
onChange={(e) => setConfig({ ...config, delay_seconds: parseFloat(e.target.value) || 0.5 })}
|
||||
min={0.5}
|
||||
max={30}
|
||||
step={0.5}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-medium mb-1">Actions per Iteration</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.actions_per_iteration}
|
||||
onChange={(e) => setConfig({ ...config, actions_per_iteration: parseInt(e.target.value) || 1 })}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-gray-700 font-medium">Enabled Actions:</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.create_users}
|
||||
onChange={(e) => setConfig({ ...config, create_users: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-gray-700">Create Users</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.create_bets}
|
||||
onChange={(e) => setConfig({ ...config, create_bets: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-gray-700">Create Bets</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.take_bets}
|
||||
onChange={(e) => setConfig({ ...config, take_bets: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-gray-700">Take/Match Bets</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.add_comments}
|
||||
onChange={(e) => setConfig({ ...config, add_comments: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-gray-700">Add Comments</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.cancel_bets}
|
||||
onChange={(e) => setConfig({ ...config, cancel_bets: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-gray-700">Cancel Bets</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
333
frontend/src/components/admin/AdminUsers.tsx
Normal file
333
frontend/src/components/admin/AdminUsers.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Loading } from '@/components/common/Loading'
|
||||
import { Search, User, Shield, Ban, DollarSign, Edit, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import type { AdminUser, BalanceAdjustRequest } from '@/types/admin'
|
||||
|
||||
export const AdminUsers = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||
const [showBalanceModal, setShowBalanceModal] = useState(false)
|
||||
const [balanceAmount, setBalanceAmount] = useState('')
|
||||
const [balanceReason, setBalanceReason] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-users', page, search, statusFilter],
|
||||
queryFn: () =>
|
||||
adminApi.getUsers({
|
||||
page,
|
||||
page_size: 20,
|
||||
search: search || undefined,
|
||||
status_filter: statusFilter || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ userId, status }: { userId: number; status: 'active' | 'suspended' }) =>
|
||||
adminApi.changeUserStatus(userId, { status }),
|
||||
onSuccess: () => {
|
||||
toast.success('User status updated')
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||
setSelectedUser(null)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update status')
|
||||
},
|
||||
})
|
||||
|
||||
const adminMutation = useMutation({
|
||||
mutationFn: ({ userId, isAdmin }: { userId: number; isAdmin: boolean }) =>
|
||||
adminApi.updateUser(userId, { is_admin: isAdmin }),
|
||||
onSuccess: () => {
|
||||
toast.success('Admin privileges updated')
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update privileges')
|
||||
},
|
||||
})
|
||||
|
||||
const balanceMutation = useMutation({
|
||||
mutationFn: ({ userId, data }: { userId: number; data: BalanceAdjustRequest }) =>
|
||||
adminApi.adjustUserBalance(userId, data),
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Balance adjusted: $${data.previous_balance} → $${data.new_balance}`)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||
setShowBalanceModal(false)
|
||||
setSelectedUser(null)
|
||||
setBalanceAmount('')
|
||||
setBalanceReason('')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to adjust balance')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearch(searchInput)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleBalanceSubmit = () => {
|
||||
if (!selectedUser || !balanceAmount || !balanceReason) return
|
||||
balanceMutation.mutate({
|
||||
userId: selectedUser.id,
|
||||
data: {
|
||||
amount: parseFloat(balanceAmount),
|
||||
reason: balanceReason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / data.page_size) : 1
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Search by username, email, or name..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">User</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Status</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Balance</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Stats</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Joined</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-semibold text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data?.users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<User size={20} className="text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{user.username}</span>
|
||||
{user.is_admin && (
|
||||
<Shield size={14} className="text-blue-500" title="Admin" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: user.status === 'suspended'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{user.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">${Number(user.balance).toFixed(2)}</p>
|
||||
{Number(user.escrow) > 0 && (
|
||||
<p className="text-xs text-gray-500">+ ${Number(user.escrow).toFixed(2)} escrow</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm">
|
||||
<p className="text-gray-900">
|
||||
{user.wins}W / {user.losses}L
|
||||
</p>
|
||||
<p className="text-gray-500">{(user.win_rate * 100).toFixed(0)}% win rate</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user)
|
||||
setShowBalanceModal(true)
|
||||
}}
|
||||
className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded"
|
||||
title="Adjust Balance"
|
||||
>
|
||||
<DollarSign size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status: user.status === 'active' ? 'suspended' : 'active',
|
||||
})
|
||||
}
|
||||
className={`p-1.5 rounded ${
|
||||
user.status === 'active'
|
||||
? 'text-gray-500 hover:text-red-600 hover:bg-red-50'
|
||||
: 'text-gray-500 hover:text-green-600 hover:bg-green-50'
|
||||
}`}
|
||||
title={user.status === 'active' ? 'Suspend User' : 'Activate User'}
|
||||
>
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
adminMutation.mutate({
|
||||
userId: user.id,
|
||||
isAdmin: !user.is_admin,
|
||||
})
|
||||
}
|
||||
className={`p-1.5 rounded ${
|
||||
user.is_admin
|
||||
? 'text-blue-500 hover:text-gray-600 hover:bg-gray-50'
|
||||
: 'text-gray-500 hover:text-blue-600 hover:bg-blue-50'
|
||||
}`}
|
||||
title={user.is_admin ? 'Remove Admin' : 'Make Admin'}
|
||||
>
|
||||
<Shield size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {(page - 1) * 20 + 1} to {Math.min(page * 20, data?.total || 0)} of {data?.total || 0} users
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Balance Adjustment Modal */}
|
||||
{showBalanceModal && selectedUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">Adjust Balance</h3>
|
||||
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-600">User: <span className="font-medium">{selectedUser.username}</span></p>
|
||||
<p className="text-sm text-gray-600">Current Balance: <span className="font-medium">${Number(selectedUser.balance).toFixed(2)}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-gray-700 font-medium mb-1">Amount (+ to add, - to subtract)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={balanceAmount}
|
||||
onChange={(e) => setBalanceAmount(e.target.value)}
|
||||
placeholder="e.g., 100 or -50"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-medium mb-1">Reason</label>
|
||||
<textarea
|
||||
value={balanceReason}
|
||||
onChange={(e) => setBalanceReason(e.target.value)}
|
||||
placeholder="Reason for adjustment..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowBalanceModal(false)
|
||||
setSelectedUser(null)
|
||||
setBalanceAmount('')
|
||||
setBalanceReason('')
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBalanceSubmit}
|
||||
disabled={!balanceAmount || !balanceReason || balanceMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{balanceMutation.isPending ? 'Adjusting...' : 'Adjust Balance'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -12,7 +12,8 @@ import {
|
||||
Rocket,
|
||||
Share2,
|
||||
Settings,
|
||||
Receipt
|
||||
Receipt,
|
||||
Shield
|
||||
} from 'lucide-react'
|
||||
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
|
||||
import { Button } from '@/components/common/Button'
|
||||
@ -153,6 +154,16 @@ function ProfileDropdown() {
|
||||
<Wallet size={16} />
|
||||
Wallet
|
||||
</Link>
|
||||
{user.is_admin && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-2 text-blue-600 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<Shield size={16} />
|
||||
Admin Panel
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web3 Wallet section */}
|
||||
|
||||
Reference in New Issue
Block a user