Compare commits
2 Commits
33f49f4e47
...
481bdabaa8
| Author | SHA1 | Date | |
|---|---|---|---|
| 481bdabaa8 | |||
| 8e634994bd |
28
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
28
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add challenge proposals support
|
||||||
|
|
||||||
|
Revision ID: 011_add_challenge_proposals
|
||||||
|
Revises: 010_add_telegram_profile
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '011_add_challenge_proposals'
|
||||||
|
down_revision: Union[str, None] = '010_add_telegram_profile'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||||
|
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('challenges', 'status')
|
||||||
|
op.drop_column('challenges', 'proposed_by_id')
|
||||||
32
backend/alembic/versions/012_add_user_banned.py
Normal file
32
backend/alembic/versions/012_add_user_banned.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add user banned fields
|
||||||
|
|
||||||
|
Revision ID: 012_add_user_banned
|
||||||
|
Revises: 011_add_challenge_proposals
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '012_add_user_banned'
|
||||||
|
down_revision: Union[str, None] = '011_add_challenge_proposals'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'ban_reason')
|
||||||
|
op.drop_column('users', 'banned_by_id')
|
||||||
|
op.drop_column('users', 'banned_at')
|
||||||
|
op.drop_column('users', 'is_banned')
|
||||||
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Add admin_logs table
|
||||||
|
|
||||||
|
Revision ID: 013_add_admin_logs
|
||||||
|
Revises: 012_add_user_banned
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '013_add_admin_logs'
|
||||||
|
down_revision: Union[str, None] = '012_add_user_banned'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('admin_logs'):
|
||||||
|
op.create_table(
|
||||||
|
'admin_logs',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('admin_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('action', sa.String(50), nullable=False),
|
||||||
|
sa.Column('target_type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('target_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('details', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(50), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_admin_id'):
|
||||||
|
op.create_index('ix_admin_logs_admin_id', 'admin_logs', ['admin_id'])
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_action'):
|
||||||
|
op.create_index('ix_admin_logs_action', 'admin_logs', ['action'])
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_created_at'):
|
||||||
|
op.create_index('ix_admin_logs_created_at', 'admin_logs', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_admin_logs_created_at', 'admin_logs')
|
||||||
|
op.drop_index('ix_admin_logs_action', 'admin_logs')
|
||||||
|
op.drop_index('ix_admin_logs_admin_id', 'admin_logs')
|
||||||
|
op.drop_table('admin_logs')
|
||||||
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Add admin_2fa_sessions table
|
||||||
|
|
||||||
|
Revision ID: 014_add_admin_2fa
|
||||||
|
Revises: 013_add_admin_logs
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '014_add_admin_2fa'
|
||||||
|
down_revision: Union[str, None] = '013_add_admin_logs'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('admin_2fa_sessions'):
|
||||||
|
op.create_table(
|
||||||
|
'admin_2fa_sessions',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('code', sa.String(6), nullable=False),
|
||||||
|
sa.Column('telegram_sent', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_user_id'):
|
||||||
|
op.create_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions', ['user_id'])
|
||||||
|
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_expires_at'):
|
||||||
|
op.create_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions', ['expires_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions')
|
||||||
|
op.drop_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions')
|
||||||
|
op.drop_table('admin_2fa_sessions')
|
||||||
54
backend/alembic/versions/015_add_static_content.py
Normal file
54
backend/alembic/versions/015_add_static_content.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add static_content table
|
||||||
|
|
||||||
|
Revision ID: 015_add_static_content
|
||||||
|
Revises: 014_add_admin_2fa
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '015_add_static_content'
|
||||||
|
down_revision: Union[str, None] = '014_add_admin_2fa'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('static_content'):
|
||||||
|
op.create_table(
|
||||||
|
'static_content',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('key', sa.String(100), unique=True, nullable=False),
|
||||||
|
sa.Column('title', sa.String(200), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('updated_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('static_content', 'ix_static_content_key'):
|
||||||
|
op.create_index('ix_static_content_key', 'static_content', ['key'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_static_content_key', 'static_content')
|
||||||
|
op.drop_table('static_content')
|
||||||
36
backend/alembic/versions/016_add_banned_until.py
Normal file
36
backend/alembic/versions/016_add_banned_until.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Add banned_until field
|
||||||
|
|
||||||
|
Revision ID: 016_add_banned_until
|
||||||
|
Revises: 015_add_static_content
|
||||||
|
Create Date: 2024-12-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '016_add_banned_until'
|
||||||
|
down_revision: Union[str, None] = '015_add_static_content'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists('users', 'banned_until'):
|
||||||
|
op.add_column('users', sa.Column('banned_until', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'banned_until'):
|
||||||
|
op.drop_column('users', 'banned_until')
|
||||||
32
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
32
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Make admin_id nullable in admin_logs for system actions
|
||||||
|
|
||||||
|
Revision ID: 017_admin_logs_nullable_admin_id
|
||||||
|
Revises: 016_add_banned_until
|
||||||
|
Create Date: 2024-12-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '017_admin_logs_nullable_admin_id'
|
||||||
|
down_revision: Union[str, None] = '016_add_banned_until'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Make admin_id nullable for system actions (like auto-unban)
|
||||||
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revert to not nullable (will fail if there are NULL values)
|
||||||
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status, Header
|
from fastapi import Depends, HTTPException, status, Header
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import decode_access_token
|
from app.core.security import decode_access_token
|
||||||
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
|
from app.models import User, Participant, Marathon, UserRole, ParticipantRole, AdminLog, AdminActionType
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
@@ -43,6 +44,50 @@ async def get_current_user(
|
|||||||
detail="User not found",
|
detail="User not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if user is banned
|
||||||
|
if user.is_banned:
|
||||||
|
# Auto-unban if ban expired
|
||||||
|
if user.banned_until and datetime.utcnow() > user.banned_until:
|
||||||
|
# Save ban info for logging before clearing
|
||||||
|
old_ban_reason = user.ban_reason
|
||||||
|
old_banned_until = user.banned_until.isoformat() if user.banned_until else None
|
||||||
|
|
||||||
|
user.is_banned = False
|
||||||
|
user.banned_at = None
|
||||||
|
user.banned_until = None
|
||||||
|
user.banned_by_id = None
|
||||||
|
user.ban_reason = None
|
||||||
|
|
||||||
|
# Log system auto-unban action
|
||||||
|
log = AdminLog(
|
||||||
|
admin_id=None, # System action, no admin
|
||||||
|
action=AdminActionType.USER_AUTO_UNBAN.value,
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
details={
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"reason": old_ban_reason,
|
||||||
|
"banned_until": old_banned_until,
|
||||||
|
"system": True,
|
||||||
|
},
|
||||||
|
ip_address=None,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
else:
|
||||||
|
# Still banned - return ban info in error
|
||||||
|
ban_info = {
|
||||||
|
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
"reason": user.ban_reason,
|
||||||
|
}
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=ban_info,
|
||||||
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +101,21 @@ def require_admin(user: User) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_with_2fa(user: User) -> User:
|
||||||
|
"""Check if user is admin with Telegram linked (2FA enabled)"""
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin access required",
|
||||||
|
)
|
||||||
|
if not user.telegram_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Для доступа к админ-панели необходимо привязать Telegram в профиле",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_participant(
|
async def get_participant(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
|
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -15,3 +15,4 @@ router.include_router(admin.router)
|
|||||||
router.include_router(events.router)
|
router.include_router(events.router)
|
||||||
router.include_router(assignments.router)
|
router.include_router(assignments.router)
|
||||||
router.include_router(telegram.router)
|
router.include_router(telegram.router)
|
||||||
|
router.include_router(content.router)
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_admin
|
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||||
from app.models import User, UserRole, Marathon, Participant, Game
|
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||||
from app.schemas import UserPublic, MarathonListItem, MessageResponse
|
from app.schemas import (
|
||||||
|
UserPublic, MessageResponse,
|
||||||
|
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse,
|
||||||
|
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
||||||
|
StaticContentCreate, DashboardStats
|
||||||
|
)
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -14,21 +22,6 @@ class SetUserRole(BaseModel):
|
|||||||
role: str = Field(..., pattern="^(user|admin)$")
|
role: str = Field(..., pattern="^(user|admin)$")
|
||||||
|
|
||||||
|
|
||||||
class AdminUserResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
login: str
|
|
||||||
nickname: str
|
|
||||||
role: str
|
|
||||||
avatar_url: str | None = None
|
|
||||||
telegram_id: int | None = None
|
|
||||||
telegram_username: str | None = None
|
|
||||||
marathons_count: int = 0
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class AdminMarathonResponse(BaseModel):
|
class AdminMarathonResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
@@ -44,6 +37,29 @@ class AdminMarathonResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Helper Functions ============
|
||||||
|
async def log_admin_action(
|
||||||
|
db,
|
||||||
|
admin_id: int,
|
||||||
|
action: str,
|
||||||
|
target_type: str,
|
||||||
|
target_id: int,
|
||||||
|
details: dict | None = None,
|
||||||
|
ip_address: str | None = None
|
||||||
|
):
|
||||||
|
"""Log an admin action."""
|
||||||
|
log = AdminLog(
|
||||||
|
admin_id=admin_id,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=list[AdminUserResponse])
|
@router.get("/users", response_model=list[AdminUserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
@@ -51,9 +67,10 @@ async def list_users(
|
|||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
|
banned_only: bool = False,
|
||||||
):
|
):
|
||||||
"""List all users. Admin only."""
|
"""List all users. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
query = select(User).order_by(User.created_at.desc())
|
query = select(User).order_by(User.created_at.desc())
|
||||||
|
|
||||||
@@ -63,6 +80,9 @@ async def list_users(
|
|||||||
(User.nickname.ilike(f"%{search}%"))
|
(User.nickname.ilike(f"%{search}%"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if banned_only:
|
||||||
|
query = query.where(User.is_banned == True)
|
||||||
|
|
||||||
query = query.offset(skip).limit(limit)
|
query = query.offset(skip).limit(limit)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
users = result.scalars().all()
|
users = result.scalars().all()
|
||||||
@@ -83,6 +103,10 @@ async def list_users(
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
))
|
))
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -91,7 +115,7 @@ async def list_users(
|
|||||||
@router.get("/users/{user_id}", response_model=AdminUserResponse)
|
@router.get("/users/{user_id}", response_model=AdminUserResponse)
|
||||||
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Get user details. Admin only."""
|
"""Get user details. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -112,6 +136,10 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,9 +149,10 @@ async def set_user_role(
|
|||||||
data: SetUserRole,
|
data: SetUserRole,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
request: Request,
|
||||||
):
|
):
|
||||||
"""Set user's global role. Admin only."""
|
"""Set user's global role. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
# Cannot change own role
|
# Cannot change own role
|
||||||
if user_id == current_user.id:
|
if user_id == current_user.id:
|
||||||
@@ -134,10 +163,19 @@ async def set_user_role(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
old_role = user.role
|
||||||
user.role = data.role
|
user.role = data.role
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_ROLE_CHANGE.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"old_role": old_role, "new_role": data.role, "nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
marathons_count = await db.scalar(
|
marathons_count = await db.scalar(
|
||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
@@ -152,13 +190,17 @@ async def set_user_role(
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
||||||
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Delete a user. Admin only."""
|
"""Delete a user. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
# Cannot delete yourself
|
# Cannot delete yourself
|
||||||
if user_id == current_user.id:
|
if user_id == current_user.id:
|
||||||
@@ -188,7 +230,7 @@ async def list_marathons(
|
|||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
):
|
):
|
||||||
"""List all marathons. Admin only."""
|
"""List all marathons. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Marathon)
|
select(Marathon)
|
||||||
@@ -227,25 +269,34 @@ async def list_marathons(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
||||||
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession, request: Request):
|
||||||
"""Delete a marathon. Admin only."""
|
"""Delete a marathon. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
if not marathon:
|
if not marathon:
|
||||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
marathon_title = marathon.title
|
||||||
await db.delete(marathon)
|
await db.delete(marathon)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_DELETE.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon_title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
return MessageResponse(message="Marathon deleted")
|
return MessageResponse(message="Marathon deleted")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_stats(current_user: CurrentUser, db: DbSession):
|
async def get_stats(current_user: CurrentUser, db: DbSession):
|
||||||
"""Get platform statistics. Admin only."""
|
"""Get platform statistics. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
users_count = await db.scalar(select(func.count()).select_from(User))
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||||
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||||
@@ -258,3 +309,439 @@ async def get_stats(current_user: CurrentUser, db: DbSession):
|
|||||||
"games_count": games_count,
|
"games_count": games_count,
|
||||||
"total_participations": participants_count,
|
"total_participations": participants_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Ban/Unban Users ============
|
||||||
|
@router.post("/users/{user_id}/ban", response_model=AdminUserResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def ban_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
data: BanUserRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Ban a user. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot ban yourself")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if user.role == UserRole.ADMIN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot ban another admin")
|
||||||
|
|
||||||
|
if user.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="User is already banned")
|
||||||
|
|
||||||
|
user.is_banned = True
|
||||||
|
user.banned_at = datetime.utcnow()
|
||||||
|
# Normalize to naive datetime (remove tzinfo) to match banned_at
|
||||||
|
user.banned_until = data.banned_until.replace(tzinfo=None) if data.banned_until else None
|
||||||
|
user.banned_by_id = current_user.id
|
||||||
|
user.ban_reason = data.reason
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_BAN.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname, "reason": data.reason},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
|
||||||
|
async def unban_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Unban a user. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if not user.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not banned")
|
||||||
|
|
||||||
|
user.is_banned = False
|
||||||
|
user.banned_at = None
|
||||||
|
user.banned_until = None
|
||||||
|
user.banned_by_id = None
|
||||||
|
user.ban_reason = None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_UNBAN.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=None,
|
||||||
|
banned_until=None,
|
||||||
|
ban_reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Force Finish Marathon ============
|
||||||
|
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
||||||
|
async def force_finish_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Force finish a marathon. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
if marathon.status == MarathonStatus.FINISHED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon is already finished")
|
||||||
|
|
||||||
|
old_status = marathon.status
|
||||||
|
marathon.status = MarathonStatus.FINISHED.value
|
||||||
|
marathon.end_date = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_FORCE_FINISH.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title, "old_status": old_status},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify participants
|
||||||
|
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
|
||||||
|
|
||||||
|
return MessageResponse(message="Marathon finished")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Admin Logs ============
|
||||||
|
@router.get("/logs", response_model=AdminLogsListResponse)
|
||||||
|
async def get_logs(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
action: str | None = None,
|
||||||
|
admin_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""Get admin action logs. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(AdminLog)
|
||||||
|
.options(selectinload(AdminLog.admin))
|
||||||
|
.order_by(AdminLog.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if action:
|
||||||
|
query = query.where(AdminLog.action == action)
|
||||||
|
if admin_id:
|
||||||
|
query = query.where(AdminLog.admin_id == admin_id)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(func.count()).select_from(AdminLog)
|
||||||
|
if action:
|
||||||
|
count_query = count_query.where(AdminLog.action == action)
|
||||||
|
if admin_id:
|
||||||
|
count_query = count_query.where(AdminLog.admin_id == admin_id)
|
||||||
|
total = await db.scalar(count_query)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
return AdminLogsListResponse(
|
||||||
|
logs=[
|
||||||
|
AdminLogResponse(
|
||||||
|
id=log.id,
|
||||||
|
admin_id=log.admin_id,
|
||||||
|
admin_nickname=log.admin.nickname if log.admin else None,
|
||||||
|
action=log.action,
|
||||||
|
target_type=log.target_type,
|
||||||
|
target_id=log.target_id,
|
||||||
|
details=log.details,
|
||||||
|
ip_address=log.ip_address,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log in logs
|
||||||
|
],
|
||||||
|
total=total or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Broadcast ============
|
||||||
|
@router.post("/broadcast/all", response_model=BroadcastResponse)
|
||||||
|
@limiter.limit("1/minute")
|
||||||
|
async def broadcast_to_all(
|
||||||
|
request: Request,
|
||||||
|
data: BroadcastRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Send broadcast message to all users with Telegram linked. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Get all users with telegram_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id.isnot(None))
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
total_count = len(users)
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if await telegram_notifier.send_message(user.telegram_id, data.message):
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
||||||
|
"broadcast", 0,
|
||||||
|
{"message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/broadcast/marathon/{marathon_id}", response_model=BroadcastResponse)
|
||||||
|
@limiter.limit("3/minute")
|
||||||
|
async def broadcast_to_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
data: BroadcastRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Send broadcast message to marathon participants. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Get participants count
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(Participant, Participant.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
User.telegram_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = total_result.scalars().all()
|
||||||
|
total_count = len(users)
|
||||||
|
|
||||||
|
sent_count = await telegram_notifier.notify_marathon_participants(
|
||||||
|
db, marathon_id, data.message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Static Content ============
|
||||||
|
@router.get("/content", response_model=list[StaticContentResponse])
|
||||||
|
async def list_content(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).order_by(StaticContent.key)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content/{key}", response_model=StaticContentResponse)
|
||||||
|
async def get_content(key: str, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get static content by key. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/content/{key}", response_model=StaticContentResponse)
|
||||||
|
async def update_content(
|
||||||
|
request: Request,
|
||||||
|
key: str,
|
||||||
|
data: StaticContentUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Update static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
|
||||||
|
content.title = data.title
|
||||||
|
content.content = data.content
|
||||||
|
content.updated_by_id = current_user.id
|
||||||
|
content.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
|
||||||
|
"content", content.id,
|
||||||
|
{"key": key, "title": data.title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/content", response_model=StaticContentResponse)
|
||||||
|
async def create_content(
|
||||||
|
request: Request,
|
||||||
|
data: StaticContentCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check if key exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == data.key)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Content with this key already exists")
|
||||||
|
|
||||||
|
content = StaticContent(
|
||||||
|
key=data.key,
|
||||||
|
title=data.title,
|
||||||
|
content=data.content,
|
||||||
|
updated_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(content)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Dashboard ============
|
||||||
|
@router.get("/dashboard", response_model=DashboardStats)
|
||||||
|
async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get dashboard statistics. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||||
|
banned_users_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(User).where(User.is_banned == True)
|
||||||
|
)
|
||||||
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||||
|
active_marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Marathon).where(Marathon.status == MarathonStatus.ACTIVE.value)
|
||||||
|
)
|
||||||
|
games_count = await db.scalar(select(func.count()).select_from(Game))
|
||||||
|
total_participations = await db.scalar(select(func.count()).select_from(Participant))
|
||||||
|
|
||||||
|
# Get recent logs
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminLog)
|
||||||
|
.options(selectinload(AdminLog.admin))
|
||||||
|
.order_by(AdminLog.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
recent_logs = result.scalars().all()
|
||||||
|
|
||||||
|
return DashboardStats(
|
||||||
|
users_count=users_count or 0,
|
||||||
|
banned_users_count=banned_users_count or 0,
|
||||||
|
marathons_count=marathons_count or 0,
|
||||||
|
active_marathons_count=active_marathons_count or 0,
|
||||||
|
games_count=games_count or 0,
|
||||||
|
total_participations=total_participations or 0,
|
||||||
|
recent_logs=[
|
||||||
|
AdminLogResponse(
|
||||||
|
id=log.id,
|
||||||
|
admin_id=log.admin_id,
|
||||||
|
admin_nickname=log.admin.nickname if log.admin else None,
|
||||||
|
action=log.action,
|
||||||
|
target_type=log.target_type,
|
||||||
|
target_id=log.target_id,
|
||||||
|
details=log.details,
|
||||||
|
ip_address=log.ip_address,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log in recent_logs
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
import secrets
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Request
|
from fastapi import APIRouter, HTTPException, status, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.models import User
|
from app.models import User, UserRole, Admin2FASession
|
||||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate
|
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ async def register(request: Request, data: UserRegister, db: DbSession):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||||
# Find user
|
# Find user
|
||||||
@@ -53,6 +57,99 @@ async def login(request: Request, data: UserLogin, db: DbSession):
|
|||||||
detail="Incorrect login or password",
|
detail="Incorrect login or password",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if user is banned
|
||||||
|
if user.is_banned:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Your account has been banned",
|
||||||
|
)
|
||||||
|
|
||||||
|
# If admin with Telegram linked, require 2FA
|
||||||
|
if user.role == UserRole.ADMIN.value and user.telegram_id:
|
||||||
|
# Generate 6-digit code
|
||||||
|
code = "".join([str(secrets.randbelow(10)) for _ in range(6)])
|
||||||
|
|
||||||
|
# Create 2FA session (expires in 5 minutes)
|
||||||
|
session = Admin2FASession(
|
||||||
|
user_id=user.id,
|
||||||
|
code=code,
|
||||||
|
expires_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
|
||||||
|
# Send code to Telegram
|
||||||
|
message = f"🔐 <b>Код подтверждения для входа в админку</b>\n\nВаш код: <code>{code}</code>\n\nКод действителен 5 минут."
|
||||||
|
sent = await telegram_notifier.send_message(user.telegram_id, message)
|
||||||
|
|
||||||
|
if sent:
|
||||||
|
session.telegram_sent = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
requires_2fa=True,
|
||||||
|
two_factor_session_id=session.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular user or admin without Telegram - generate token immediately
|
||||||
|
# Admin without Telegram can login but admin panel will check for Telegram
|
||||||
|
access_token = create_access_token(subject=user.id)
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
user=UserPrivate.model_validate(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/verify", response_model=TokenResponse)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession):
|
||||||
|
"""Verify 2FA code and return JWT token."""
|
||||||
|
# Find session
|
||||||
|
result = await db.execute(
|
||||||
|
select(Admin2FASession).where(Admin2FASession.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid session",
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.is_verified:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Session already verified",
|
||||||
|
)
|
||||||
|
|
||||||
|
if datetime.utcnow() > session.expires_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Code expired",
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.code != code:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid code",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as verified
|
||||||
|
session.is_verified = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(select(User).where(User.id == session.user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
# Generate token
|
# Generate token
|
||||||
access_token = create_access_token(subject=user.id)
|
access_token = create_access_token(subject=user.id)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
||||||
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge
|
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge, User
|
||||||
|
from app.models.challenge import ChallengeStatus
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
ChallengeCreate,
|
ChallengeCreate,
|
||||||
ChallengeUpdate,
|
ChallengeUpdate,
|
||||||
@@ -15,7 +16,9 @@ from app.schemas import (
|
|||||||
ChallengesSaveRequest,
|
ChallengesSaveRequest,
|
||||||
ChallengesGenerateRequest,
|
ChallengesGenerateRequest,
|
||||||
)
|
)
|
||||||
|
from app.schemas.challenge import ChallengePropose, ProposedByUser
|
||||||
from app.services.gpt import gpt_service
|
from app.services.gpt import gpt_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(tags=["challenges"])
|
router = APIRouter(tags=["challenges"])
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ router = APIRouter(tags=["challenges"])
|
|||||||
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Challenge)
|
select(Challenge)
|
||||||
.options(selectinload(Challenge.game))
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
.where(Challenge.id == challenge_id)
|
.where(Challenge.id == challenge_id)
|
||||||
)
|
)
|
||||||
challenge = result.scalar_one_or_none()
|
challenge = result.scalar_one_or_none()
|
||||||
@@ -32,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
|||||||
return challenge
|
return challenge
|
||||||
|
|
||||||
|
|
||||||
|
def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse:
|
||||||
|
"""Helper to build ChallengeResponse with proposed_by"""
|
||||||
|
proposed_by = None
|
||||||
|
if challenge.proposed_by:
|
||||||
|
proposed_by = ProposedByUser(
|
||||||
|
id=challenge.proposed_by.id,
|
||||||
|
nickname=challenge.proposed_by.nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
return ChallengeResponse(
|
||||||
|
id=challenge.id,
|
||||||
|
title=challenge.title,
|
||||||
|
description=challenge.description,
|
||||||
|
type=challenge.type,
|
||||||
|
difficulty=challenge.difficulty,
|
||||||
|
points=challenge.points,
|
||||||
|
estimated_time=challenge.estimated_time,
|
||||||
|
proof_type=challenge.proof_type,
|
||||||
|
proof_hint=challenge.proof_hint,
|
||||||
|
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||||
|
is_generated=challenge.is_generated,
|
||||||
|
created_at=challenge.created_at,
|
||||||
|
status=challenge.status,
|
||||||
|
proposed_by=proposed_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""List challenges for a game. Participants can view challenges for approved games only."""
|
"""List challenges for a game. Participants can view approved and pending challenges."""
|
||||||
# Get game and check access
|
# Get game and check access
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game).where(Game.id == game_id)
|
select(Game).where(Game.id == game_id)
|
||||||
@@ -54,30 +84,17 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
|
|||||||
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Game not accessible")
|
raise HTTPException(status_code=403, detail="Game not accessible")
|
||||||
|
|
||||||
result = await db.execute(
|
# Get challenges with proposed_by
|
||||||
select(Challenge)
|
query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
|
||||||
.where(Challenge.game_id == game_id)
|
|
||||||
.order_by(Challenge.difficulty, Challenge.created_at)
|
# Regular participants see approved and pending challenges (but not rejected)
|
||||||
)
|
if not current_user.is_admin and participant and not participant.is_organizer:
|
||||||
|
query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value]))
|
||||||
|
|
||||||
|
result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at))
|
||||||
challenges = result.scalars().all()
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [build_challenge_response(c, game) for c in challenges]
|
||||||
ChallengeResponse(
|
|
||||||
id=c.id,
|
|
||||||
title=c.title,
|
|
||||||
description=c.description,
|
|
||||||
type=c.type,
|
|
||||||
difficulty=c.difficulty,
|
|
||||||
points=c.points,
|
|
||||||
estimated_time=c.estimated_time,
|
|
||||||
proof_type=c.proof_type,
|
|
||||||
proof_hint=c.proof_hint,
|
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
|
||||||
is_generated=c.is_generated,
|
|
||||||
created_at=c.created_at,
|
|
||||||
)
|
|
||||||
for c in challenges
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
@@ -94,36 +111,21 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
|
|||||||
if not current_user.is_admin and not participant:
|
if not current_user.is_admin and not participant:
|
||||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
# Get all challenges from approved games in this marathon
|
# Get all approved challenges from approved games in this marathon
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Challenge)
|
select(Challenge)
|
||||||
.join(Game, Challenge.game_id == Game.id)
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
.options(selectinload(Challenge.game))
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
.where(
|
.where(
|
||||||
Game.marathon_id == marathon_id,
|
Game.marathon_id == marathon_id,
|
||||||
Game.status == GameStatus.APPROVED.value,
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Challenge.status == ChallengeStatus.APPROVED.value,
|
||||||
)
|
)
|
||||||
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
||||||
)
|
)
|
||||||
challenges = result.scalars().all()
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
ChallengeResponse(
|
|
||||||
id=c.id,
|
|
||||||
title=c.title,
|
|
||||||
description=c.description,
|
|
||||||
type=c.type,
|
|
||||||
difficulty=c.difficulty,
|
|
||||||
points=c.points,
|
|
||||||
estimated_time=c.estimated_time,
|
|
||||||
proof_type=c.proof_type,
|
|
||||||
proof_hint=c.proof_hint,
|
|
||||||
game=GameShort(id=c.game.id, title=c.game.title, cover_url=None),
|
|
||||||
is_generated=c.is_generated,
|
|
||||||
created_at=c.created_at,
|
|
||||||
)
|
|
||||||
for c in challenges
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||||
@@ -166,25 +168,13 @@ async def create_challenge(
|
|||||||
proof_type=data.proof_type.value,
|
proof_type=data.proof_type.value,
|
||||||
proof_hint=data.proof_hint,
|
proof_hint=data.proof_hint,
|
||||||
is_generated=False,
|
is_generated=False,
|
||||||
|
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
|
||||||
)
|
)
|
||||||
db.add(challenge)
|
db.add(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(challenge)
|
await db.refresh(challenge)
|
||||||
|
|
||||||
return ChallengeResponse(
|
return build_challenge_response(challenge, game)
|
||||||
id=challenge.id,
|
|
||||||
title=challenge.title,
|
|
||||||
description=challenge.description,
|
|
||||||
type=challenge.type,
|
|
||||||
difficulty=challenge.difficulty,
|
|
||||||
points=challenge.points,
|
|
||||||
estimated_time=challenge.estimated_time,
|
|
||||||
proof_type=challenge.proof_type,
|
|
||||||
proof_hint=challenge.proof_hint,
|
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
|
||||||
is_generated=challenge.is_generated,
|
|
||||||
created_at=challenge.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||||
@@ -386,26 +376,12 @@ async def update_challenge(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(challenge)
|
await db.refresh(challenge)
|
||||||
|
|
||||||
game = challenge.game
|
return build_challenge_response(challenge, challenge.game)
|
||||||
return ChallengeResponse(
|
|
||||||
id=challenge.id,
|
|
||||||
title=challenge.title,
|
|
||||||
description=challenge.description,
|
|
||||||
type=challenge.type,
|
|
||||||
difficulty=challenge.difficulty,
|
|
||||||
points=challenge.points,
|
|
||||||
estimated_time=challenge.estimated_time,
|
|
||||||
proof_type=challenge.proof_type,
|
|
||||||
proof_hint=challenge.proof_hint,
|
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
|
||||||
is_generated=challenge.is_generated,
|
|
||||||
created_at=challenge.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
||||||
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Delete a challenge. Organizers only."""
|
"""Delete a challenge. Organizers can delete any, participants can delete their own pending."""
|
||||||
challenge = await get_challenge_or_404(db, challenge_id)
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
# Check marathon is in preparing state
|
# Check marathon is in preparing state
|
||||||
@@ -414,10 +390,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
||||||
|
|
||||||
# Only organizers can delete challenges
|
participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
|
||||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
|
||||||
|
# Check permissions
|
||||||
|
if current_user.is_admin or (participant and participant.is_organizer):
|
||||||
|
# Organizers can delete any challenge
|
||||||
|
pass
|
||||||
|
elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value:
|
||||||
|
# Participants can delete their own pending challenges
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="You can only delete your own pending challenges")
|
||||||
|
|
||||||
await db.delete(challenge)
|
await db.delete(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Challenge deleted")
|
return MessageResponse(message="Challenge deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Proposed challenges endpoints ============
|
||||||
|
|
||||||
|
@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse)
|
||||||
|
async def propose_challenge(
|
||||||
|
game_id: int,
|
||||||
|
data: ChallengePropose,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Propose a challenge for a game. Participants only, during PREPARING phase."""
|
||||||
|
# Get game
|
||||||
|
result = await db.execute(select(Game).where(Game.id == game_id))
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon")
|
||||||
|
|
||||||
|
# Check user is participant
|
||||||
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Can only propose challenges to approved games
|
||||||
|
if game.status != GameStatus.APPROVED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only propose challenges to approved games")
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=game_id,
|
||||||
|
title=data.title,
|
||||||
|
description=data.description,
|
||||||
|
type=data.type.value,
|
||||||
|
difficulty=data.difficulty.value,
|
||||||
|
points=data.points,
|
||||||
|
estimated_time=data.estimated_time,
|
||||||
|
proof_type=data.proof_type.value,
|
||||||
|
proof_hint=data.proof_hint,
|
||||||
|
is_generated=False,
|
||||||
|
proposed_by_id=current_user.id,
|
||||||
|
status=ChallengeStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
db.add(challenge)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Load proposed_by relationship
|
||||||
|
challenge.proposed_by = current_user
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all pending proposed challenges for a marathon. Organizers only."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Only organizers can see all proposed challenges
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Get all pending challenges from approved games
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Challenge.status == ChallengeStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
.order_by(Challenge.created_at.desc())
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List current user's proposed challenges for a marathon."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Check user is participant
|
||||||
|
participant = await get_participant(db, current_user.id, marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Get user's proposed challenges
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Challenge.proposed_by_id == current_user.id,
|
||||||
|
)
|
||||||
|
.order_by(Challenge.created_at.desc())
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse)
|
||||||
|
async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Approve a proposed challenge. Organizers only."""
|
||||||
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can approve
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
if challenge.status != ChallengeStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||||
|
|
||||||
|
challenge.status = ChallengeStatus.APPROVED.value
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Send Telegram notification to proposer
|
||||||
|
if challenge.proposed_by_id:
|
||||||
|
await telegram_notifier.notify_challenge_approved(
|
||||||
|
db,
|
||||||
|
challenge.proposed_by_id,
|
||||||
|
marathon.title,
|
||||||
|
challenge.game.title,
|
||||||
|
challenge.title
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, challenge.game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse)
|
||||||
|
async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Reject a proposed challenge. Organizers only."""
|
||||||
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can reject
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
if challenge.status != ChallengeStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||||
|
|
||||||
|
# Save info for notification before changing status
|
||||||
|
proposer_id = challenge.proposed_by_id
|
||||||
|
game_title = challenge.game.title
|
||||||
|
challenge_title = challenge.title
|
||||||
|
|
||||||
|
challenge.status = ChallengeStatus.REJECTED.value
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Send Telegram notification to proposer
|
||||||
|
if proposer_id:
|
||||||
|
await telegram_notifier.notify_challenge_rejected(
|
||||||
|
db,
|
||||||
|
proposer_id,
|
||||||
|
marathon.title,
|
||||||
|
game_title,
|
||||||
|
challenge_title
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, challenge.game)
|
||||||
|
|||||||
20
backend/app/api/v1/content.py
Normal file
20
backend/app/api/v1/content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.deps import DbSession
|
||||||
|
from app.models import StaticContent
|
||||||
|
from app.schemas import StaticContentResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/content", tags=["content"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{key}", response_model=StaticContentResponse)
|
||||||
|
async def get_public_content(key: str, db: DbSession):
|
||||||
|
"""Get public static content by key. No authentication required."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
return content
|
||||||
@@ -86,7 +86,7 @@ async def generate_link_token(current_user: CurrentUser):
|
|||||||
)
|
)
|
||||||
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
||||||
|
|
||||||
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
|
bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
|
||||||
bot_url = f"https://t.me/{bot_username}?start={token}"
|
bot_url = f"https://t.me/{bot_username}?start={token}"
|
||||||
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from app.models.activity import Activity, ActivityType
|
|||||||
from app.models.event import Event, EventType
|
from app.models.event import Event, EventType
|
||||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||||
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
||||||
|
from app.models.admin_log import AdminLog, AdminActionType
|
||||||
|
from app.models.admin_2fa import Admin2FASession
|
||||||
|
from app.models.static_content import StaticContent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -35,4 +38,8 @@ __all__ = [
|
|||||||
"DisputeStatus",
|
"DisputeStatus",
|
||||||
"DisputeComment",
|
"DisputeComment",
|
||||||
"DisputeVote",
|
"DisputeVote",
|
||||||
|
"AdminLog",
|
||||||
|
"AdminActionType",
|
||||||
|
"Admin2FASession",
|
||||||
|
"StaticContent",
|
||||||
]
|
]
|
||||||
|
|||||||
20
backend/app/models/admin_2fa.py
Normal file
20
backend/app/models/admin_2fa.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Admin2FASession(Base):
|
||||||
|
__tablename__ = "admin_2fa_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||||
|
telegram_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||||
46
backend/app/models/admin_log.py
Normal file
46
backend/app/models/admin_log.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AdminActionType(str, Enum):
|
||||||
|
# User actions
|
||||||
|
USER_BAN = "user_ban"
|
||||||
|
USER_UNBAN = "user_unban"
|
||||||
|
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
||||||
|
USER_ROLE_CHANGE = "user_role_change"
|
||||||
|
|
||||||
|
# Marathon actions
|
||||||
|
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||||
|
MARATHON_DELETE = "marathon_delete"
|
||||||
|
|
||||||
|
# Content actions
|
||||||
|
CONTENT_UPDATE = "content_update"
|
||||||
|
|
||||||
|
# Broadcast actions
|
||||||
|
BROADCAST_ALL = "broadcast_all"
|
||||||
|
BROADCAST_MARATHON = "broadcast_marathon"
|
||||||
|
|
||||||
|
# Auth actions
|
||||||
|
ADMIN_LOGIN = "admin_login"
|
||||||
|
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
||||||
|
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLog(Base):
|
||||||
|
__tablename__ = "admin_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
admin_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) # Nullable for system actions
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])
|
||||||
@@ -29,6 +29,12 @@ class ProofType(str, Enum):
|
|||||||
STEAM = "steam"
|
STEAM = "steam"
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
class Challenge(Base):
|
class Challenge(Base):
|
||||||
__tablename__ = "challenges"
|
__tablename__ = "challenges"
|
||||||
|
|
||||||
@@ -45,8 +51,13 @@ class Challenge(Base):
|
|||||||
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Proposed challenges support
|
||||||
|
proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
||||||
|
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
|
||||||
assignments: Mapped[list["Assignment"]] = relationship(
|
assignments: Mapped[list["Assignment"]] = relationship(
|
||||||
"Assignment",
|
"Assignment",
|
||||||
back_populates="challenge"
|
back_populates="challenge"
|
||||||
|
|||||||
20
backend/app/models/static_content.py
Normal file
20
backend/app/models/static_content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContent(Base):
|
||||||
|
__tablename__ = "static_content"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
updated_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
updated_by: Mapped["User | None"] = relationship("User", foreign_keys=[updated_by_id])
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import String, BigInteger, DateTime
|
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -27,6 +27,13 @@ class User(Base):
|
|||||||
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Ban fields
|
||||||
|
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
banned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
banned_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = permanent
|
||||||
|
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||||
"Marathon",
|
"Marathon",
|
||||||
@@ -47,6 +54,11 @@ class User(Base):
|
|||||||
back_populates="approved_by",
|
back_populates="approved_by",
|
||||||
foreign_keys="Game.approved_by_id"
|
foreign_keys="Game.approved_by_id"
|
||||||
)
|
)
|
||||||
|
banned_by: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
remote_side="User.id",
|
||||||
|
foreign_keys=[banned_by_id]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self) -> bool:
|
def is_admin(self) -> bool:
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ from app.schemas.dispute import (
|
|||||||
AssignmentDetailResponse,
|
AssignmentDetailResponse,
|
||||||
ReturnedAssignmentResponse,
|
ReturnedAssignmentResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.admin import (
|
||||||
|
BanUserRequest,
|
||||||
|
AdminUserResponse,
|
||||||
|
AdminLogResponse,
|
||||||
|
AdminLogsListResponse,
|
||||||
|
BroadcastRequest,
|
||||||
|
BroadcastResponse,
|
||||||
|
StaticContentResponse,
|
||||||
|
StaticContentUpdate,
|
||||||
|
StaticContentCreate,
|
||||||
|
TwoFactorInitiateRequest,
|
||||||
|
TwoFactorInitiateResponse,
|
||||||
|
TwoFactorVerifyRequest,
|
||||||
|
LoginResponse,
|
||||||
|
DashboardStats,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User
|
# User
|
||||||
@@ -157,4 +173,19 @@ __all__ = [
|
|||||||
"DisputeResponse",
|
"DisputeResponse",
|
||||||
"AssignmentDetailResponse",
|
"AssignmentDetailResponse",
|
||||||
"ReturnedAssignmentResponse",
|
"ReturnedAssignmentResponse",
|
||||||
|
# Admin
|
||||||
|
"BanUserRequest",
|
||||||
|
"AdminUserResponse",
|
||||||
|
"AdminLogResponse",
|
||||||
|
"AdminLogsListResponse",
|
||||||
|
"BroadcastRequest",
|
||||||
|
"BroadcastResponse",
|
||||||
|
"StaticContentResponse",
|
||||||
|
"StaticContentUpdate",
|
||||||
|
"StaticContentCreate",
|
||||||
|
"TwoFactorInitiateRequest",
|
||||||
|
"TwoFactorInitiateResponse",
|
||||||
|
"TwoFactorVerifyRequest",
|
||||||
|
"LoginResponse",
|
||||||
|
"DashboardStats",
|
||||||
]
|
]
|
||||||
|
|||||||
119
backend/app/schemas/admin.py
Normal file
119
backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# ============ User Ban ============
|
||||||
|
class BanUserRequest(BaseModel):
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
banned_until: datetime | None = None # None = permanent ban
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
login: str
|
||||||
|
nickname: str
|
||||||
|
role: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
telegram_id: int | None = None
|
||||||
|
telegram_username: str | None = None
|
||||||
|
marathons_count: int = 0
|
||||||
|
created_at: str
|
||||||
|
is_banned: bool = False
|
||||||
|
banned_at: str | None = None
|
||||||
|
banned_until: str | None = None # None = permanent
|
||||||
|
ban_reason: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Admin Logs ============
|
||||||
|
class AdminLogResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
admin_id: int | None = None # Nullable for system actions
|
||||||
|
admin_nickname: str | None = None # Nullable for system actions
|
||||||
|
action: str
|
||||||
|
target_type: str
|
||||||
|
target_id: int
|
||||||
|
details: dict | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLogsListResponse(BaseModel):
|
||||||
|
logs: list[AdminLogResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Broadcast ============
|
||||||
|
class BroadcastRequest(BaseModel):
|
||||||
|
message: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastResponse(BaseModel):
|
||||||
|
sent_count: int
|
||||||
|
total_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Static Content ============
|
||||||
|
class StaticContentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
updated_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContentUpdate(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContentCreate(BaseModel):
|
||||||
|
key: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9_-]+$")
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 2FA ============
|
||||||
|
class TwoFactorInitiateRequest(BaseModel):
|
||||||
|
pass # No additional data needed
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorInitiateResponse(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
expires_at: datetime
|
||||||
|
message: str = "Code sent to Telegram"
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorVerifyRequest(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
code: str = Field(..., min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
"""Login response that may require 2FA"""
|
||||||
|
access_token: str | None = None
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: Any = None # UserPrivate
|
||||||
|
requires_2fa: bool = False
|
||||||
|
two_factor_session_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Dashboard Stats ============
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
users_count: int
|
||||||
|
banned_users_count: int
|
||||||
|
marathons_count: int
|
||||||
|
active_marathons_count: int
|
||||||
|
games_count: int
|
||||||
|
total_participations: int
|
||||||
|
recent_logs: list[AdminLogResponse] = []
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models.challenge import ChallengeType, Difficulty, ProofType
|
from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
|
||||||
from app.schemas.game import GameShort
|
from app.schemas.game import GameShort
|
||||||
|
|
||||||
|
|
||||||
|
class ProposedByUser(BaseModel):
|
||||||
|
"""Minimal user info for proposed challenges"""
|
||||||
|
id: int
|
||||||
|
nickname: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class ChallengeBase(BaseModel):
|
class ChallengeBase(BaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=100)
|
title: str = Field(..., min_length=1, max_length=100)
|
||||||
description: str = Field(..., min_length=1)
|
description: str = Field(..., min_length=1)
|
||||||
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
|
|||||||
game: GameShort
|
game: GameShort
|
||||||
is_generated: bool
|
is_generated: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
status: str = "approved"
|
||||||
|
proposed_by: ProposedByUser | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengePropose(ChallengeBase):
|
||||||
|
"""Schema for proposing a challenge by a participant"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChallengeGenerated(BaseModel):
|
class ChallengeGenerated(BaseModel):
|
||||||
"""Schema for GPT-generated challenges"""
|
"""Schema for GPT-generated challenges"""
|
||||||
title: str
|
title: str
|
||||||
|
|||||||
@@ -276,6 +276,42 @@ class TelegramNotifier:
|
|||||||
)
|
)
|
||||||
return await self.notify_user(db, user_id, message)
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_challenge_approved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str,
|
||||||
|
challenge_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed challenge was approved."""
|
||||||
|
message = (
|
||||||
|
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Теперь оно доступно для всех участников."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_challenge_rejected(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str,
|
||||||
|
challenge_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed challenge was rejected."""
|
||||||
|
message = (
|
||||||
|
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Ты можешь предложить другой челлендж."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
telegram_notifier = TelegramNotifier()
|
telegram_notifier = TelegramNotifier()
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
|
||||||
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
# S3 Storage
|
# S3 Storage
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||||
|
import { BannedScreen } from '@/components/BannedScreen'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { Layout } from '@/components/layout/Layout'
|
import { Layout } from '@/components/layout/Layout'
|
||||||
@@ -23,6 +24,17 @@ import { NotFoundPage } from '@/pages/NotFoundPage'
|
|||||||
import { TeapotPage } from '@/pages/TeapotPage'
|
import { TeapotPage } from '@/pages/TeapotPage'
|
||||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||||
|
|
||||||
|
// Admin Pages
|
||||||
|
import {
|
||||||
|
AdminLayout,
|
||||||
|
AdminDashboardPage,
|
||||||
|
AdminUsersPage,
|
||||||
|
AdminMarathonsPage,
|
||||||
|
AdminLogsPage,
|
||||||
|
AdminBroadcastPage,
|
||||||
|
AdminContentPage,
|
||||||
|
} from '@/pages/admin'
|
||||||
|
|
||||||
// Protected route wrapper
|
// Protected route wrapper
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||||
@@ -46,6 +58,19 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
@@ -159,6 +184,23 @@ function App() {
|
|||||||
<Route path="500" element={<ServerErrorPage />} />
|
<Route path="500" element={<ServerErrorPage />} />
|
||||||
<Route path="error" 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 */}
|
{/* 404 - must be last */}
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import client from './client'
|
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 = {
|
export const adminApi = {
|
||||||
|
// Dashboard
|
||||||
|
getDashboard: async (): Promise<DashboardStats> => {
|
||||||
|
const response = await client.get<DashboardStats>('/admin/dashboard')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
|
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
|
||||||
const params: Record<string, unknown> = { skip, limit }
|
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
|
||||||
if (search) params.search = search
|
if (search) params.search = search
|
||||||
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
||||||
return response.data
|
return response.data
|
||||||
@@ -24,6 +39,19 @@ export const adminApi = {
|
|||||||
await client.delete(`/admin/users/${id}`)
|
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
|
// Marathons
|
||||||
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||||
const params: Record<string, unknown> = { skip, limit }
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
@@ -36,9 +64,62 @@ export const adminApi = {
|
|||||||
await client.delete(`/admin/marathons/${id}`)
|
await client.delete(`/admin/marathons/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
forceFinishMarathon: async (id: number): Promise<void> => {
|
||||||
|
await client.post(`/admin/marathons/${id}/force-finish`)
|
||||||
|
},
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
getStats: async (): Promise<PlatformStats> => {
|
getStats: async (): Promise<PlatformStats> => {
|
||||||
const response = await client.get<PlatformStats>('/admin/stats')
|
const response = await client.get<PlatformStats>('/admin/stats')
|
||||||
return response.data
|
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 client from './client'
|
||||||
import type { TokenResponse, User } from '@/types'
|
import type { TokenResponse, LoginResponse, User } from '@/types'
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
login: string
|
login: string
|
||||||
@@ -18,8 +18,15 @@ export const authApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
login: async (data: LoginData): Promise<TokenResponse> => {
|
login: async (data: LoginData): Promise<LoginResponse> => {
|
||||||
const response = await client.post<TokenResponse>('/auth/login', data)
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { AxiosError } from 'axios'
|
import axios, { AxiosError } from 'axios'
|
||||||
|
import { useAuthStore, type BanInfo } from '@/store/auth'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||||
|
|
||||||
@@ -18,10 +19,20 @@ client.interceptors.request.use((config) => {
|
|||||||
return 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
|
// Response interceptor to handle errors
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error: AxiosError<{ detail: string }>) => {
|
(error: AxiosError<{ detail: string | BanInfo }>) => {
|
||||||
// Unauthorized - redirect to login
|
// Unauthorized - redirect to login
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
@@ -29,6 +40,15 @@ client.interceptors.response.use(
|
|||||||
window.location.href = '/login'
|
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
|
// Server error or network error - redirect to 500 page
|
||||||
if (
|
if (
|
||||||
error.response?.status === 500 ||
|
error.response?.status === 500 ||
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export const gamesApi = {
|
|||||||
await client.delete(`/challenges/${id}`)
|
await client.delete(`/challenges/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
|
||||||
|
const response = await client.patch<Challenge>(`/challenges/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||||
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
||||||
@@ -89,4 +94,30 @@ export const gamesApi = {
|
|||||||
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Proposed challenges
|
||||||
|
proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
|
||||||
|
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||||
|
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||||
|
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
approveChallenge: async (id: number): Promise<Challenge> => {
|
||||||
|
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectChallenge: async (id: number): Promise<Challenge> => {
|
||||||
|
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export { marathonsApi } from './marathons'
|
|||||||
export { gamesApi } from './games'
|
export { gamesApi } from './games'
|
||||||
export { wheelApi } from './wheel'
|
export { wheelApi } from './wheel'
|
||||||
export { feedApi } from './feed'
|
export { feedApi } from './feed'
|
||||||
export { adminApi } from './admin'
|
export { adminApi, contentApi } from './admin'
|
||||||
export { eventsApi } from './events'
|
export { eventsApi } from './events'
|
||||||
export { challengesApi } from './challenges'
|
export { challengesApi } from './challenges'
|
||||||
export { assignmentsApi } from './assignments'
|
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 { useState, useEffect } from 'react'
|
||||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
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 { TelegramLink } from '@/components/TelegramLink'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
@@ -74,6 +74,21 @@ export function Layout() {
|
|||||||
<span>Марафоны</span>
|
<span>Марафоны</span>
|
||||||
</Link>
|
</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">
|
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
@@ -144,6 +159,20 @@ export function Layout() {
|
|||||||
<Trophy className="w-5 h-5" />
|
<Trophy className="w-5 h-5" />
|
||||||
<span>Марафоны</span>
|
<span>Марафоны</span>
|
||||||
</Link>
|
</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
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -61,6 +61,27 @@ export function LobbyPage() {
|
|||||||
})
|
})
|
||||||
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
|
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
|
||||||
|
|
||||||
|
// Edit challenge
|
||||||
|
const [editingChallengeId, setEditingChallengeId] = useState<number | null>(null)
|
||||||
|
const [editChallenge, setEditChallenge] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: 'completion',
|
||||||
|
difficulty: 'medium',
|
||||||
|
points: 50,
|
||||||
|
estimated_time: 30,
|
||||||
|
proof_type: 'screenshot',
|
||||||
|
proof_hint: '',
|
||||||
|
})
|
||||||
|
const [isUpdatingChallenge, setIsUpdatingChallenge] = useState(false)
|
||||||
|
|
||||||
|
// Proposed challenges
|
||||||
|
const [proposedChallenges, setProposedChallenges] = useState<Challenge[]>([])
|
||||||
|
const [myProposedChallenges, setMyProposedChallenges] = useState<Challenge[]>([])
|
||||||
|
const [approvingChallengeId, setApprovingChallengeId] = useState<number | null>(null)
|
||||||
|
const [isProposingChallenge, setIsProposingChallenge] = useState(false)
|
||||||
|
const [editingProposedId, setEditingProposedId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Start marathon
|
// Start marathon
|
||||||
const [isStarting, setIsStarting] = useState(false)
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
|
|
||||||
@@ -84,6 +105,23 @@ export function LobbyPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setPendingGames([])
|
setPendingGames([])
|
||||||
}
|
}
|
||||||
|
// Load proposed challenges for organizers
|
||||||
|
try {
|
||||||
|
const proposed = await gamesApi.getProposedChallenges(parseInt(id))
|
||||||
|
setProposedChallenges(proposed)
|
||||||
|
} catch {
|
||||||
|
setProposedChallenges([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load my proposed challenges for all participants
|
||||||
|
if (marathonData.my_participation) {
|
||||||
|
try {
|
||||||
|
const myProposed = await gamesApi.getMyProposedChallenges(parseInt(id))
|
||||||
|
setMyProposedChallenges(myProposed)
|
||||||
|
} catch {
|
||||||
|
setMyProposedChallenges([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load data:', error)
|
console.error('Failed to load data:', error)
|
||||||
@@ -249,6 +287,206 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStartEditChallenge = (challenge: Challenge) => {
|
||||||
|
setEditingChallengeId(challenge.id)
|
||||||
|
setEditChallenge({
|
||||||
|
title: challenge.title,
|
||||||
|
description: challenge.description,
|
||||||
|
type: challenge.type,
|
||||||
|
difficulty: challenge.difficulty,
|
||||||
|
points: challenge.points,
|
||||||
|
estimated_time: challenge.estimated_time || 30,
|
||||||
|
proof_type: challenge.proof_type,
|
||||||
|
proof_hint: challenge.proof_hint || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateChallenge = async (challengeId: number, gameId: number) => {
|
||||||
|
if (!editChallenge.title.trim() || !editChallenge.description.trim()) {
|
||||||
|
toast.warning('Заполните название и описание')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingChallenge(true)
|
||||||
|
try {
|
||||||
|
await gamesApi.updateChallenge(challengeId, {
|
||||||
|
title: editChallenge.title.trim(),
|
||||||
|
description: editChallenge.description.trim(),
|
||||||
|
type: editChallenge.type,
|
||||||
|
difficulty: editChallenge.difficulty,
|
||||||
|
points: editChallenge.points,
|
||||||
|
estimated_time: editChallenge.estimated_time || undefined,
|
||||||
|
proof_type: editChallenge.proof_type,
|
||||||
|
proof_hint: editChallenge.proof_hint.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success('Задание обновлено')
|
||||||
|
setEditingChallengeId(null)
|
||||||
|
const challenges = await gamesApi.getChallenges(gameId)
|
||||||
|
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось обновить задание')
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingChallenge(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProposedChallenges = async () => {
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
const proposed = await gamesApi.getProposedChallenges(parseInt(id))
|
||||||
|
setProposedChallenges(proposed)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load proposed challenges:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApproveChallenge = async (challengeId: number) => {
|
||||||
|
setApprovingChallengeId(challengeId)
|
||||||
|
try {
|
||||||
|
await gamesApi.approveChallenge(challengeId)
|
||||||
|
toast.success('Задание одобрено')
|
||||||
|
await loadProposedChallenges()
|
||||||
|
// Reload challenges for the game
|
||||||
|
const challenge = proposedChallenges.find(c => c.id === challengeId)
|
||||||
|
if (challenge) {
|
||||||
|
const challenges = await gamesApi.getChallenges(challenge.game.id)
|
||||||
|
setGameChallenges(prev => ({ ...prev, [challenge.game.id]: challenges }))
|
||||||
|
}
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось одобрить задание')
|
||||||
|
} finally {
|
||||||
|
setApprovingChallengeId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRejectChallenge = async (challengeId: number) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Отклонить задание?',
|
||||||
|
message: 'Задание будет удалено.',
|
||||||
|
confirmText: 'Отклонить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setApprovingChallengeId(challengeId)
|
||||||
|
try {
|
||||||
|
await gamesApi.rejectChallenge(challengeId)
|
||||||
|
toast.success('Задание отклонено')
|
||||||
|
await loadProposedChallenges()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось отклонить задание')
|
||||||
|
} finally {
|
||||||
|
setApprovingChallengeId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEditProposed = (challenge: Challenge) => {
|
||||||
|
setEditingProposedId(challenge.id)
|
||||||
|
setEditChallenge({
|
||||||
|
title: challenge.title,
|
||||||
|
description: challenge.description,
|
||||||
|
type: challenge.type,
|
||||||
|
difficulty: challenge.difficulty,
|
||||||
|
points: challenge.points,
|
||||||
|
estimated_time: challenge.estimated_time || 30,
|
||||||
|
proof_type: challenge.proof_type,
|
||||||
|
proof_hint: challenge.proof_hint || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateProposedChallenge = async (challengeId: number) => {
|
||||||
|
if (!editChallenge.title.trim() || !editChallenge.description.trim()) {
|
||||||
|
toast.warning('Заполните название и описание')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingChallenge(true)
|
||||||
|
try {
|
||||||
|
await gamesApi.updateChallenge(challengeId, {
|
||||||
|
title: editChallenge.title.trim(),
|
||||||
|
description: editChallenge.description.trim(),
|
||||||
|
type: editChallenge.type,
|
||||||
|
difficulty: editChallenge.difficulty,
|
||||||
|
points: editChallenge.points,
|
||||||
|
estimated_time: editChallenge.estimated_time || undefined,
|
||||||
|
proof_type: editChallenge.proof_type,
|
||||||
|
proof_hint: editChallenge.proof_hint.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success('Задание обновлено')
|
||||||
|
setEditingProposedId(null)
|
||||||
|
await loadProposedChallenges()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось обновить задание')
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingChallenge(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProposeChallenge = async (gameId: number) => {
|
||||||
|
if (!newChallenge.title.trim() || !newChallenge.description.trim()) {
|
||||||
|
toast.warning('Заполните название и описание')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProposingChallenge(true)
|
||||||
|
try {
|
||||||
|
await gamesApi.proposeChallenge(gameId, {
|
||||||
|
title: newChallenge.title.trim(),
|
||||||
|
description: newChallenge.description.trim(),
|
||||||
|
type: newChallenge.type,
|
||||||
|
difficulty: newChallenge.difficulty,
|
||||||
|
points: newChallenge.points,
|
||||||
|
estimated_time: newChallenge.estimated_time || undefined,
|
||||||
|
proof_type: newChallenge.proof_type,
|
||||||
|
proof_hint: newChallenge.proof_hint.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success('Задание предложено на модерацию')
|
||||||
|
setNewChallenge({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: 'completion',
|
||||||
|
difficulty: 'medium',
|
||||||
|
points: 50,
|
||||||
|
estimated_time: 30,
|
||||||
|
proof_type: 'screenshot',
|
||||||
|
proof_hint: '',
|
||||||
|
})
|
||||||
|
setAddingChallengeToGameId(null)
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось предложить задание')
|
||||||
|
} finally {
|
||||||
|
setIsProposingChallenge(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteMyProposedChallenge = async (challengeId: number) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить предложение?',
|
||||||
|
message: 'Предложенное задание будет удалено.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gamesApi.deleteChallenge(challengeId)
|
||||||
|
toast.success('Предложение удалено')
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось удалить предложение')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateChallenges = async () => {
|
const handleGenerateChallenges = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
@@ -476,14 +714,114 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{gameChallenges[game.id]?.length > 0 ? (
|
{(() => {
|
||||||
gameChallenges[game.id].map((challenge) => (
|
// For organizers: hide pending challenges (they see them in separate block)
|
||||||
|
// For regular users: hide their own pending/rejected challenges (they see them in "My proposals")
|
||||||
|
// but show their own approved challenges in both places
|
||||||
|
const visibleChallenges = isOrganizer
|
||||||
|
? gameChallenges[game.id]?.filter(c => c.status !== 'pending') || []
|
||||||
|
: gameChallenges[game.id]?.filter(c =>
|
||||||
|
!(c.proposed_by?.id === user?.id && c.status !== 'approved')
|
||||||
|
) || []
|
||||||
|
|
||||||
|
return visibleChallenges.length > 0 ? (
|
||||||
|
visibleChallenges.map((challenge) => (
|
||||||
<div
|
<div
|
||||||
key={challenge.id}
|
key={challenge.id}
|
||||||
className="flex items-start justify-between gap-3 p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||||
>
|
>
|
||||||
|
{editingChallengeId === challenge.id ? (
|
||||||
|
// Edit form
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Название задания"
|
||||||
|
value={editChallenge.title}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Описание"
|
||||||
|
value={editChallenge.description}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
className="input w-full resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.type}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="completion">Прохождение</option>
|
||||||
|
<option value="no_death">Без смертей</option>
|
||||||
|
<option value="speedrun">Спидран</option>
|
||||||
|
<option value="collection">Коллекция</option>
|
||||||
|
<option value="achievement">Достижение</option>
|
||||||
|
<option value="challenge_run">Челлендж-ран</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.difficulty}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="easy">Легко</option>
|
||||||
|
<option value="medium">Средне</option>
|
||||||
|
<option value="hard">Сложно</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editChallenge.points}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.proof_type}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="screenshot">Скриншот</option>
|
||||||
|
<option value="video">Видео</option>
|
||||||
|
<option value="steam">Steam</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUpdateChallenge(challenge.id, game.id)}
|
||||||
|
isLoading={isUpdatingChallenge}
|
||||||
|
icon={<Check className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingChallengeId(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Display challenge
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
{challenge.status === 'pending' && getStatusBadge('pending')}
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||||
@@ -500,17 +838,32 @@ export function LobbyPage() {
|
|||||||
<Sparkles className="w-3 h-3" /> ИИ
|
<Sparkles className="w-3 h-3" /> ИИ
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{challenge.proposed_by && (
|
||||||
|
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" /> {challenge.proposed_by.nickname}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
||||||
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
||||||
</div>
|
</div>
|
||||||
{isOrganizer && (
|
{isOrganizer && (
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEditChallenge(challenge)}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
||||||
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -518,15 +871,16 @@ export function LobbyPage() {
|
|||||||
<p className="text-center text-gray-500 py-4 text-sm">
|
<p className="text-center text-gray-500 py-4 text-sm">
|
||||||
Нет заданий
|
Нет заданий
|
||||||
</p>
|
</p>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Add challenge form */}
|
{/* Add/Propose challenge form */}
|
||||||
{isOrganizer && game.status === 'approved' && (
|
{game.status === 'approved' && (
|
||||||
addingChallengeToGameId === game.id ? (
|
addingChallengeToGameId === game.id ? (
|
||||||
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
||||||
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
|
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
|
||||||
<Plus className="w-4 h-4 text-neon-400" />
|
<Plus className="w-4 h-4 text-neon-400" />
|
||||||
Новое задание
|
{isOrganizer ? 'Новое задание' : 'Предложить задание'}
|
||||||
</h4>
|
</h4>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Название задания"
|
placeholder="Название задания"
|
||||||
@@ -613,6 +967,7 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{isOrganizer ? (
|
||||||
<NeonButton
|
<NeonButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCreateChallenge(game.id)}
|
onClick={() => handleCreateChallenge(game.id)}
|
||||||
@@ -622,6 +977,17 @@ export function LobbyPage() {
|
|||||||
>
|
>
|
||||||
Добавить
|
Добавить
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
|
) : (
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleProposeChallenge(game.id)}
|
||||||
|
isLoading={isProposingChallenge}
|
||||||
|
disabled={!newChallenge.title || !newChallenge.description}
|
||||||
|
icon={<Plus className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Предложить
|
||||||
|
</NeonButton>
|
||||||
|
)}
|
||||||
<NeonButton
|
<NeonButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -630,6 +996,11 @@ export function LobbyPage() {
|
|||||||
Отмена
|
Отмена
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
|
{!isOrganizer && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Задание будет отправлено на модерацию организаторам
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -640,7 +1011,7 @@ export function LobbyPage() {
|
|||||||
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
|
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Добавить задание вручную
|
{isOrganizer ? 'Добавить задание вручную' : 'Предложить задание'}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -721,6 +1092,233 @@ export function LobbyPage() {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Proposed challenges for moderation */}
|
||||||
|
{isOrganizer && proposedChallenges.length > 0 && (
|
||||||
|
<GlassCard className="mb-8 border-accent-500/30">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-5 h-5 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-accent-400">Предложенные задания</h3>
|
||||||
|
<p className="text-sm text-gray-400">{proposedChallenges.length} заданий ожидают</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{proposedChallenges.map((challenge) => (
|
||||||
|
<div
|
||||||
|
key={challenge.id}
|
||||||
|
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||||
|
>
|
||||||
|
{editingProposedId === challenge.id ? (
|
||||||
|
// Edit form
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||||
|
{challenge.game.title}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
placeholder="Название задания"
|
||||||
|
value={editChallenge.title}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Описание"
|
||||||
|
value={editChallenge.description}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
className="input w-full resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.type}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="completion">Прохождение</option>
|
||||||
|
<option value="no_death">Без смертей</option>
|
||||||
|
<option value="speedrun">Спидран</option>
|
||||||
|
<option value="collection">Коллекция</option>
|
||||||
|
<option value="achievement">Достижение</option>
|
||||||
|
<option value="challenge_run">Челлендж-ран</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.difficulty}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="easy">Легко</option>
|
||||||
|
<option value="medium">Средне</option>
|
||||||
|
<option value="hard">Сложно</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editChallenge.points}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.proof_type}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="screenshot">Скриншот</option>
|
||||||
|
<option value="video">Видео</option>
|
||||||
|
<option value="steam">Steam</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUpdateProposedChallenge(challenge.id)}
|
||||||
|
isLoading={isUpdatingChallenge}
|
||||||
|
icon={<Check className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingProposedId(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
{challenge.proposed_by && (
|
||||||
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Display
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||||
|
{challenge.game.title}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||||
|
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||||
|
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||||
|
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||||
|
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neon-400 font-semibold">
|
||||||
|
+{challenge.points}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||||
|
<p className="text-sm text-gray-400 mb-2">{challenge.description}</p>
|
||||||
|
{challenge.proposed_by && (
|
||||||
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEditProposed(challenge)}
|
||||||
|
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApproveChallenge(challenge.id)}
|
||||||
|
disabled={approvingChallengeId === challenge.id}
|
||||||
|
className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{approvingChallengeId === challenge.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectChallenge(challenge.id)}
|
||||||
|
disabled={approvingChallengeId === challenge.id}
|
||||||
|
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* My proposed challenges (for non-organizers) */}
|
||||||
|
{!isOrganizer && myProposedChallenges.length > 0 && (
|
||||||
|
<GlassCard className="mb-8 border-neon-500/30">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-5 h-5 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-neon-400">Мои предложения</h3>
|
||||||
|
<p className="text-sm text-gray-400">{myProposedChallenges.length} заданий</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{myProposedChallenges.map((challenge) => (
|
||||||
|
<div
|
||||||
|
key={challenge.id}
|
||||||
|
className="flex items-start justify-between gap-3 p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||||
|
{challenge.game.title}
|
||||||
|
</span>
|
||||||
|
{getStatusBadge(challenge.status)}
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||||
|
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||||
|
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||||
|
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||||
|
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neon-400 font-semibold">
|
||||||
|
+{challenge.points}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||||
|
<p className="text-sm text-gray-400">{challenge.description}</p>
|
||||||
|
</div>
|
||||||
|
{challenge.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteMyProposedChallenge(challenge.id)}
|
||||||
|
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Generate challenges */}
|
{/* Generate challenges */}
|
||||||
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||||||
<GlassCard className="mb-8">
|
<GlassCard className="mb-8">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { z } from 'zod'
|
|||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
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({
|
const loginSchema = z.object({
|
||||||
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
|
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
|
||||||
@@ -17,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
|
|||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate()
|
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 [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const [twoFACode, setTwoFACode] = useState('')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -32,7 +33,12 @@ export function LoginPage() {
|
|||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
clearError()
|
clearError()
|
||||||
try {
|
try {
|
||||||
await login(data)
|
const result = await login(data)
|
||||||
|
|
||||||
|
// If 2FA required, don't navigate
|
||||||
|
if (result.requires2FA) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check for pending invite code
|
// Check for pending invite code
|
||||||
const pendingCode = consumePendingInviteCode()
|
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 = [
|
const features = [
|
||||||
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
|
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
|
||||||
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
|
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
|
||||||
@@ -113,6 +137,63 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{/* Form Block (right) */}
|
{/* Form Block (right) */}
|
||||||
<GlassCard className="p-8">
|
<GlassCard className="p-8">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* 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="Код подтверждения"
|
||||||
|
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}
|
||||||
|
disabled={twoFACode.length !== 6}
|
||||||
|
icon={<Shield className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</NeonButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
|
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
|
||||||
@@ -168,6 +249,8 @@ export function LoginPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</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 type { User } from '@/types'
|
||||||
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
|
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 {
|
interface AuthState {
|
||||||
user: User | null
|
user: User | null
|
||||||
token: string | null
|
token: string | null
|
||||||
@@ -11,8 +26,12 @@ interface AuthState {
|
|||||||
error: string | null
|
error: string | null
|
||||||
pendingInviteCode: string | null
|
pendingInviteCode: string | null
|
||||||
avatarVersion: number
|
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>
|
register: (data: RegisterData) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
@@ -20,6 +39,8 @@ interface AuthState {
|
|||||||
consumePendingInviteCode: () => string | null
|
consumePendingInviteCode: () => string | null
|
||||||
updateUser: (updates: Partial<User>) => void
|
updateUser: (updates: Partial<User>) => void
|
||||||
bumpAvatarVersion: () => void
|
bumpAvatarVersion: () => void
|
||||||
|
setBanned: (banInfo: BanInfo) => void
|
||||||
|
clearBanned: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
@@ -32,11 +53,25 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
error: null,
|
error: null,
|
||||||
pendingInviteCode: null,
|
pendingInviteCode: null,
|
||||||
avatarVersion: 0,
|
avatarVersion: 0,
|
||||||
|
pending2FA: null,
|
||||||
|
banInfo: null,
|
||||||
|
|
||||||
login: async (data) => {
|
login: async (data) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null, pending2FA: null })
|
||||||
try {
|
try {
|
||||||
const response = await authApi.login(data)
|
const response = await authApi.login(data)
|
||||||
|
|
||||||
|
// 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)
|
localStorage.setItem('token', response.access_token)
|
||||||
set({
|
set({
|
||||||
user: response.user,
|
user: response.user,
|
||||||
@@ -44,6 +79,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
return { requires2FA: false }
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
set({
|
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) => {
|
register: async (data) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
@@ -81,6 +149,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
banInfo: null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -104,6 +173,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
bumpAvatarVersion: () => {
|
bumpAvatarVersion: () => {
|
||||||
set({ avatarVersion: get().avatarVersion + 1 })
|
set({ avatarVersion: get().avatarVersion + 1 })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setBanned: (banInfo) => {
|
||||||
|
set({ banInfo })
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBanned: () => {
|
||||||
|
set({ banInfo: null })
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export interface TokenResponse {
|
|||||||
user: User
|
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
|
// Marathon types
|
||||||
export type MarathonStatus = 'preparing' | 'active' | 'finished'
|
export type MarathonStatus = 'preparing' | 'active' | 'finished'
|
||||||
export type ParticipantRole = 'participant' | 'organizer'
|
export type ParticipantRole = 'participant' | 'organizer'
|
||||||
@@ -135,6 +144,13 @@ export type ChallengeType =
|
|||||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||||
export type ProofType = 'screenshot' | 'video' | 'steam'
|
export type ProofType = 'screenshot' | 'video' | 'steam'
|
||||||
|
|
||||||
|
export type ChallengeStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
|
||||||
|
export interface ProposedByUser {
|
||||||
|
id: number
|
||||||
|
nickname: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Challenge {
|
export interface Challenge {
|
||||||
id: number
|
id: number
|
||||||
game: GameShort
|
game: GameShort
|
||||||
@@ -148,6 +164,8 @@ export interface Challenge {
|
|||||||
proof_hint: string | null
|
proof_hint: string | null
|
||||||
is_generated: boolean
|
is_generated: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
|
status: ChallengeStatus
|
||||||
|
proposed_by: ProposedByUser | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChallengePreview {
|
export interface ChallengePreview {
|
||||||
@@ -395,6 +413,10 @@ export interface AdminUser {
|
|||||||
telegram_username: string | null
|
telegram_username: string | null
|
||||||
marathons_count: number
|
marathons_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
is_banned: boolean
|
||||||
|
banned_at: string | null
|
||||||
|
banned_until: string | null // null = permanent ban
|
||||||
|
ban_reason: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminMarathon {
|
export interface AdminMarathon {
|
||||||
@@ -416,6 +438,64 @@ export interface PlatformStats {
|
|||||||
total_participations: number
|
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
|
// Dispute types
|
||||||
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
||||||
|
|
||||||
|
|||||||
@@ -249,8 +249,8 @@ def get_ssl_info(domain: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def cleanup_old_metrics(days: int = 7):
|
def cleanup_old_metrics(days: int = 1):
|
||||||
"""Delete metrics older than specified days."""
|
"""Delete metrics older than specified days (default: 24 hours)."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cutoff = datetime.now() - timedelta(days=days)
|
cutoff = datetime.now() - timedelta(days=days)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://frontend:80")
|
|||||||
BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
|
BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
|
||||||
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
|
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
|
||||||
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
|
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
|
||||||
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "30"))
|
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "600")) # 10 minutes
|
||||||
|
|
||||||
# Initialize monitor
|
# Initialize monitor
|
||||||
monitor = ServiceMonitor()
|
monitor = ServiceMonitor()
|
||||||
@@ -46,11 +46,11 @@ async def periodic_health_check():
|
|||||||
|
|
||||||
|
|
||||||
async def periodic_cleanup():
|
async def periodic_cleanup():
|
||||||
"""Background task to cleanup old metrics (daily)."""
|
"""Background task to cleanup old metrics (hourly)."""
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(86400) # 24 hours
|
await asyncio.sleep(3600) # 1 hour
|
||||||
try:
|
try:
|
||||||
deleted = cleanup_old_metrics(days=7)
|
deleted = cleanup_old_metrics(days=1) # Keep only last 24 hours
|
||||||
print(f"Cleaned up {deleted} old metrics")
|
print(f"Cleaned up {deleted} old metrics")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Cleanup error: {e}")
|
print(f"Cleanup error: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user