{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 (
+ <>
+ + Ваш доступ к платформе был ограничен администрацией. +
+ + {/* Ban Info Card */} +Дата блокировки
+{bannedAtFormatted}
+Срок
++ {bannedUntilFormatted ? `до ${bannedUntilFormatted}` : 'Навсегда'} +
+Причина
++ {banInfo.reason} +
++ {banInfo.banned_until + ? 'Ваш аккаунт будет автоматически разблокирован по истечении срока.' + : 'Если вы считаете, что блокировка ошибочна, обратитесь к администрации.'} +
+ + {/* Logout button */} +Войдите, чтобы продолжить
-