Add admin panel
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||
import { BannedScreen } from '@/components/BannedScreen'
|
||||
|
||||
// Layout
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
@@ -23,6 +24,17 @@ import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||
import { TeapotPage } from '@/pages/TeapotPage'
|
||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||
|
||||
// Admin Pages
|
||||
import {
|
||||
AdminLayout,
|
||||
AdminDashboardPage,
|
||||
AdminUsersPage,
|
||||
AdminMarathonsPage,
|
||||
AdminLogsPage,
|
||||
AdminBroadcastPage,
|
||||
AdminContentPage,
|
||||
} from '@/pages/admin'
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
@@ -46,6 +58,19 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const banInfo = useAuthStore((state) => state.banInfo)
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
// Show banned screen if user is authenticated and banned
|
||||
if (isAuthenticated && banInfo) {
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
<BannedScreen banInfo={banInfo} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
@@ -159,6 +184,23 @@ function App() {
|
||||
<Route path="500" element={<ServerErrorPage />} />
|
||||
<Route path="error" element={<ServerErrorPage />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||
<Route path="content" element={<AdminContentPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 - must be last */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import client from './client'
|
||||
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types'
|
||||
import type {
|
||||
AdminUser,
|
||||
AdminMarathon,
|
||||
UserRole,
|
||||
PlatformStats,
|
||||
AdminLogsResponse,
|
||||
BroadcastResponse,
|
||||
StaticContent,
|
||||
DashboardStats
|
||||
} from '@/types'
|
||||
|
||||
export const adminApi = {
|
||||
// Dashboard
|
||||
getDashboard: async (): Promise<DashboardStats> => {
|
||||
const response = await client.get<DashboardStats>('/admin/dashboard')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Users
|
||||
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
|
||||
if (search) params.search = search
|
||||
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
||||
return response.data
|
||||
@@ -24,6 +39,19 @@ export const adminApi = {
|
||||
await client.delete(`/admin/users/${id}`)
|
||||
},
|
||||
|
||||
banUser: async (id: number, reason: string, bannedUntil?: string): Promise<AdminUser> => {
|
||||
const response = await client.post<AdminUser>(`/admin/users/${id}/ban`, {
|
||||
reason,
|
||||
banned_until: bannedUntil || null,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
unbanUser: async (id: number): Promise<AdminUser> => {
|
||||
const response = await client.post<AdminUser>(`/admin/users/${id}/unban`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Marathons
|
||||
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
@@ -36,9 +64,62 @@ export const adminApi = {
|
||||
await client.delete(`/admin/marathons/${id}`)
|
||||
},
|
||||
|
||||
forceFinishMarathon: async (id: number): Promise<void> => {
|
||||
await client.post(`/admin/marathons/${id}/force-finish`)
|
||||
},
|
||||
|
||||
// Stats
|
||||
getStats: async (): Promise<PlatformStats> => {
|
||||
const response = await client.get<PlatformStats>('/admin/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Logs
|
||||
getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise<AdminLogsResponse> => {
|
||||
const params: Record<string, unknown> = { skip, limit }
|
||||
if (action) params.action = action
|
||||
if (adminId) params.admin_id = adminId
|
||||
const response = await client.get<AdminLogsResponse>('/admin/logs', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Broadcast
|
||||
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
|
||||
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
|
||||
return response.data
|
||||
},
|
||||
|
||||
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
|
||||
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Static Content
|
||||
listContent: async (): Promise<StaticContent[]> => {
|
||||
const response = await client.get<StaticContent[]>('/admin/content')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getContent: async (key: string): Promise<StaticContent> => {
|
||||
const response = await client.get<StaticContent>(`/admin/content/${key}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||
const response = await client.put<StaticContent>(`/admin/content/${key}`, { title, content })
|
||||
return response.data
|
||||
},
|
||||
|
||||
createContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// Public content API (no auth required)
|
||||
export const contentApi = {
|
||||
getPublicContent: async (key: string): Promise<StaticContent> => {
|
||||
const response = await client.get<StaticContent>(`/content/${key}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from './client'
|
||||
import type { TokenResponse, User } from '@/types'
|
||||
import type { TokenResponse, LoginResponse, User } from '@/types'
|
||||
|
||||
export interface RegisterData {
|
||||
login: string
|
||||
@@ -18,8 +18,15 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
login: async (data: LoginData): Promise<TokenResponse> => {
|
||||
const response = await client.post<TokenResponse>('/auth/login', data)
|
||||
login: async (data: LoginData): Promise<LoginResponse> => {
|
||||
const response = await client.post<LoginResponse>('/auth/login', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
verify2FA: async (sessionId: number, code: string): Promise<TokenResponse> => {
|
||||
const response = await client.post<TokenResponse>('/auth/2fa/verify', null, {
|
||||
params: { session_id: sessionId, code }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { useAuthStore, type BanInfo } from '@/store/auth'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
@@ -18,10 +19,20 @@ client.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
// Helper to check if detail is ban info object
|
||||
function isBanInfo(detail: unknown): detail is BanInfo {
|
||||
return (
|
||||
typeof detail === 'object' &&
|
||||
detail !== null &&
|
||||
'banned_at' in detail &&
|
||||
'reason' in detail
|
||||
)
|
||||
}
|
||||
|
||||
// Response interceptor to handle errors
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<{ detail: string }>) => {
|
||||
(error: AxiosError<{ detail: string | BanInfo }>) => {
|
||||
// Unauthorized - redirect to login
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
@@ -29,6 +40,15 @@ client.interceptors.response.use(
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
// Forbidden - check if user is banned
|
||||
if (error.response?.status === 403) {
|
||||
const detail = error.response.data?.detail
|
||||
if (isBanInfo(detail)) {
|
||||
// User is banned - set ban info in store
|
||||
useAuthStore.getState().setBanned(detail)
|
||||
}
|
||||
}
|
||||
|
||||
// Server error or network error - redirect to 500 page
|
||||
if (
|
||||
error.response?.status === 500 ||
|
||||
|
||||
@@ -3,7 +3,7 @@ export { marathonsApi } from './marathons'
|
||||
export { gamesApi } from './games'
|
||||
export { wheelApi } from './wheel'
|
||||
export { feedApi } from './feed'
|
||||
export { adminApi } from './admin'
|
||||
export { adminApi, contentApi } from './admin'
|
||||
export { eventsApi } from './events'
|
||||
export { challengesApi } from './challenges'
|
||||
export { assignmentsApi } from './assignments'
|
||||
|
||||
130
frontend/src/components/BannedScreen.tsx
Normal file
130
frontend/src/components/BannedScreen.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Ban, LogOut, Calendar, Clock, AlertTriangle, Sparkles } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
|
||||
interface BanInfo {
|
||||
banned_at: string | null
|
||||
banned_until: string | null
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
interface BannedScreenProps {
|
||||
banInfo: BanInfo
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Moscow',
|
||||
}) + ' (МСК)'
|
||||
}
|
||||
|
||||
export function BannedScreen({ banInfo }: BannedScreenProps) {
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
|
||||
const bannedAtFormatted = formatDate(banInfo.banned_at)
|
||||
const bannedUntilFormatted = formatDate(banInfo.banned_until)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex flex-col items-center justify-center text-center px-4">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-red-500/5 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="relative mb-8">
|
||||
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
|
||||
<Ban className="w-16 h-16 text-red-400" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
|
||||
<AlertTriangle className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
{/* Decorative dots */}
|
||||
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-red-500/50 animate-pulse" />
|
||||
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-orange-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
|
||||
{/* Title with glow */}
|
||||
<div className="relative mb-4">
|
||||
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-red-400">
|
||||
Аккаунт заблокирован
|
||||
</h1>
|
||||
<div className="absolute inset-0 text-4xl font-bold text-red-500/20 blur-xl">
|
||||
Аккаунт заблокирован
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 mb-8 max-w-md">
|
||||
Ваш доступ к платформе был ограничен администрацией.
|
||||
</p>
|
||||
|
||||
{/* Ban Info Card */}
|
||||
<div className="glass rounded-2xl p-6 mb-8 max-w-md w-full border border-red-500/20 text-left space-y-4">
|
||||
{bannedAtFormatted && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-dark-700/50">
|
||||
<Calendar className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Дата блокировки</p>
|
||||
<p className="text-white font-medium">{bannedAtFormatted}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-dark-700/50">
|
||||
<Clock className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Срок</p>
|
||||
<p className={`font-medium ${bannedUntilFormatted ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{bannedUntilFormatted ? `до ${bannedUntilFormatted}` : 'Навсегда'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{banInfo.reason && (
|
||||
<div className="pt-4 border-t border-dark-600">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Причина</p>
|
||||
<p className="text-white bg-dark-700/50 rounded-xl p-4 border border-dark-600">
|
||||
{banInfo.reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info text */}
|
||||
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||
{banInfo.banned_until
|
||||
? 'Ваш аккаунт будет автоматически разблокирован по истечении срока.'
|
||||
: 'Если вы считаете, что блокировка ошибочна, обратитесь к администрации.'}
|
||||
</p>
|
||||
|
||||
{/* Logout button */}
|
||||
<NeonButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={logout}
|
||||
icon={<LogOut className="w-5 h-5" />}
|
||||
>
|
||||
Выйти из аккаунта
|
||||
</NeonButton>
|
||||
|
||||
{/* Decorative sparkles */}
|
||||
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||
<Sparkles className="w-6 h-6 text-red-400 animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
|
||||
import { TelegramLink } from '@/components/TelegramLink'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
@@ -74,6 +74,21 @@ export function Layout() {
|
||||
<span>Марафоны</span>
|
||||
</Link>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||
location.pathname.startsWith('/admin')
|
||||
? 'text-purple-400 bg-purple-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>Админка</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
||||
<Link
|
||||
to="/profile"
|
||||
@@ -144,6 +159,20 @@ export function Layout() {
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span>Марафоны</span>
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||
location.pathname.startsWith('/admin')
|
||||
? 'text-purple-400 bg-purple-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>Админка</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/profile"
|
||||
className={clsx(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from 'zod'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { marathonsApi } from '@/api'
|
||||
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react'
|
||||
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target, Shield, ArrowLeft } from 'lucide-react'
|
||||
|
||||
const loginSchema = z.object({
|
||||
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
|
||||
@@ -17,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
|
||||
const { login, verify2FA, cancel2FA, pending2FA, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [twoFACode, setTwoFACode] = useState('')
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -32,7 +33,12 @@ export function LoginPage() {
|
||||
setSubmitError(null)
|
||||
clearError()
|
||||
try {
|
||||
await login(data)
|
||||
const result = await login(data)
|
||||
|
||||
// If 2FA required, don't navigate
|
||||
if (result.requires2FA) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for pending invite code
|
||||
const pendingCode = consumePendingInviteCode()
|
||||
@@ -52,6 +58,24 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handle2FASubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
clearError()
|
||||
try {
|
||||
await verify2FA(twoFACode)
|
||||
navigate('/marathons')
|
||||
} catch {
|
||||
setSubmitError(error || 'Неверный код')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel2FA = () => {
|
||||
cancel2FA()
|
||||
setTwoFACode('')
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
const features = [
|
||||
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
|
||||
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
|
||||
@@ -113,61 +137,120 @@ export function LoginPage() {
|
||||
|
||||
{/* Form Block (right) */}
|
||||
<GlassCard className="p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
|
||||
<p className="text-gray-400">Войдите, чтобы продолжить</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
{(submitError || error) && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{submitError || error}</span>
|
||||
{pending2FA ? (
|
||||
// 2FA Form
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center">
|
||||
<Shield className="w-8 h-8 text-neon-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Двухфакторная аутентификация</h2>
|
||||
<p className="text-gray-400">Введите код из Telegram</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Логин"
|
||||
placeholder="Введите логин"
|
||||
error={errors.login?.message}
|
||||
autoComplete="username"
|
||||
{...register('login')}
|
||||
/>
|
||||
{/* 2FA Form */}
|
||||
<form onSubmit={handle2FASubmit} className="space-y-5">
|
||||
{(submitError || error) && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{submitError || error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Пароль"
|
||||
type="password"
|
||||
placeholder="Введите пароль"
|
||||
error={errors.password?.message}
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
<Input
|
||||
label="Код подтверждения"
|
||||
placeholder="000000"
|
||||
value={twoFACode}
|
||||
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<NeonButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
icon={<LogIn className="w-5 h-5" />}
|
||||
>
|
||||
Войти
|
||||
</NeonButton>
|
||||
</form>
|
||||
<NeonButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
disabled={twoFACode.length !== 6}
|
||||
icon={<Shield className="w-5 h-5" />}
|
||||
>
|
||||
Подтвердить
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||
<p className="text-gray-400 text-sm">
|
||||
Нет аккаунта?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
{/* Back button */}
|
||||
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||
<button
|
||||
onClick={handleCancel2FA}
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center justify-center gap-2 mx-auto"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Вернуться к входу
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Regular Login Form
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
|
||||
<p className="text-gray-400">Войдите, чтобы продолжить</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
{(submitError || error) && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{submitError || error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Логин"
|
||||
placeholder="Введите логин"
|
||||
error={errors.login?.message}
|
||||
autoComplete="username"
|
||||
{...register('login')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Пароль"
|
||||
type="password"
|
||||
placeholder="Введите пароль"
|
||||
error={errors.password?.message}
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<NeonButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
icon={<LogIn className="w-5 h-5" />}
|
||||
>
|
||||
Войти
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||
<p className="text-gray-400 text-sm">
|
||||
Нет аккаунта?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
|
||||
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminMarathon } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
|
||||
|
||||
export function AdminBroadcastPage() {
|
||||
const [message, setMessage] = useState('')
|
||||
const [targetType, setTargetType] = useState<'all' | 'marathon'>('all')
|
||||
const [marathonId, setMarathonId] = useState<number | null>(null)
|
||||
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (targetType === 'marathon') {
|
||||
loadMarathons()
|
||||
}
|
||||
}, [targetType])
|
||||
|
||||
const loadMarathons = async () => {
|
||||
setLoadingMarathons(true)
|
||||
try {
|
||||
const data = await adminApi.listMarathons(0, 100)
|
||||
setMarathons(data.filter(m => m.status === 'active'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load marathons:', err)
|
||||
} finally {
|
||||
setLoadingMarathons(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) {
|
||||
toast.error('Введите сообщение')
|
||||
return
|
||||
}
|
||||
|
||||
if (targetType === 'marathon' && !marathonId) {
|
||||
toast.error('Выберите марафон')
|
||||
return
|
||||
}
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
let result
|
||||
if (targetType === 'all') {
|
||||
result = await adminApi.broadcastToAll(message)
|
||||
} else {
|
||||
result = await adminApi.broadcastToMarathon(marathonId!, message)
|
||||
}
|
||||
|
||||
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
|
||||
setMessage('')
|
||||
} catch (err) {
|
||||
console.error('Failed to send broadcast:', err)
|
||||
toast.error('Ошибка отправки')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-pink-500/20 border border-pink-500/30">
|
||||
<Send className="w-6 h-6 text-pink-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Target Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Кому отправить
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetType('all')
|
||||
setMarathonId(null)
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||
targetType === 'all'
|
||||
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
<span className="font-medium">Всем пользователям</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTargetType('marathon')}
|
||||
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||
targetType === 'marathon'
|
||||
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span className="font-medium">Участникам марафона</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marathon Selection */}
|
||||
{targetType === 'marathon' && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Выберите марафон
|
||||
</label>
|
||||
{loadingMarathons ? (
|
||||
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
|
||||
) : (
|
||||
<select
|
||||
value={marathonId || ''}
|
||||
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
>
|
||||
<option value="">Выберите марафон...</option>
|
||||
{marathons.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.title} ({m.participants_count} участников)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{marathons.length === 0 && !loadingMarathons && (
|
||||
<p className="text-sm text-gray-500">Нет активных марафонов</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Сообщение
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<p className="text-gray-500">
|
||||
Поддерживается HTML: <b>, <i>, <code>, <a href>
|
||||
</p>
|
||||
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
||||
{message.length} / 2000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<NeonButton
|
||||
size="lg"
|
||||
color="purple"
|
||||
onClick={handleSend}
|
||||
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
||||
isLoading={sending}
|
||||
icon={<Send className="w-5 h-5" />}
|
||||
className="w-full"
|
||||
>
|
||||
{sending ? 'Отправка...' : 'Отправить рассылку'}
|
||||
</NeonButton>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="glass rounded-xl p-4 border border-amber-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Сообщение будет отправлено только пользователям с привязанным Telegram.
|
||||
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
261
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
261
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { StaticContent } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react'
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminContentPage() {
|
||||
const [contents, setContents] = useState<StaticContent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<StaticContent | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [formKey, setFormKey] = useState('')
|
||||
const [formTitle, setFormTitle] = useState('')
|
||||
const [formContent, setFormContent] = useState('')
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
loadContents()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const loadContents = async () => {
|
||||
try {
|
||||
const data = await adminApi.listContent()
|
||||
setContents(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load contents:', err)
|
||||
toast.error('Ошибка загрузки контента')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (content: StaticContent) => {
|
||||
setEditing(content)
|
||||
setFormKey(content.key)
|
||||
setFormTitle(content.title)
|
||||
setFormContent(content.content)
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreating(true)
|
||||
setEditing(null)
|
||||
setFormKey('')
|
||||
setFormTitle('')
|
||||
setFormContent('')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(null)
|
||||
setCreating(false)
|
||||
setFormKey('')
|
||||
setFormTitle('')
|
||||
setFormContent('')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formTitle.trim() || !formContent.trim()) {
|
||||
toast.error('Заполните все поля')
|
||||
return
|
||||
}
|
||||
|
||||
if (creating && !formKey.trim()) {
|
||||
toast.error('Введите ключ')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (creating) {
|
||||
const newContent = await adminApi.createContent(formKey, formTitle, formContent)
|
||||
setContents([...contents, newContent])
|
||||
toast.success('Контент создан')
|
||||
} else if (editing) {
|
||||
const updated = await adminApi.updateContent(editing.key, formTitle, formContent)
|
||||
setContents(contents.map(c => c.id === updated.id ? updated : c))
|
||||
toast.success('Контент обновлён')
|
||||
}
|
||||
handleCancel()
|
||||
} catch (err) {
|
||||
console.error('Failed to save content:', err)
|
||||
toast.error('Ошибка сохранения')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-neon-500/20 border border-neon-500/30">
|
||||
<FileText className="w-6 h-6 text-neon-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Статический контент</h1>
|
||||
</div>
|
||||
<NeonButton onClick={handleCreate} icon={<Plus className="w-4 h-4" />}>
|
||||
Добавить
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Content List */}
|
||||
<div className="space-y-4">
|
||||
{contents.length === 0 ? (
|
||||
<div className="glass rounded-xl border border-dark-600 p-8 text-center">
|
||||
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-400">Нет статического контента</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Создайте первую страницу</p>
|
||||
</div>
|
||||
) : (
|
||||
contents.map((content) => (
|
||||
<div
|
||||
key={content.id}
|
||||
className={`glass rounded-xl border p-5 cursor-pointer transition-all duration-200 ${
|
||||
editing?.id === content.id
|
||||
? 'border-accent-500/50 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => handleEdit(content)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Code className="w-4 h-4 text-neon-400" />
|
||||
<p className="text-sm text-neon-400 font-mono">{content.key}</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white truncate">{content.title}</h3>
|
||||
<p className="text-sm text-gray-400 mt-2 line-clamp-2">
|
||||
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(content)
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
|
||||
Обновлено: {formatDate(content.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{(editing || creating) && (
|
||||
<div className="glass rounded-xl border border-dark-600 p-6 sticky top-6 h-fit">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
{creating ? (
|
||||
<>
|
||||
<Plus className="w-5 h-5 text-neon-400" />
|
||||
Новый контент
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="w-5 h-5 text-accent-400" />
|
||||
Редактирование
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{creating && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Ключ
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formKey}
|
||||
onChange={(e) => setFormKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||
placeholder="about-page"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white font-mono placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1.5">
|
||||
Только буквы, цифры, дефисы и подчеркивания
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Заголовок
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="Заголовок страницы"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Содержимое (HTML)
|
||||
</label>
|
||||
<textarea
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={14}
|
||||
placeholder="<p>HTML контент...</p>"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 font-mono text-sm resize-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NeonButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
207
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
207
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { DashboardStats } from '@/types'
|
||||
import { Users, Trophy, Gamepad2, UserCheck, Ban, Activity, TrendingUp } from 'lucide-react'
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
user_ban: 'Бан пользователя',
|
||||
user_unban: 'Разбан пользователя',
|
||||
user_role_change: 'Изменение роли',
|
||||
marathon_force_finish: 'Принудительное завершение',
|
||||
marathon_delete: 'Удаление марафона',
|
||||
content_update: 'Обновление контента',
|
||||
broadcast_all: 'Рассылка всем',
|
||||
broadcast_marathon: 'Рассылка марафону',
|
||||
admin_login: 'Вход админа',
|
||||
admin_2fa_success: '2FA успех',
|
||||
admin_2fa_fail: '2FA неудача',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
user_ban: 'text-red-400',
|
||||
user_unban: 'text-green-400',
|
||||
user_role_change: 'text-accent-400',
|
||||
marathon_force_finish: 'text-orange-400',
|
||||
marathon_delete: 'text-red-400',
|
||||
content_update: 'text-neon-400',
|
||||
broadcast_all: 'text-pink-400',
|
||||
broadcast_marathon: 'text-pink-400',
|
||||
admin_login: 'text-blue-400',
|
||||
admin_2fa_success: 'text-green-400',
|
||||
admin_2fa_fail: 'text-red-400',
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
gradient,
|
||||
glowColor
|
||||
}: {
|
||||
icon: typeof Users
|
||||
label: string
|
||||
value: number
|
||||
gradient: string
|
||||
glowColor: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`glass rounded-xl p-5 border border-dark-600 hover:border-dark-500 transition-all duration-300 hover:shadow-[0_0_20px_${glowColor}]`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-xl ${gradient} shadow-lg`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{label}</p>
|
||||
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
}, [])
|
||||
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const data = await adminApi.getDashboard()
|
||||
setStats(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
Не удалось загрузить данные
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
|
||||
<TrendingUp className="w-6 h-6 text-accent-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Дашборд</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Всего пользователей"
|
||||
value={stats.users_count}
|
||||
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||
glowColor="rgba(59,130,246,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Ban}
|
||||
label="Заблокировано"
|
||||
value={stats.banned_users_count}
|
||||
gradient="bg-gradient-to-br from-red-500 to-red-600"
|
||||
glowColor="rgba(239,68,68,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Trophy}
|
||||
label="Всего марафонов"
|
||||
value={stats.marathons_count}
|
||||
gradient="bg-gradient-to-br from-accent-500 to-pink-500"
|
||||
glowColor="rgba(139,92,246,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Активных марафонов"
|
||||
value={stats.active_marathons_count}
|
||||
gradient="bg-gradient-to-br from-green-500 to-emerald-600"
|
||||
glowColor="rgba(34,197,94,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Gamepad2}
|
||||
label="Всего игр"
|
||||
value={stats.games_count}
|
||||
gradient="bg-gradient-to-br from-orange-500 to-amber-500"
|
||||
glowColor="rgba(249,115,22,0.15)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={UserCheck}
|
||||
label="Участий в марафонах"
|
||||
value={stats.total_participations}
|
||||
gradient="bg-gradient-to-br from-neon-500 to-cyan-500"
|
||||
glowColor="rgba(34,211,238,0.15)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Logs */}
|
||||
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||
<div className="p-4 border-b border-dark-600">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-accent-400" />
|
||||
Последние действия
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{stats.recent_logs.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-4">Нет записей</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stats.recent_logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-start justify-between p-4 bg-dark-700/50 hover:bg-dark-700 rounded-xl border border-dark-600 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className={`font-medium ${ACTION_COLORS[log.action] || 'text-white'}`}>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
<span className="text-gray-500">Админ:</span> {log.admin_nickname}
|
||||
<span className="text-gray-600 mx-2">•</span>
|
||||
<span className="text-gray-500">{log.target_type}</span> #{log.target_id}
|
||||
</p>
|
||||
{log.details && (
|
||||
<p className="text-xs text-gray-500 mt-2 font-mono bg-dark-800 rounded px-2 py-1 inline-block">
|
||||
{JSON.stringify(log.details)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap ml-4">
|
||||
{formatDate(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
169
frontend/src/pages/admin/AdminLayout.tsx
Normal file
169
frontend/src/pages/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Outlet, NavLink, Navigate, Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Trophy,
|
||||
ScrollText,
|
||||
Send,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
MessageCircle,
|
||||
Sparkles,
|
||||
Lock
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||
]
|
||||
|
||||
export function AdminLayout() {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
|
||||
// Only admins can access
|
||||
if (!user || user.role !== 'admin') {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
// Admin without Telegram - show warning
|
||||
if (!user.telegram_id) {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-amber-500/5 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="relative mb-8 animate-float">
|
||||
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-amber-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(245,158,11,0.15)]">
|
||||
<Lock className="w-16 h-16 text-amber-400" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-accent-400" />
|
||||
</div>
|
||||
{/* Decorative dots */}
|
||||
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-amber-500/50 animate-pulse" />
|
||||
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-accent-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
|
||||
{/* Title with glow */}
|
||||
<div className="relative mb-4">
|
||||
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-accent-400">
|
||||
Требуется Telegram
|
||||
</h1>
|
||||
<div className="absolute inset-0 text-3xl font-bold text-amber-500/20 blur-xl">
|
||||
Требуется Telegram
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 mb-2 max-w-md">
|
||||
Для доступа к админ-панели необходимо привязать Telegram-аккаунт.
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||
Это требуется для двухфакторной аутентификации при входе.
|
||||
</p>
|
||||
|
||||
{/* Info card */}
|
||||
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-amber-500/20">
|
||||
<div className="flex items-center gap-2 text-amber-400 mb-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Двухфакторная аутентификация</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
После привязки Telegram при входе в админ-панель вам будет отправляться код подтверждения.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<Link to="/profile">
|
||||
<NeonButton size="lg" color="purple" icon={<MessageCircle className="w-5 h-5" />}>
|
||||
Привязать Telegram
|
||||
</NeonButton>
|
||||
</Link>
|
||||
<Link to="/marathons">
|
||||
<NeonButton size="lg" variant="secondary" icon={<ArrowLeft className="w-5 h-5" />}>
|
||||
На сайт
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Decorative sparkles */}
|
||||
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||
<Sparkles className="w-6 h-6 text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||
<Sparkles className="w-4 h-4 text-accent-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[calc(100vh-64px)]">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-0 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 glass border-r border-dark-600 flex flex-col relative z-10">
|
||||
<div className="p-4 border-b border-dark-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-pink-400">
|
||||
Админ-панель
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30 shadow-[0_0_10px_rgba(139,92,246,0.15)]'
|
||||
: 'text-gray-400 hover:bg-dark-600/50 hover:text-white border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-dark-600">
|
||||
<NavLink
|
||||
to="/marathons"
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-gray-400 hover:text-neon-400 transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="font-medium">Вернуться на сайт</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-6 overflow-auto relative z-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
208
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminLog } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { ChevronLeft, ChevronRight, Filter, ScrollText } from 'lucide-react'
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
user_ban: 'Бан пользователя',
|
||||
user_unban: 'Разбан пользователя',
|
||||
user_auto_unban: 'Авто-разбан (система)',
|
||||
user_role_change: 'Изменение роли',
|
||||
marathon_force_finish: 'Принудительное завершение',
|
||||
marathon_delete: 'Удаление марафона',
|
||||
content_update: 'Обновление контента',
|
||||
broadcast_all: 'Рассылка всем',
|
||||
broadcast_marathon: 'Рассылка марафону',
|
||||
admin_login: 'Вход админа',
|
||||
admin_2fa_success: '2FA успех',
|
||||
admin_2fa_fail: '2FA неудача',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
user_ban: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
user_unban: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||
user_auto_unban: 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30',
|
||||
user_role_change: 'bg-accent-500/20 text-accent-400 border border-accent-500/30',
|
||||
marathon_force_finish: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
|
||||
marathon_delete: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
content_update: 'bg-neon-500/20 text-neon-400 border border-neon-500/30',
|
||||
broadcast_all: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
|
||||
broadcast_marathon: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
|
||||
admin_login: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
admin_2fa_success: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||
admin_2fa_fail: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminLogsPage() {
|
||||
const [logs, setLogs] = useState<AdminLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionFilter, setActionFilter] = useState<string>('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const LIMIT = 30
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getLogs(page * LIMIT, LIMIT, actionFilter || undefined)
|
||||
setLogs(data.logs)
|
||||
setTotal(data.total)
|
||||
} catch (err) {
|
||||
console.error('Failed to load logs:', err)
|
||||
toast.error('Ошибка загрузки логов')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, actionFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [loadLogs])
|
||||
|
||||
const totalPages = Math.ceil(total / LIMIT)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-500/20 border border-orange-500/30">
|
||||
<ScrollText className="w-6 h-6 text-orange-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Логи действий</h1>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 bg-dark-700/50 px-3 py-1.5 rounded-lg border border-dark-600">
|
||||
Всего: <span className="text-white font-medium">{total}</span> записей
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => {
|
||||
setActionFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
className="bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors min-w-[200px]"
|
||||
>
|
||||
<option value="">Все действия</option>
|
||||
{Object.entries(ACTION_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Дата</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Админ</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действие</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Цель</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Детали</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-600">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
Логи не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-400 whitespace-nowrap font-mono">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
{log.admin_nickname ? (
|
||||
<span className="text-white">{log.admin_nickname}</span>
|
||||
) : (
|
||||
<span className="text-cyan-400 italic">Система</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-lg ${ACTION_COLORS[log.action] || 'bg-dark-600/50 text-gray-400 border border-dark-500'}`}>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
<span className="text-gray-500">{log.target_type}</span>
|
||||
<span className="text-neon-400 font-mono ml-1">#{log.target_id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 max-w-xs">
|
||||
{log.details ? (
|
||||
<span className="font-mono text-xs bg-dark-700/50 px-2 py-1 rounded truncate block">
|
||||
{JSON.stringify(log.details)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 font-mono">
|
||||
{log.ip_address || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Страница <span className="text-white font-medium">{page + 1}</span> из <span className="text-white font-medium">{totalPages || 1}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
frontend/src/pages/admin/AdminMarathonsPage.tsx
Normal file
242
frontend/src/pages/admin/AdminMarathonsPage.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminMarathon } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react'
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
|
||||
preparing: {
|
||||
label: 'Подготовка',
|
||||
icon: Loader2,
|
||||
className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
|
||||
},
|
||||
active: {
|
||||
label: 'Активный',
|
||||
icon: Clock,
|
||||
className: 'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
},
|
||||
finished: {
|
||||
label: 'Завершён',
|
||||
icon: CheckCircle,
|
||||
className: 'bg-dark-600/50 text-gray-400 border border-dark-500'
|
||||
},
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminMarathonsPage() {
|
||||
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const LIMIT = 20
|
||||
|
||||
const loadMarathons = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listMarathons(page * LIMIT, LIMIT, search || undefined)
|
||||
setMarathons(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load marathons:', err)
|
||||
toast.error('Ошибка загрузки марафонов')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathons()
|
||||
}, [loadMarathons])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(0)
|
||||
loadMarathons()
|
||||
}
|
||||
|
||||
const handleDelete = async (marathon: AdminMarathon) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Удалить марафон',
|
||||
message: `Вы уверены, что хотите удалить марафон "${marathon.title}"? Это действие необратимо.`,
|
||||
confirmText: 'Удалить',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await adminApi.deleteMarathon(marathon.id)
|
||||
setMarathons(marathons.filter(m => m.id !== marathon.id))
|
||||
toast.success('Марафон удалён')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete marathon:', err)
|
||||
toast.error('Ошибка удаления')
|
||||
}
|
||||
}
|
||||
|
||||
const handleForceFinish = async (marathon: AdminMarathon) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Завершить марафон',
|
||||
message: `Принудительно завершить марафон "${marathon.title}"? Участники получат уведомление.`,
|
||||
confirmText: 'Завершить',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await adminApi.forceFinishMarathon(marathon.id)
|
||||
setMarathons(marathons.map(m =>
|
||||
m.id === marathon.id ? { ...m, status: 'finished' } : m
|
||||
))
|
||||
toast.success('Марафон завершён')
|
||||
} catch (err) {
|
||||
console.error('Failed to finish marathon:', err)
|
||||
toast.error('Ошибка завершения')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
|
||||
<Trophy className="w-6 h-6 text-accent-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Марафоны</h1>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<NeonButton type="submit" color="purple">
|
||||
Найти
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
{/* Marathons Table */}
|
||||
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-600">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : marathons.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
Марафоны не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
marathons.map((marathon) => {
|
||||
const statusConfig = STATUS_CONFIG[marathon.status] || STATUS_CONFIG.finished
|
||||
const StatusIcon = statusConfig.icon
|
||||
return (
|
||||
<tr key={marathon.id} className="hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{marathon.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-white font-medium">{marathon.title}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-300">{marathon.creator.nickname}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${statusConfig.className}`}>
|
||||
<StatusIcon className={`w-3 h-3 ${marathon.status === 'preparing' ? 'animate-spin' : ''}`} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
<span className="text-gray-500">{formatDate(marathon.start_date)}</span>
|
||||
<span className="text-gray-600 mx-1">→</span>
|
||||
<span className="text-gray-500">{formatDate(marathon.end_date)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{marathon.status !== 'finished' && (
|
||||
<button
|
||||
onClick={() => handleForceFinish(marathon)}
|
||||
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
|
||||
title="Завершить марафон"
|
||||
>
|
||||
<StopCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(marathon)}
|
||||
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Страница <span className="text-white font-medium">{page + 1}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={marathons.length < LIMIT}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
398
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
398
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminUser, UserRole } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X } from 'lucide-react'
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [bannedOnly, setBannedOnly] = useState(false)
|
||||
const [page, setPage] = useState(0)
|
||||
const [banModalUser, setBanModalUser] = useState<AdminUser | null>(null)
|
||||
const [banReason, setBanReason] = useState('')
|
||||
const [banDuration, setBanDuration] = useState<string>('permanent')
|
||||
const [banCustomDate, setBanCustomDate] = useState('')
|
||||
const [banning, setBanning] = useState(false)
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const LIMIT = 20
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listUsers(page * LIMIT, LIMIT, search || undefined, bannedOnly)
|
||||
setUsers(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
toast.error('Ошибка загрузки пользователей')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, search, bannedOnly])
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [loadUsers])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(0)
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!banModalUser || !banReason.trim()) return
|
||||
|
||||
let bannedUntil: string | undefined
|
||||
if (banDuration !== 'permanent') {
|
||||
const now = new Date()
|
||||
if (banDuration === '1d') {
|
||||
now.setDate(now.getDate() + 1)
|
||||
bannedUntil = now.toISOString()
|
||||
} else if (banDuration === '7d') {
|
||||
now.setDate(now.getDate() + 7)
|
||||
bannedUntil = now.toISOString()
|
||||
} else if (banDuration === '30d') {
|
||||
now.setDate(now.getDate() + 30)
|
||||
bannedUntil = now.toISOString()
|
||||
} else if (banDuration === 'custom' && banCustomDate) {
|
||||
bannedUntil = new Date(banCustomDate).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
setBanning(true)
|
||||
try {
|
||||
const updated = await adminApi.banUser(banModalUser.id, banReason, bannedUntil)
|
||||
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||
toast.success(`Пользователь ${updated.nickname} заблокирован`)
|
||||
setBanModalUser(null)
|
||||
setBanReason('')
|
||||
setBanDuration('permanent')
|
||||
setBanCustomDate('')
|
||||
} catch (err) {
|
||||
console.error('Failed to ban user:', err)
|
||||
toast.error('Ошибка блокировки')
|
||||
} finally {
|
||||
setBanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnban = async (user: AdminUser) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Разблокировать пользователя',
|
||||
message: `Вы уверены, что хотите разблокировать ${user.nickname}?`,
|
||||
confirmText: 'Разблокировать',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const updated = await adminApi.unbanUser(user.id)
|
||||
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||
toast.success(`Пользователь ${updated.nickname} разблокирован`)
|
||||
} catch (err) {
|
||||
console.error('Failed to unban user:', err)
|
||||
toast.error('Ошибка разблокировки')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleChange = async (user: AdminUser, newRole: UserRole) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Изменить роль',
|
||||
message: `Изменить роль ${user.nickname} на ${newRole === 'admin' ? 'Администратор' : 'Пользователь'}?`,
|
||||
confirmText: 'Изменить',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const updated = await adminApi.setUserRole(user.id, newRole)
|
||||
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||
toast.success(`Роль ${updated.nickname} изменена`)
|
||||
} catch (err) {
|
||||
console.error('Failed to change role:', err)
|
||||
toast.error('Ошибка изменения роли')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
|
||||
<Users className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Пользователи</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по логину или никнейму..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<NeonButton type="submit" color="purple">
|
||||
Найти
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
<label className="flex items-center gap-2 text-gray-300 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bannedOnly}
|
||||
onChange={(e) => {
|
||||
setBannedOnly(e.target.checked)
|
||||
setPage(0)
|
||||
}}
|
||||
className="w-4 h-4 rounded border-dark-600 bg-dark-700 text-accent-500 focus:ring-accent-500/50 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="group-hover:text-white transition-colors">Только заблокированные</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Логин</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Никнейм</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-600">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
Пользователи не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{user.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-white">{user.login}</td>
|
||||
<td className="px-4 py-3 text-sm text-white font-medium">{user.nickname}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${
|
||||
user.role === 'admin'
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||
: 'bg-dark-600/50 text-gray-400 border border-dark-500'
|
||||
}`}>
|
||||
{user.role === 'admin' && <Shield className="w-3 h-3" />}
|
||||
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
{user.telegram_username ? (
|
||||
<span className="text-neon-400">@{user.telegram_username}</span>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
|
||||
<td className="px-4 py-3">
|
||||
{user.is_banned ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
||||
<Ban className="w-3 h-3" />
|
||||
Заблокирован
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
Активен
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{user.is_banned ? (
|
||||
<button
|
||||
onClick={() => handleUnban(user)}
|
||||
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
|
||||
title="Разблокировать"
|
||||
>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</button>
|
||||
) : user.role !== 'admin' ? (
|
||||
<button
|
||||
onClick={() => setBanModalUser(user)}
|
||||
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||
title="Заблокировать"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{user.role === 'admin' ? (
|
||||
<button
|
||||
onClick={() => handleRoleChange(user, 'user')}
|
||||
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
|
||||
title="Снять права админа"
|
||||
>
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleRoleChange(user, 'admin')}
|
||||
className="p-2 text-accent-400 hover:bg-accent-500/20 rounded-lg transition-colors"
|
||||
title="Сделать админом"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Страница <span className="text-white font-medium">{page + 1}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={users.length < LIMIT}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ban Modal */}
|
||||
{banModalUser && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Ban className="w-5 h-5 text-red-400" />
|
||||
Заблокировать {banModalUser.nickname}?
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setBanModalUser(null)
|
||||
setBanReason('')
|
||||
setBanDuration('permanent')
|
||||
setBanCustomDate('')
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ban Duration */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Срок блокировки
|
||||
</label>
|
||||
<select
|
||||
value={banDuration}
|
||||
onChange={(e) => setBanDuration(e.target.value)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
>
|
||||
<option value="permanent">Навсегда</option>
|
||||
<option value="1d">1 день</option>
|
||||
<option value="7d">7 дней</option>
|
||||
<option value="30d">30 дней</option>
|
||||
<option value="custom">Указать дату</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Date */}
|
||||
{banDuration === 'custom' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Разблокировать
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={banCustomDate}
|
||||
onChange={(e) => setBanCustomDate(e.target.value)}
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Причина
|
||||
</label>
|
||||
<textarea
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
placeholder="Причина блокировки..."
|
||||
rows={3}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<NeonButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setBanModalUser(null)
|
||||
setBanReason('')
|
||||
setBanDuration('permanent')
|
||||
setBanCustomDate('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="danger"
|
||||
onClick={handleBan}
|
||||
disabled={!banReason.trim() || banning || (banDuration === 'custom' && !banCustomDate)}
|
||||
isLoading={banning}
|
||||
icon={<Ban className="w-4 h-4" />}
|
||||
>
|
||||
Заблокировать
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/pages/admin/index.ts
Normal file
7
frontend/src/pages/admin/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { AdminLayout } from './AdminLayout'
|
||||
export { AdminDashboardPage } from './AdminDashboardPage'
|
||||
export { AdminUsersPage } from './AdminUsersPage'
|
||||
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
||||
export { AdminLogsPage } from './AdminLogsPage'
|
||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||
export { AdminContentPage } from './AdminContentPage'
|
||||
@@ -3,6 +3,21 @@ import { persist } from 'zustand/middleware'
|
||||
import type { User } from '@/types'
|
||||
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
|
||||
|
||||
interface Pending2FA {
|
||||
sessionId: number
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
requires2FA: boolean
|
||||
sessionId?: number
|
||||
}
|
||||
|
||||
export interface BanInfo {
|
||||
banned_at: string | null
|
||||
banned_until: string | null
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
@@ -11,8 +26,12 @@ interface AuthState {
|
||||
error: string | null
|
||||
pendingInviteCode: string | null
|
||||
avatarVersion: number
|
||||
pending2FA: Pending2FA | null
|
||||
banInfo: BanInfo | null
|
||||
|
||||
login: (data: LoginData) => Promise<void>
|
||||
login: (data: LoginData) => Promise<LoginResult>
|
||||
verify2FA: (code: string) => Promise<void>
|
||||
cancel2FA: () => void
|
||||
register: (data: RegisterData) => Promise<void>
|
||||
logout: () => void
|
||||
clearError: () => void
|
||||
@@ -20,6 +39,8 @@ interface AuthState {
|
||||
consumePendingInviteCode: () => string | null
|
||||
updateUser: (updates: Partial<User>) => void
|
||||
bumpAvatarVersion: () => void
|
||||
setBanned: (banInfo: BanInfo) => void
|
||||
clearBanned: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
@@ -32,18 +53,34 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
pendingInviteCode: null,
|
||||
avatarVersion: 0,
|
||||
pending2FA: null,
|
||||
banInfo: null,
|
||||
|
||||
login: async (data) => {
|
||||
set({ isLoading: true, error: null })
|
||||
set({ isLoading: true, error: null, pending2FA: null })
|
||||
try {
|
||||
const response = await authApi.login(data)
|
||||
localStorage.setItem('token', response.access_token)
|
||||
set({
|
||||
user: response.user,
|
||||
token: response.access_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
// Check if 2FA is required
|
||||
if (response.requires_2fa && response.two_factor_session_id) {
|
||||
set({
|
||||
isLoading: false,
|
||||
pending2FA: { sessionId: response.two_factor_session_id },
|
||||
})
|
||||
return { requires2FA: true, sessionId: response.two_factor_session_id }
|
||||
}
|
||||
|
||||
// Regular login (no 2FA)
|
||||
if (response.access_token && response.user) {
|
||||
localStorage.setItem('token', response.access_token)
|
||||
set({
|
||||
user: response.user,
|
||||
token: response.access_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
return { requires2FA: false }
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({
|
||||
@@ -54,6 +91,37 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
verify2FA: async (code) => {
|
||||
const pending = get().pending2FA
|
||||
if (!pending) {
|
||||
throw new Error('No pending 2FA session')
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const response = await authApi.verify2FA(pending.sessionId, code)
|
||||
localStorage.setItem('token', response.access_token)
|
||||
set({
|
||||
user: response.user,
|
||||
token: response.access_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
pending2FA: null,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({
|
||||
error: error.response?.data?.detail || '2FA verification failed',
|
||||
isLoading: false,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
cancel2FA: () => {
|
||||
set({ pending2FA: null, error: null })
|
||||
},
|
||||
|
||||
register: async (data) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
@@ -81,6 +149,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
banInfo: null,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -104,6 +173,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
bumpAvatarVersion: () => {
|
||||
set({ avatarVersion: get().avatarVersion + 1 })
|
||||
},
|
||||
|
||||
setBanned: (banInfo) => {
|
||||
set({ banInfo })
|
||||
},
|
||||
|
||||
clearBanned: () => {
|
||||
set({ banInfo: null })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
|
||||
@@ -26,6 +26,15 @@ export interface TokenResponse {
|
||||
user: User
|
||||
}
|
||||
|
||||
// Login response (may require 2FA for admins)
|
||||
export interface LoginResponse {
|
||||
access_token?: string | null
|
||||
token_type: string
|
||||
user?: User | null
|
||||
requires_2fa: boolean
|
||||
two_factor_session_id?: number | null
|
||||
}
|
||||
|
||||
// Marathon types
|
||||
export type MarathonStatus = 'preparing' | 'active' | 'finished'
|
||||
export type ParticipantRole = 'participant' | 'organizer'
|
||||
@@ -404,6 +413,10 @@ export interface AdminUser {
|
||||
telegram_username: string | null
|
||||
marathons_count: number
|
||||
created_at: string
|
||||
is_banned: boolean
|
||||
banned_at: string | null
|
||||
banned_until: string | null // null = permanent ban
|
||||
ban_reason: string | null
|
||||
}
|
||||
|
||||
export interface AdminMarathon {
|
||||
@@ -425,6 +438,64 @@ export interface PlatformStats {
|
||||
total_participations: number
|
||||
}
|
||||
|
||||
// Admin action log types
|
||||
export type AdminActionType =
|
||||
| 'user_ban'
|
||||
| 'user_unban'
|
||||
| 'user_role_change'
|
||||
| 'marathon_force_finish'
|
||||
| 'marathon_delete'
|
||||
| 'content_update'
|
||||
| 'broadcast_all'
|
||||
| 'broadcast_marathon'
|
||||
| 'admin_login'
|
||||
| 'admin_2fa_success'
|
||||
| 'admin_2fa_fail'
|
||||
|
||||
export interface AdminLog {
|
||||
id: number
|
||||
admin_id: number
|
||||
admin_nickname: string
|
||||
action: AdminActionType
|
||||
target_type: string
|
||||
target_id: number
|
||||
details: Record<string, unknown> | null
|
||||
ip_address: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminLogsResponse {
|
||||
logs: AdminLog[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Broadcast types
|
||||
export interface BroadcastResponse {
|
||||
sent_count: number
|
||||
total_count: number
|
||||
}
|
||||
|
||||
// Static content types
|
||||
export interface StaticContent {
|
||||
id: number
|
||||
key: string
|
||||
title: string
|
||||
content: string
|
||||
updated_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Dashboard stats
|
||||
export interface DashboardStats {
|
||||
users_count: number
|
||||
banned_users_count: number
|
||||
marathons_count: number
|
||||
active_marathons_count: number
|
||||
games_count: number
|
||||
total_participations: number
|
||||
recent_logs: AdminLog[]
|
||||
}
|
||||
|
||||
// Dispute types
|
||||
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user