Add admin panel

This commit is contained in:
2025-12-19 02:07:25 +07:00
parent 8e634994bd
commit 481bdabaa8
40 changed files with 3526 additions and 112 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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: &lt;b&gt;, &lt;i&gt;, &lt;code&gt;, &lt;a href&gt;
</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>
)
}

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

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

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

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

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

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

View 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'

View File

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

View File

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