diff --git a/backend/alembic/versions/012_add_user_banned.py b/backend/alembic/versions/012_add_user_banned.py new file mode 100644 index 0000000..f23e59a --- /dev/null +++ b/backend/alembic/versions/012_add_user_banned.py @@ -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') diff --git a/backend/alembic/versions/013_add_admin_logs.py b/backend/alembic/versions/013_add_admin_logs.py new file mode 100644 index 0000000..3dc34a2 --- /dev/null +++ b/backend/alembic/versions/013_add_admin_logs.py @@ -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') diff --git a/backend/alembic/versions/014_add_admin_2fa.py b/backend/alembic/versions/014_add_admin_2fa.py new file mode 100644 index 0000000..a4e3fbf --- /dev/null +++ b/backend/alembic/versions/014_add_admin_2fa.py @@ -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') diff --git a/backend/alembic/versions/015_add_static_content.py b/backend/alembic/versions/015_add_static_content.py new file mode 100644 index 0000000..4c8c6e1 --- /dev/null +++ b/backend/alembic/versions/015_add_static_content.py @@ -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') diff --git a/backend/alembic/versions/016_add_banned_until.py b/backend/alembic/versions/016_add_banned_until.py new file mode 100644 index 0000000..6eb4cc3 --- /dev/null +++ b/backend/alembic/versions/016_add_banned_until.py @@ -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') diff --git a/backend/alembic/versions/017_admin_logs_nullable_admin_id.py b/backend/alembic/versions/017_admin_logs_nullable_admin_id.py new file mode 100644 index 0000000..32c4dc8 --- /dev/null +++ b/backend/alembic/versions/017_admin_logs_nullable_admin_id.py @@ -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) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index bded2ac..468df86 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,4 +1,5 @@ from typing import Annotated +from datetime import datetime from fastapi import Depends, HTTPException, status, Header 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.database import get_db 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() @@ -43,6 +44,50 @@ async def get_current_user( 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 @@ -56,6 +101,21 @@ def require_admin(user: User) -> 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( db: AsyncSession, user_id: int, diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 54f46c2..4cfaeca 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1,6 +1,6 @@ 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") @@ -15,3 +15,4 @@ router.include_router(admin.router) router.include_router(events.router) router.include_router(assignments.router) router.include_router(telegram.router) +router.include_router(content.router) diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 2592f2b..9d6458e 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -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.orm import selectinload from pydantic import BaseModel, Field -from app.api.deps import DbSession, CurrentUser, require_admin -from app.models import User, UserRole, Marathon, Participant, Game -from app.schemas import UserPublic, MarathonListItem, MessageResponse +from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa +from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent +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"]) @@ -14,21 +22,6 @@ class SetUserRole(BaseModel): 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): id: int title: str @@ -44,6 +37,29 @@ class AdminMarathonResponse(BaseModel): 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]) async def list_users( current_user: CurrentUser, @@ -51,9 +67,10 @@ async def list_users( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), search: str | None = None, + banned_only: bool = False, ): """List all users. Admin only.""" - require_admin(current_user) + require_admin_with_2fa(current_user) query = select(User).order_by(User.created_at.desc()) @@ -63,6 +80,9 @@ async def list_users( (User.nickname.ilike(f"%{search}%")) ) + if banned_only: + query = query.where(User.is_banned == True) + query = query.offset(skip).limit(limit) result = await db.execute(query) users = result.scalars().all() @@ -83,6 +103,10 @@ async def list_users( 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, )) return response @@ -91,7 +115,7 @@ async def list_users( @router.get("/users/{user_id}", response_model=AdminUserResponse) async def get_user(user_id: int, current_user: CurrentUser, db: DbSession): """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)) 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, 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, ) @@ -121,9 +149,10 @@ async def set_user_role( data: SetUserRole, current_user: CurrentUser, db: DbSession, + request: Request, ): """Set user's global role. Admin only.""" - require_admin(current_user) + require_admin_with_2fa(current_user) # Cannot change own role if user_id == current_user.id: @@ -134,10 +163,19 @@ async def set_user_role( if not user: raise HTTPException(status_code=404, detail="User not found") + old_role = user.role user.role = data.role await db.commit() 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( 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, 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.delete("/users/{user_id}", response_model=MessageResponse) async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession): """Delete a user. Admin only.""" - require_admin(current_user) + require_admin_with_2fa(current_user) # Cannot delete yourself if user_id == current_user.id: @@ -188,7 +230,7 @@ async def list_marathons( search: str | None = None, ): """List all marathons. Admin only.""" - require_admin(current_user) + require_admin_with_2fa(current_user) query = ( select(Marathon) @@ -227,25 +269,34 @@ async def list_marathons( @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.""" - require_admin(current_user) + 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") + marathon_title = marathon.title await db.delete(marathon) 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") @router.get("/stats") async def get_stats(current_user: CurrentUser, db: DbSession): """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)) 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, "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 + ], + ) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index f3147b4..fe1eac8 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,11 +1,15 @@ +from datetime import datetime, timedelta +import secrets + from fastapi import APIRouter, HTTPException, status, Request from sqlalchemy import select from app.api.deps import DbSession, CurrentUser from app.core.security import verify_password, get_password_hash, create_access_token from app.core.rate_limit import limiter -from app.models import User -from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate +from app.models import User, UserRole, Admin2FASession +from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse +from app.services.telegram_notifier import telegram_notifier 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") async def login(request: Request, data: UserLogin, db: DbSession): # Find user @@ -53,6 +57,99 @@ async def login(request: Request, data: UserLogin, db: DbSession): 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"🔐 Код подтверждения для входа в админку\n\nВаш код: {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 access_token = create_access_token(subject=user.id) diff --git a/backend/app/api/v1/content.py b/backend/app/api/v1/content.py new file mode 100644 index 0000000..46c2c9a --- /dev/null +++ b/backend/app/api/v1/content.py @@ -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 diff --git a/backend/app/api/v1/telegram.py b/backend/app/api/v1/telegram.py index 48d24e7..25193d1 100644 --- a/backend/app/api/v1/telegram.py +++ b/backend/app/api/v1/telegram.py @@ -86,7 +86,7 @@ async def generate_link_token(current_user: CurrentUser): ) 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}" logger.info(f"[TG_LINK] Bot URL: {bot_url}") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8a44015..89456cc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,6 +8,9 @@ from app.models.activity import Activity, ActivityType from app.models.event import Event, EventType from app.models.swap_request import SwapRequest, SwapRequestStatus 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__ = [ "User", @@ -35,4 +38,8 @@ __all__ = [ "DisputeStatus", "DisputeComment", "DisputeVote", + "AdminLog", + "AdminActionType", + "Admin2FASession", + "StaticContent", ] diff --git a/backend/app/models/admin_2fa.py b/backend/app/models/admin_2fa.py new file mode 100644 index 0000000..e9ae248 --- /dev/null +++ b/backend/app/models/admin_2fa.py @@ -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]) diff --git a/backend/app/models/admin_log.py b/backend/app/models/admin_log.py new file mode 100644 index 0000000..afa4685 --- /dev/null +++ b/backend/app/models/admin_log.py @@ -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]) diff --git a/backend/app/models/static_content.py b/backend/app/models/static_content.py new file mode 100644 index 0000000..7637c41 --- /dev/null +++ b/backend/app/models/static_content.py @@ -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]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 88feaaf..8339e5f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,6 @@ from datetime import datetime 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 app.core.database import Base @@ -27,6 +27,13 @@ class User(Base): role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value) 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 created_marathons: Mapped[list["Marathon"]] = relationship( "Marathon", @@ -47,6 +54,11 @@ class User(Base): back_populates="approved_by", foreign_keys="Game.approved_by_id" ) + banned_by: Mapped["User | None"] = relationship( + "User", + remote_side="User.id", + foreign_keys=[banned_by_id] + ) @property def is_admin(self) -> bool: diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 295b2c6..34d3568 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -81,6 +81,22 @@ from app.schemas.dispute import ( AssignmentDetailResponse, ReturnedAssignmentResponse, ) +from app.schemas.admin import ( + BanUserRequest, + AdminUserResponse, + AdminLogResponse, + AdminLogsListResponse, + BroadcastRequest, + BroadcastResponse, + StaticContentResponse, + StaticContentUpdate, + StaticContentCreate, + TwoFactorInitiateRequest, + TwoFactorInitiateResponse, + TwoFactorVerifyRequest, + LoginResponse, + DashboardStats, +) __all__ = [ # User @@ -157,4 +173,19 @@ __all__ = [ "DisputeResponse", "AssignmentDetailResponse", "ReturnedAssignmentResponse", + # Admin + "BanUserRequest", + "AdminUserResponse", + "AdminLogResponse", + "AdminLogsListResponse", + "BroadcastRequest", + "BroadcastResponse", + "StaticContentResponse", + "StaticContentUpdate", + "StaticContentCreate", + "TwoFactorInitiateRequest", + "TwoFactorInitiateResponse", + "TwoFactorVerifyRequest", + "LoginResponse", + "DashboardStats", ] diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..5323e26 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -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] = [] diff --git a/docker-compose.yml b/docker-compose.yml index 7779dcc..2ad6884 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: SECRET_KEY: ${SECRET_KEY:-change-me-in-production} OPENAI_API_KEY: ${OPENAI_API_KEY} 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:-} DEBUG: ${DEBUG:-false} # S3 Storage diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 78dbf64..ea0a209 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { useAuthStore } from '@/store/auth' import { ToastContainer, ConfirmModal } from '@/components/ui' +import { BannedScreen } from '@/components/BannedScreen' // Layout import { Layout } from '@/components/layout/Layout' @@ -23,6 +24,17 @@ import { NotFoundPage } from '@/pages/NotFoundPage' import { TeapotPage } from '@/pages/TeapotPage' import { ServerErrorPage } from '@/pages/ServerErrorPage' +// Admin Pages +import { + AdminLayout, + AdminDashboardPage, + AdminUsersPage, + AdminMarathonsPage, + AdminLogsPage, + AdminBroadcastPage, + AdminContentPage, +} from '@/pages/admin' + // Protected route wrapper function ProtectedRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated) @@ -46,6 +58,19 @@ function PublicRoute({ children }: { children: React.ReactNode }) { } function App() { + const banInfo = useAuthStore((state) => state.banInfo) + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + // Show banned screen if user is authenticated and banned + if (isAuthenticated && banInfo) { + return ( + <> + + + + ) + } + return ( <> @@ -159,6 +184,23 @@ function App() { } /> } /> + {/* Admin routes */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + + {/* 404 - must be last */} } /> diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 526006f..2b3c500 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,10 +1,25 @@ import client from './client' -import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types' +import type { + AdminUser, + AdminMarathon, + UserRole, + PlatformStats, + AdminLogsResponse, + BroadcastResponse, + StaticContent, + DashboardStats +} from '@/types' export const adminApi = { + // Dashboard + getDashboard: async (): Promise => { + const response = await client.get('/admin/dashboard') + return response.data + }, + // Users - listUsers: async (skip = 0, limit = 50, search?: string): Promise => { - const params: Record = { skip, limit } + listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise => { + const params: Record = { skip, limit, banned_only: bannedOnly } if (search) params.search = search const response = await client.get('/admin/users', { params }) return response.data @@ -24,6 +39,19 @@ export const adminApi = { await client.delete(`/admin/users/${id}`) }, + banUser: async (id: number, reason: string, bannedUntil?: string): Promise => { + const response = await client.post(`/admin/users/${id}/ban`, { + reason, + banned_until: bannedUntil || null, + }) + return response.data + }, + + unbanUser: async (id: number): Promise => { + const response = await client.post(`/admin/users/${id}/unban`) + return response.data + }, + // Marathons listMarathons: async (skip = 0, limit = 50, search?: string): Promise => { const params: Record = { skip, limit } @@ -36,9 +64,62 @@ export const adminApi = { await client.delete(`/admin/marathons/${id}`) }, + forceFinishMarathon: async (id: number): Promise => { + await client.post(`/admin/marathons/${id}/force-finish`) + }, + // Stats getStats: async (): Promise => { const response = await client.get('/admin/stats') return response.data }, + + // Logs + getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise => { + const params: Record = { skip, limit } + if (action) params.action = action + if (adminId) params.admin_id = adminId + const response = await client.get('/admin/logs', { params }) + return response.data + }, + + // Broadcast + broadcastToAll: async (message: string): Promise => { + const response = await client.post('/admin/broadcast/all', { message }) + return response.data + }, + + broadcastToMarathon: async (marathonId: number, message: string): Promise => { + const response = await client.post(`/admin/broadcast/marathon/${marathonId}`, { message }) + return response.data + }, + + // Static Content + listContent: async (): Promise => { + const response = await client.get('/admin/content') + return response.data + }, + + getContent: async (key: string): Promise => { + const response = await client.get(`/admin/content/${key}`) + return response.data + }, + + updateContent: async (key: string, title: string, content: string): Promise => { + const response = await client.put(`/admin/content/${key}`, { title, content }) + return response.data + }, + + createContent: async (key: string, title: string, content: string): Promise => { + const response = await client.post('/admin/content', { key, title, content }) + return response.data + }, +} + +// Public content API (no auth required) +export const contentApi = { + getPublicContent: async (key: string): Promise => { + const response = await client.get(`/content/${key}`) + return response.data + }, } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 872f311..4955a23 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,5 +1,5 @@ import client from './client' -import type { TokenResponse, User } from '@/types' +import type { TokenResponse, LoginResponse, User } from '@/types' export interface RegisterData { login: string @@ -18,8 +18,15 @@ export const authApi = { return response.data }, - login: async (data: LoginData): Promise => { - const response = await client.post('/auth/login', data) + login: async (data: LoginData): Promise => { + const response = await client.post('/auth/login', data) + return response.data + }, + + verify2FA: async (sessionId: number, code: string): Promise => { + const response = await client.post('/auth/2fa/verify', null, { + params: { session_id: sessionId, code } + }) return response.data }, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c798505..910616b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,5 @@ import axios, { AxiosError } from 'axios' +import { useAuthStore, type BanInfo } from '@/store/auth' const API_URL = import.meta.env.VITE_API_URL || '/api/v1' @@ -18,10 +19,20 @@ client.interceptors.request.use((config) => { return config }) +// Helper to check if detail is ban info object +function isBanInfo(detail: unknown): detail is BanInfo { + return ( + typeof detail === 'object' && + detail !== null && + 'banned_at' in detail && + 'reason' in detail + ) +} + // Response interceptor to handle errors client.interceptors.response.use( (response) => response, - (error: AxiosError<{ detail: string }>) => { + (error: AxiosError<{ detail: string | BanInfo }>) => { // Unauthorized - redirect to login if (error.response?.status === 401) { localStorage.removeItem('token') @@ -29,6 +40,15 @@ client.interceptors.response.use( window.location.href = '/login' } + // Forbidden - check if user is banned + if (error.response?.status === 403) { + const detail = error.response.data?.detail + if (isBanInfo(detail)) { + // User is banned - set ban info in store + useAuthStore.getState().setBanned(detail) + } + } + // Server error or network error - redirect to 500 page if ( error.response?.status === 500 || diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 67fe8d8..5819376 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -3,7 +3,7 @@ export { marathonsApi } from './marathons' export { gamesApi } from './games' export { wheelApi } from './wheel' export { feedApi } from './feed' -export { adminApi } from './admin' +export { adminApi, contentApi } from './admin' export { eventsApi } from './events' export { challengesApi } from './challenges' export { assignmentsApi } from './assignments' diff --git a/frontend/src/components/BannedScreen.tsx b/frontend/src/components/BannedScreen.tsx new file mode 100644 index 0000000..0758bbd --- /dev/null +++ b/frontend/src/components/BannedScreen.tsx @@ -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 ( +
+ {/* Background effects */} +
+
+
+
+ + {/* Icon */} +
+
+ +
+
+ +
+ {/* Decorative dots */} +
+
+
+ + {/* Title with glow */} +
+

+ Аккаунт заблокирован +

+
+ Аккаунт заблокирован +
+
+ +

+ Ваш доступ к платформе был ограничен администрацией. +

+ + {/* Ban Info Card */} +
+ {bannedAtFormatted && ( +
+
+ +
+
+

Дата блокировки

+

{bannedAtFormatted}

+
+
+ )} + +
+
+ +
+
+

Срок

+

+ {bannedUntilFormatted ? `до ${bannedUntilFormatted}` : 'Навсегда'} +

+
+
+ + {banInfo.reason && ( +
+

Причина

+

+ {banInfo.reason} +

+
+ )} +
+ + {/* Info text */} +

+ {banInfo.banned_until + ? 'Ваш аккаунт будет автоматически разблокирован по истечении срока.' + : 'Если вы считаете, что блокировка ошибочна, обратитесь к администрации.'} +

+ + {/* Logout button */} + } + > + Выйти из аккаунта + + + {/* Decorative sparkles */} +
+ +
+
+ +
+
+ ) +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 8702c49..d5a5b71 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '@/store/auth' -import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react' +import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react' import { TelegramLink } from '@/components/TelegramLink' import { clsx } from 'clsx' @@ -74,6 +74,21 @@ export function Layout() { Марафоны + {user?.role === 'admin' && ( + + + Админка + + )} +
Марафоны + {user?.role === 'admin' && ( + + + Админка + + )} export function LoginPage() { const navigate = useNavigate() - const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore() + const { login, verify2FA, cancel2FA, pending2FA, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore() const [submitError, setSubmitError] = useState(null) + const [twoFACode, setTwoFACode] = useState('') const { register, @@ -32,7 +33,12 @@ export function LoginPage() { setSubmitError(null) clearError() try { - await login(data) + const result = await login(data) + + // If 2FA required, don't navigate + if (result.requires2FA) { + return + } // Check for pending invite code const pendingCode = consumePendingInviteCode() @@ -52,6 +58,24 @@ export function LoginPage() { } } + const handle2FASubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitError(null) + clearError() + try { + await verify2FA(twoFACode) + navigate('/marathons') + } catch { + setSubmitError(error || 'Неверный код') + } + } + + const handleCancel2FA = () => { + cancel2FA() + setTwoFACode('') + setSubmitError(null) + } + const features = [ { icon: , text: 'Соревнуйтесь с друзьями' }, { icon: , text: 'Выполняйте челленджи' }, @@ -113,61 +137,120 @@ export function LoginPage() { {/* Form Block (right) */} - {/* Header */} -
-

Добро пожаловать!

-

Войдите, чтобы продолжить

-
- - {/* Form */} -
- {(submitError || error) && ( -
- - {submitError || error} + {pending2FA ? ( + // 2FA Form + <> + {/* Header */} +
+
+ +
+

Двухфакторная аутентификация

+

Введите код из Telegram

- )} - + {/* 2FA Form */} + + {(submitError || error) && ( +
+ + {submitError || error} +
+ )} - + setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))} + maxLength={6} + className="text-center text-2xl tracking-widest font-mono" + autoFocus + /> - } - > - Войти - - + } + > + Подтвердить + + - {/* Footer */} -
-

- Нет аккаунта?{' '} - - Зарегистрироваться - -

-
+ {/* Back button */} +
+ +
+ + ) : ( + // Regular Login Form + <> + {/* Header */} +
+

Добро пожаловать!

+

Войдите, чтобы продолжить

+
+ + {/* Form */} +
+ {(submitError || error) && ( +
+ + {submitError || error} +
+ )} + + + + + + } + > + Войти + +
+ + {/* Footer */} +
+

+ Нет аккаунта?{' '} + + Зарегистрироваться + +

+
+ + )}
diff --git a/frontend/src/pages/admin/AdminBroadcastPage.tsx b/frontend/src/pages/admin/AdminBroadcastPage.tsx new file mode 100644 index 0000000..7198e43 --- /dev/null +++ b/frontend/src/pages/admin/AdminBroadcastPage.tsx @@ -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(null) + const [marathons, setMarathons] = useState([]) + 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 ( +
+ {/* Header */} +
+
+ +
+

Рассылка уведомлений

+
+ +
+ {/* Target Selection */} +
+ +
+ + +
+
+ + {/* Marathon Selection */} + {targetType === 'marathon' && ( +
+ + {loadingMarathons ? ( +
+ ) : ( + + )} + {marathons.length === 0 && !loadingMarathons && ( +

Нет активных марафонов

+ )} +
+ )} + + {/* Message */} +
+ +