v0.3 - кастомизация аккаунта, просмотр чужих аккаунтов, создание кастомных титулов
This commit is contained in:
+2
-1
@@ -13,9 +13,10 @@ frontend/node_modules/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
|
|
||||||
# DEV-вход (по нику) физически НЕ попадает в прод-образ
|
# DEV-код физически НЕ попадает в прод-образ (вход по нику, удаление аккаунтов)
|
||||||
backend/app/auth/dev_stub.py
|
backend/app/auth/dev_stub.py
|
||||||
backend/app/routers/dev_auth.py
|
backend/app/routers/dev_auth.py
|
||||||
|
backend/app/routers/dev_admin.py
|
||||||
|
|
||||||
# Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt)
|
# Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt)
|
||||||
backend/tests/
|
backend/tests/
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ COPY --from=frontend /app/frontend/dist /app/static
|
|||||||
RUN sed -i 's/\r$//' /app/entrypoint.sh \
|
RUN sed -i 's/\r$//' /app/entrypoint.sh \
|
||||||
&& chmod +x /app/entrypoint.sh \
|
&& chmod +x /app/entrypoint.sh \
|
||||||
&& useradd --create-home --uid 10001 appuser \
|
&& useradd --create-home --uid 10001 appuser \
|
||||||
&& mkdir -p /data/uploads \
|
&& mkdir -p /data/uploads /data/achievements \
|
||||||
&& chown -R appuser:appuser /app /data
|
&& chown -R appuser:appuser /app /data
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Кастомизация профиля: о себе (bio) и аватар (avatar_path).
|
||||||
|
|
||||||
|
Идемпотентна: на свежей БД (0001 создаёт схему из актуальных моделей через create_all)
|
||||||
|
столбцы уже есть → no-op; на существующей БД — добавляет столбцы. render_as_batch включён
|
||||||
|
в env.py (нужен для ALTER в SQLite).
|
||||||
|
|
||||||
|
Revision ID: 0004_user_profile
|
||||||
|
Revises: 0003_match_lifecycle
|
||||||
|
Create Date: 2026-06-17
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0004_user_profile"
|
||||||
|
down_revision: Union[str, None] = "0003_match_lifecycle"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
add = []
|
||||||
|
if "bio" not in cols:
|
||||||
|
add.append(sa.Column("bio", sa.Text(), nullable=True))
|
||||||
|
if "avatar_path" not in cols:
|
||||||
|
add.append(sa.Column("avatar_path", sa.String(255), nullable=True))
|
||||||
|
|
||||||
|
if add:
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
for col in add:
|
||||||
|
b.add_column(col)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
for name in ("avatar_path", "bio"):
|
||||||
|
try:
|
||||||
|
b.drop_column(name)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Задел под титулы/ачивки: users.title_achievement_slug + таблица user_achievements.
|
||||||
|
|
||||||
|
Сами определения ачивок хранятся файлами (settings.achievements_dir), в БД — только
|
||||||
|
будущая выдача игрокам. Идемпотентна (как 0003/0004): на свежей БД (0001 = create_all из
|
||||||
|
актуальных моделей) столбец/таблица уже есть → no-op; на существующей — добавляет.
|
||||||
|
|
||||||
|
Revision ID: 0005_achievements
|
||||||
|
Revises: 0004_user_profile
|
||||||
|
Create Date: 2026-06-17
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0005_achievements"
|
||||||
|
down_revision: Union[str, None] = "0004_user_profile"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
if "title_achievement_slug" not in cols:
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
b.add_column(sa.Column("title_achievement_slug", sa.String(64), nullable=True))
|
||||||
|
|
||||||
|
if "user_achievements" not in insp.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
"user_achievements",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("achievement_slug", sa.String(64), nullable=False),
|
||||||
|
sa.Column("earned_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_user_achievements_user_id", "user_achievements", ["user_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
if "user_achievements" in insp.get_table_names():
|
||||||
|
op.drop_table("user_achievements")
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
try:
|
||||||
|
b.drop_column("title_achievement_slug")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
@@ -15,22 +15,30 @@ from app.models import User
|
|||||||
from app.services import audit_service, user_service
|
from app.services import audit_service, user_service
|
||||||
|
|
||||||
|
|
||||||
def login_with_identity(
|
def establish_session(
|
||||||
session: Session, response: Response, request: Request, identity: ExternalIdentity
|
session: Session, response: Response, request: Request, user: User, provider: str
|
||||||
) -> User:
|
) -> User:
|
||||||
user = user_service.get_or_create_from_identity(session, identity)
|
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
||||||
security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type]
|
security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
action="login",
|
action="login",
|
||||||
entity_type="user",
|
entity_type="user",
|
||||||
entity_id=user.id,
|
entity_id=user.id,
|
||||||
payload={"provider": identity.provider},
|
payload={"provider": provider},
|
||||||
ip=request.client.host if request.client else None,
|
ip=request.client.host if request.client else None,
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def login_with_identity(
|
||||||
|
session: Session, response: Response, request: Request, identity: ExternalIdentity
|
||||||
|
) -> User:
|
||||||
|
"""Вход с авто-созданием (dev-вход по нику): ник подбирается автоматически."""
|
||||||
|
user = user_service.get_or_create_from_identity(session, identity)
|
||||||
|
return establish_session(session, response, request, user, identity.provider)
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ class Settings(BaseSettings):
|
|||||||
dev_upload_dir: str = "./data/dev/uploads"
|
dev_upload_dir: str = "./data/dev/uploads"
|
||||||
prod_upload_dir: str = "/data/uploads"
|
prod_upload_dir: str = "/data/uploads"
|
||||||
|
|
||||||
|
# Каталог ачивок (папка на ачивку: meta.json, condition.py, icon.*), раздельно.
|
||||||
|
dev_achievements_dir: str = "./data/dev/achievements"
|
||||||
|
prod_achievements_dir: str = "/data/achievements"
|
||||||
|
|
||||||
# JWT / cookie
|
# JWT / cookie
|
||||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
secret_key: str = "change-me-dev-secret-not-for-production"
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
@@ -97,6 +101,10 @@ class Settings(BaseSettings):
|
|||||||
def upload_dir(self) -> str:
|
def upload_dir(self) -> str:
|
||||||
return self.dev_upload_dir if self.is_development else self.prod_upload_dir
|
return self.dev_upload_dir if self.is_development else self.prod_upload_dir
|
||||||
|
|
||||||
|
@property
|
||||||
|
def achievements_dir(self) -> str:
|
||||||
|
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_secure(self) -> bool:
|
def cookie_secure(self) -> bool:
|
||||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
||||||
|
|||||||
@@ -94,6 +94,20 @@ class NicknameTakenError(ConflictError):
|
|||||||
super().__init__("Никнейм уже занят.")
|
super().__init__("Никнейм уже занят.")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNicknameRequiredError(ConflictError):
|
||||||
|
"""Первый вход через Telegram, но предлагаемый ник (тег) занят/некорректен —
|
||||||
|
фронт должен показать выбор ника и повторить регистрацию через /telegram/register.
|
||||||
|
`suggested` идёт в details для предзаполнения поля."""
|
||||||
|
|
||||||
|
code = "TELEGRAM_NICKNAME_REQUIRED"
|
||||||
|
|
||||||
|
def __init__(self, suggested: str, message: str | None = None) -> None:
|
||||||
|
super().__init__(
|
||||||
|
message or f"Ник «{suggested}» уже занят — выберите другой.",
|
||||||
|
details={"suggested": suggested},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FactionNotAvailableError(ValidationError):
|
class FactionNotAvailableError(ValidationError):
|
||||||
code = "FACTION_NOT_AVAILABLE"
|
code = "FACTION_NOT_AVAILABLE"
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -15,7 +15,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import AppError, app_error_handler
|
from app.core.errors import AppError, app_error_handler
|
||||||
from app.routers import admin, auth, groups, matches, reference, stats, users
|
from app.routers import achievements, admin, auth, groups, matches, reference, stats, users
|
||||||
|
|
||||||
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
|
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
|
||||||
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
|
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
|
||||||
@@ -107,19 +107,20 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# API-роутеры под /api.
|
# API-роутеры под /api.
|
||||||
api_routers = [auth.router, users.router, groups.router, matches.router,
|
api_routers = [auth.router, users.router, groups.router, matches.router,
|
||||||
reference.router, stats.router, admin.router]
|
reference.router, stats.router, achievements.router, admin.router]
|
||||||
for r in api_routers:
|
for r in api_routers:
|
||||||
app.include_router(r, prefix="/api")
|
app.include_router(r, prefix="/api")
|
||||||
|
|
||||||
# DEV-вход (по нику) — только в development и только если код физически есть
|
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
||||||
# (в test/prod-образе dev_auth/dev_stub исключены, импорт просто не выполнится).
|
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
|
||||||
|
# .dockerignore, импорт просто не выполнится).
|
||||||
if settings.is_development:
|
if settings.is_development:
|
||||||
try:
|
for mod_name in ("dev_auth", "dev_admin"):
|
||||||
from app.routers import dev_auth
|
try:
|
||||||
|
mod = __import__(f"app.routers.{mod_name}", fromlist=["router"])
|
||||||
app.include_router(dev_auth.router, prefix="/api")
|
app.include_router(mod.router, prefix="/api")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@app.get("/api/health", tags=["meta"])
|
@app.get("/api/health", tags=["meta"])
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ class User(SQLModel, table=True):
|
|||||||
sa_column=Column(BigInteger, nullable=True, unique=True)
|
sa_column=Column(BigInteger, nullable=True, unique=True)
|
||||||
)
|
)
|
||||||
nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True))
|
nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True))
|
||||||
|
bio: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
# Относительный путь файла аватара (под settings.upload_dir); наличие = есть аватар.
|
||||||
|
avatar_path: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
|
||||||
role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player"))
|
role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player"))
|
||||||
auth_provider: str = Field(
|
auth_provider: str = Field(
|
||||||
default="stub",
|
default="stub",
|
||||||
@@ -95,6 +98,9 @@ class User(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
|
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
|
||||||
|
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
|
||||||
|
# (выдача ачивок игрокам — следующий этап).
|
||||||
|
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=_utcnow,
|
default_factory=_utcnow,
|
||||||
@@ -103,6 +109,26 @@ class User(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAchievement(SQLModel, table=True):
|
||||||
|
"""Полученные игроком ачивки. Задел под будущую выдачу — сейчас не заполняется.
|
||||||
|
|
||||||
|
Ссылается на ачивку по slug (источник правды — папки на диске, не таблица БД)."""
|
||||||
|
|
||||||
|
__tablename__ = "user_achievements"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
|
||||||
|
earned_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class AuthIdentity(SQLModel, table=True):
|
class AuthIdentity(SQLModel, table=True):
|
||||||
__tablename__ = "auth_identity"
|
__tablename__ = "auth_identity"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Публичное чтение ачивок: список и иконки (для отображения; выдача — на будущее)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas import api as s
|
||||||
|
from app.services import achievement_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/achievements", tags=["achievements"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[s.AchievementRead])
|
||||||
|
def list_achievements(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||||
|
return achievement_service.list_achievements()
|
||||||
|
|
||||||
|
|
||||||
|
# Иконка — без auth: косметический ассет, нужен и игроку, и в админ-панели (cookie
|
||||||
|
# админа scoped на /api/admin и сюда не отправляется). Отдаётся по известному slug.
|
||||||
|
@router.get("/{slug}/icon")
|
||||||
|
def get_icon(slug: str) -> FileResponse:
|
||||||
|
path, media_type = achievement_service.icon_file(slug)
|
||||||
|
return FileResponse(
|
||||||
|
path, media_type=media_type, headers={"Cache-Control": "public, max-age=3600"}
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
|
"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_admin
|
from app.auth.deps import get_current_admin
|
||||||
@@ -11,7 +11,15 @@ from app.db.session import get_session
|
|||||||
from app.models import User
|
from app.models import User
|
||||||
from app.routers.matches import build_match_read
|
from app.routers.matches import build_match_read
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
from app.services import admin_service, audit_service, faction_service, match_service
|
from app.services import (
|
||||||
|
achievement_service,
|
||||||
|
admin_service,
|
||||||
|
audit_service,
|
||||||
|
faction_service,
|
||||||
|
match_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
|
||||||
from app.services.match_service import ParticipantInput
|
from app.services.match_service import ParticipantInput
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
@@ -104,20 +112,9 @@ def update_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}", response_model=s.OkResponse)
|
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||||
def delete_user(
|
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
|
||||||
user_id: int,
|
# отключается (PATCH is_active), удалять нельзя.
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
admin: User = Depends(get_current_admin),
|
|
||||||
) -> s.OkResponse:
|
|
||||||
admin_service.delete_user(session, user_id)
|
|
||||||
audit_service.record(
|
|
||||||
session, actor_id=admin.id, action="delete", entity_type="user", entity_id=user_id,
|
|
||||||
ip=request.client.host if request.client else None,
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
return s.OkResponse()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||||
@@ -295,6 +292,82 @@ def delete_match(
|
|||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ачивки (определения; выдача игрокам — на будущее) ────────────────────────
|
||||||
|
|
||||||
|
@router.get("/achievements", response_model=list[s.AchievementRead])
|
||||||
|
def list_achievements(
|
||||||
|
_admin: User = Depends(get_current_admin),
|
||||||
|
) -> list[dict]:
|
||||||
|
return achievement_service.list_achievements()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/achievements", response_model=s.AchievementRead)
|
||||||
|
def create_achievement(
|
||||||
|
body: s.AchievementCreate,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
ach = achievement_service.create(body.name, body.description, body.condition)
|
||||||
|
audit_service.record(
|
||||||
|
session, actor_id=admin.id, action="create", entity_type="achievement",
|
||||||
|
payload={"slug": ach["slug"], "name": ach["name"]},
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ach
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/achievements/{slug}", response_model=s.AchievementRead)
|
||||||
|
def update_achievement(
|
||||||
|
slug: str,
|
||||||
|
body: s.AchievementUpdate,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
ach = achievement_service.update(
|
||||||
|
slug, name=body.name, description=body.description, condition=body.condition
|
||||||
|
)
|
||||||
|
audit_service.record(
|
||||||
|
session, actor_id=admin.id, action="update", entity_type="achievement",
|
||||||
|
payload={"slug": slug}, ip=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ach
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/achievements/{slug}/icon", response_model=s.AchievementRead)
|
||||||
|
def upload_achievement_icon(
|
||||||
|
slug: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_admin: User = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
from app.core.errors import ValidationError
|
||||||
|
|
||||||
|
content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1)
|
||||||
|
if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES:
|
||||||
|
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
|
||||||
|
ext = achievement_service.validate_icon(content)
|
||||||
|
return achievement_service.set_icon(slug, content, ext)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/achievements/{slug}", response_model=s.OkResponse)
|
||||||
|
def delete_achievement(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.OkResponse:
|
||||||
|
achievement_service.delete(slug)
|
||||||
|
audit_service.record(
|
||||||
|
session, actor_id=admin.id, action="delete", entity_type="achievement",
|
||||||
|
payload={"slug": slug}, ip=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/audit-logs", response_model=s.AuditLogList)
|
@router.get("/audit-logs", response_model=s.AuditLogList)
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.login import login_with_identity
|
from app.auth.login import establish_session
|
||||||
from app.auth.registry import enabled_methods
|
from app.auth.registry import enabled_methods
|
||||||
from app.auth.telegram import TelegramProvider
|
from app.auth.telegram import TelegramProvider
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.errors import TelegramNicknameRequiredError
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.routers.users import build_me
|
from app.routers.users import build_me
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -34,8 +36,44 @@ def telegram_login(
|
|||||||
response: Response,
|
response: Response,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> s.MeRead:
|
) -> s.MeRead:
|
||||||
|
"""Вход/регистрация через Telegram.
|
||||||
|
|
||||||
|
Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход —
|
||||||
|
регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409
|
||||||
|
TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register
|
||||||
|
с выбранным ником.
|
||||||
|
"""
|
||||||
identity = TelegramProvider().authenticate(body.model_dump())
|
identity = TelegramProvider().authenticate(body.model_dump())
|
||||||
user = login_with_identity(session, response, request, identity)
|
user = user_service.find_by_identity(session, identity)
|
||||||
|
if user is None:
|
||||||
|
suggested = (identity.suggested_nickname or "").strip()
|
||||||
|
if not user_service.nickname_format_ok(suggested) or not user_service.nickname_available(
|
||||||
|
session, suggested
|
||||||
|
):
|
||||||
|
raise TelegramNicknameRequiredError(suggested)
|
||||||
|
user = user_service.register_from_identity(session, identity, suggested)
|
||||||
|
establish_session(session, response, request, user, identity.provider)
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram/register", response_model=s.MeRead)
|
||||||
|
def telegram_register(
|
||||||
|
body: s.TelegramRegister,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Завершение регистрации через Telegram с выбранным ником.
|
||||||
|
|
||||||
|
Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность
|
||||||
|
доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел
|
||||||
|
зарегистрироваться (гонка) — просто входим.
|
||||||
|
"""
|
||||||
|
identity = TelegramProvider().authenticate(body.model_dump(exclude={"nickname"}))
|
||||||
|
user = user_service.find_by_identity(session, identity)
|
||||||
|
if user is None:
|
||||||
|
user = user_service.register_from_identity(session, identity, body.nickname)
|
||||||
|
establish_session(session, response, request, user, identity.provider)
|
||||||
return build_me(session, user)
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
||||||
|
|
||||||
|
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
|
||||||
|
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
||||||
|
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
||||||
|
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
|
||||||
|
отключить (PATCH is_active).
|
||||||
|
|
||||||
|
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||||||
|
строки участия игрока удаляются (player_count партий НЕ пересчитывается, поэтому
|
||||||
|
их статистика может стать несогласованной — осознанный выбор). Созданные им партии
|
||||||
|
и группы в его владении переназначаются на админа (FK RESTRICT не даёт удалить
|
||||||
|
пользователя, а сами партии/группы мы сохраняем).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_admin
|
||||||
|
from app.core.errors import NotFoundError, ValidationError
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models import Group, Match, MatchParticipant, User
|
||||||
|
from app.schemas import api as s
|
||||||
|
from app.services import audit_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/dev", tags=["admin-dev"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", response_model=s.OkResponse)
|
||||||
|
def delete_user_hard(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.OkResponse:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
if user.role == "admin":
|
||||||
|
raise ValidationError("Нельзя удалить администратора.")
|
||||||
|
nickname = user.nickname # сохраняем до удаления (для аудита)
|
||||||
|
|
||||||
|
# 1. Вычёркиваем игрока из всех партий (его строки участия).
|
||||||
|
for part in session.exec(
|
||||||
|
select(MatchParticipant).where(MatchParticipant.user_id == user_id)
|
||||||
|
).all():
|
||||||
|
session.delete(part)
|
||||||
|
# 2. Партии и группы, где он создатель/владелец, переназначаем на админа.
|
||||||
|
for match in session.exec(select(Match).where(Match.created_by == user_id)).all():
|
||||||
|
match.created_by = admin.id # type: ignore[assignment]
|
||||||
|
session.add(match)
|
||||||
|
for group in session.exec(select(Group).where(Group.owner_id == user_id)).all():
|
||||||
|
group.owner_id = admin.id # type: ignore[assignment]
|
||||||
|
session.add(group)
|
||||||
|
session.flush()
|
||||||
|
# 3. group_members и auth_identity уйдут каскадом, audit_log.actor_id → NULL.
|
||||||
|
session.delete(user)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=admin.id,
|
||||||
|
action="delete",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user_id,
|
||||||
|
payload={"hard": True, "nickname": nickname},
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return s.OkResponse()
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Роутер текущего пользователя."""
|
"""Роутер текущего пользователя."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
|
from app.core.errors import NotFoundError, ValidationError
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
@@ -12,6 +14,8 @@ from app.services import audit_service, group_service, stats_service, user_servi
|
|||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
_AVATAR_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
|
||||||
|
|
||||||
|
|
||||||
def build_me(session: Session, user: User) -> s.MeRead:
|
def build_me(session: Session, user: User) -> s.MeRead:
|
||||||
groups = [
|
groups = [
|
||||||
@@ -25,6 +29,8 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
|||||||
auth_provider=user.auth_provider,
|
auth_provider=user.auth_provider,
|
||||||
telegram_id=user.telegram_id,
|
telegram_id=user.telegram_id,
|
||||||
active_group_id=user.active_group_id,
|
active_group_id=user.active_group_id,
|
||||||
|
bio=user.bio,
|
||||||
|
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
groups=groups,
|
groups=groups,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,9 +69,84 @@ def update_me(
|
|||||||
auth_provider=user.auth_provider,
|
auth_provider=user.auth_provider,
|
||||||
telegram_id=user.telegram_id,
|
telegram_id=user.telegram_id,
|
||||||
active_group_id=user.active_group_id,
|
active_group_id=user.active_group_id,
|
||||||
|
bio=user.bio,
|
||||||
|
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/profile", response_model=s.MeRead)
|
||||||
|
def update_my_profile(
|
||||||
|
body: s.ProfileUpdate,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> s.MeRead:
|
||||||
|
user_service.update_bio(session, user, body.bio)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=user.id,
|
||||||
|
action="update",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"bio": True},
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/avatar", response_model=s.MeRead)
|
||||||
|
def upload_my_avatar(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> s.MeRead:
|
||||||
|
content = file.file.read(_AVATAR_MAX_BYTES + 1)
|
||||||
|
if len(content) > _AVATAR_MAX_BYTES:
|
||||||
|
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
|
||||||
|
ext = user_service.sniff_image_ext(content)
|
||||||
|
if ext is None:
|
||||||
|
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||||
|
user_service.set_avatar(session, user, content, ext)
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me/avatar", response_model=s.MeRead)
|
||||||
|
def delete_my_avatar(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> s.MeRead:
|
||||||
|
user_service.clear_avatar(session, user)
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/avatar")
|
||||||
|
def get_user_avatar(
|
||||||
|
user_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> FileResponse:
|
||||||
|
target = session.get(User, user_id)
|
||||||
|
path = user_service.avatar_abs_path(target.avatar_path) if target else None
|
||||||
|
if path is None or not path.exists():
|
||||||
|
raise NotFoundError("Аватар не найден.")
|
||||||
|
ext = path.suffix.lstrip(".")
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type=user_service.avatar_media_type(ext),
|
||||||
|
headers={"Cache-Control": "public, max-age=3600"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/profile", response_model=s.PublicProfile)
|
||||||
|
def get_user_profile(
|
||||||
|
user_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
return user_service.public_profile(session, user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/stats", response_model=s.ProfileStats)
|
@router.get("/me/stats", response_model=s.ProfileStats)
|
||||||
def my_stats(
|
def my_stats(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class TelegramAuthPayload(BaseModel):
|
|||||||
photo_url: str | None = None
|
photo_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramRegister(TelegramAuthPayload):
|
||||||
|
# Регистрация через Telegram с явно выбранным ником (когда тег занят/некорректен).
|
||||||
|
# Подпись виджета проверяется по тем же полям; nickname в HMAC не входит.
|
||||||
|
nickname: str
|
||||||
|
|
||||||
|
|
||||||
class DevLogin(BaseModel):
|
class DevLogin(BaseModel):
|
||||||
nickname: str
|
nickname: str
|
||||||
|
|
||||||
@@ -82,6 +88,8 @@ class UserRead(BaseModel):
|
|||||||
auth_provider: str
|
auth_provider: str
|
||||||
telegram_id: int | None = None
|
telegram_id: int | None = None
|
||||||
active_group_id: int | None = None
|
active_group_id: int | None = None
|
||||||
|
bio: str | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MeRead(UserRead):
|
class MeRead(UserRead):
|
||||||
@@ -92,6 +100,10 @@ class NicknameUpdate(BaseModel):
|
|||||||
nickname: str
|
nickname: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
bio: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ActiveGroupUpdate(BaseModel):
|
class ActiveGroupUpdate(BaseModel):
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
|
|
||||||
@@ -231,6 +243,7 @@ class LeaderboardEntry(OverallStats):
|
|||||||
user_id: int
|
user_id: int
|
||||||
nickname: str
|
nickname: str
|
||||||
rank: int | None = None
|
rank: int | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Leaderboard(BaseModel):
|
class Leaderboard(BaseModel):
|
||||||
@@ -267,6 +280,15 @@ class ProfileStats(BaseModel):
|
|||||||
recent_form: list[RecentFormItem] = []
|
recent_form: list[RecentFormItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PublicProfile(BaseModel):
|
||||||
|
# Профиль другого игрока (read-only): шапка + глобальная статистика.
|
||||||
|
user_id: int
|
||||||
|
nickname: str
|
||||||
|
bio: str | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
|
stats: ProfileStats
|
||||||
|
|
||||||
|
|
||||||
class FactionMeta(BaseModel):
|
class FactionMeta(BaseModel):
|
||||||
faction_id: int
|
faction_id: int
|
||||||
code: str
|
code: str
|
||||||
@@ -388,6 +410,27 @@ class AdminMatchRead(BaseModel):
|
|||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementRead(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
condition: str = "" # текст condition.py (задел; не исполняется)
|
||||||
|
has_condition: bool = False
|
||||||
|
icon_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
condition: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
condition: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuditLogItem(BaseModel):
|
class AuditLogItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
actor_id: int | None = None
|
actor_id: int | None = None
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Ачивки: определения хранятся файлами на диске (settings.achievements_dir).
|
||||||
|
|
||||||
|
Папка на ачивку: achievements/<slug>/
|
||||||
|
meta.json — {"name", "description", "created_at"}
|
||||||
|
condition.py — скрипт-условие выдачи (ЗАДЕЛ: только хранится как текст, НЕ исполняется)
|
||||||
|
icon.<ext> — картинка-герб (png/jpg/webp), опционально
|
||||||
|
|
||||||
|
Источник правды — файлы. В БД (user_achievements / users.title_achievement_slug) — только
|
||||||
|
будущая выдача игрокам (сейчас не используется). Выдача и исполнение condition.py — следующий
|
||||||
|
этап; здесь админ лишь создаёт/именует/описывает ачивки.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||||
|
from app.services.user_service import avatar_media_type, sniff_image_ext
|
||||||
|
|
||||||
|
_CONDITION_FILE = "condition.py"
|
||||||
|
_META_FILE = "meta.json"
|
||||||
|
_CONDITION_PLACEHOLDER = (
|
||||||
|
"# Условие выдачи ачивки (ЗАДЕЛ на будущее — пока не исполняется).\n"
|
||||||
|
"# Здесь будет описана логика автоматической выдачи этого титула игроку.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Транслитерация для slug из русского названия.
|
||||||
|
_TRANSLIT = {
|
||||||
|
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh",
|
||||||
|
"з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o",
|
||||||
|
"п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "ts",
|
||||||
|
"ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu",
|
||||||
|
"я": "ya",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _root() -> Path:
|
||||||
|
return Path(settings.achievements_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(name: str) -> str:
|
||||||
|
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
||||||
|
return slug[:48]
|
||||||
|
|
||||||
|
|
||||||
|
def _dir(slug: str) -> Path:
|
||||||
|
return _root() / slug
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_path(slug: str) -> Path | None:
|
||||||
|
folder = _dir(slug)
|
||||||
|
if not folder.is_dir():
|
||||||
|
return None
|
||||||
|
for f in sorted(folder.glob("icon.*")):
|
||||||
|
if f.is_file():
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read(slug: str) -> dict:
|
||||||
|
folder = _dir(slug)
|
||||||
|
meta_file = folder / _META_FILE
|
||||||
|
if not meta_file.is_file():
|
||||||
|
raise NotFoundError("Ачивка не найдена.")
|
||||||
|
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||||
|
condition = ""
|
||||||
|
cond_file = folder / _CONDITION_FILE
|
||||||
|
if cond_file.is_file():
|
||||||
|
condition = cond_file.read_text(encoding="utf-8")
|
||||||
|
icon = _icon_path(slug)
|
||||||
|
icon_url = None
|
||||||
|
if icon is not None:
|
||||||
|
version = int(icon.stat().st_mtime)
|
||||||
|
icon_url = f"/api/achievements/{slug}/icon?v={version}"
|
||||||
|
return {
|
||||||
|
"slug": slug,
|
||||||
|
"name": meta.get("name", slug),
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"condition": condition,
|
||||||
|
"has_condition": bool(condition.strip()),
|
||||||
|
"icon_url": icon_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_achievements() -> list[dict]:
|
||||||
|
root = _root()
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for folder in sorted(root.iterdir()):
|
||||||
|
if folder.is_dir() and (folder / _META_FILE).is_file():
|
||||||
|
out.append(_read(folder.name))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get(slug: str) -> dict:
|
||||||
|
return _read(slug)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_name(name: str) -> str:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not (1 <= len(name) <= 64):
|
||||||
|
raise ValidationError("Название ачивки: 1–64 символа.")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def create(name: str, description: str | None, condition: str | None) -> dict:
|
||||||
|
name = _validate_name(name)
|
||||||
|
slug = _slugify(name)
|
||||||
|
if not slug:
|
||||||
|
raise ValidationError("Не удалось сформировать идентификатор из названия.")
|
||||||
|
folder = _dir(slug)
|
||||||
|
if folder.exists():
|
||||||
|
raise ConflictError("Ачивка с таким названием уже существует.")
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
_write_meta(slug, name, (description or "").strip())
|
||||||
|
(folder / _CONDITION_FILE).write_text(
|
||||||
|
condition if condition else _CONDITION_PLACEHOLDER, encoding="utf-8"
|
||||||
|
)
|
||||||
|
return _read(slug)
|
||||||
|
|
||||||
|
|
||||||
|
def update(
|
||||||
|
slug: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
condition: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
folder = _dir(slug)
|
||||||
|
meta_file = folder / _META_FILE
|
||||||
|
if not meta_file.is_file():
|
||||||
|
raise NotFoundError("Ачивка не найдена.")
|
||||||
|
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||||
|
# slug не меняем (он стабильный идентификатор/путь), даже если правят название.
|
||||||
|
new_name = _validate_name(name) if name is not None else meta.get("name", slug)
|
||||||
|
new_desc = description.strip() if description is not None else meta.get("description", "")
|
||||||
|
_write_meta(slug, new_name, new_desc, created_at=meta.get("created_at"))
|
||||||
|
if condition is not None:
|
||||||
|
(folder / _CONDITION_FILE).write_text(condition, encoding="utf-8")
|
||||||
|
return _read(slug)
|
||||||
|
|
||||||
|
|
||||||
|
def set_icon(slug: str, content: bytes, ext: str) -> dict:
|
||||||
|
folder = _dir(slug)
|
||||||
|
if not (folder / _META_FILE).is_file():
|
||||||
|
raise NotFoundError("Ачивка не найдена.")
|
||||||
|
old = _icon_path(slug)
|
||||||
|
if old is not None:
|
||||||
|
try:
|
||||||
|
old.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
(folder / f"icon.{ext}").write_bytes(content)
|
||||||
|
return _read(slug)
|
||||||
|
|
||||||
|
|
||||||
|
def icon_file(slug: str) -> tuple[Path, str]:
|
||||||
|
"""Путь к файлу иконки и его media_type; NotFound, если иконки нет."""
|
||||||
|
icon = _icon_path(slug)
|
||||||
|
if icon is None:
|
||||||
|
raise NotFoundError("Иконка не найдена.")
|
||||||
|
return icon, avatar_media_type(icon.suffix.lstrip("."))
|
||||||
|
|
||||||
|
|
||||||
|
def delete(slug: str) -> None:
|
||||||
|
folder = _dir(slug)
|
||||||
|
if not folder.is_dir():
|
||||||
|
raise NotFoundError("Ачивка не найдена.")
|
||||||
|
shutil.rmtree(folder)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_icon(content: bytes) -> str:
|
||||||
|
"""Проверяет, что это поддерживаемая картинка, и возвращает расширение."""
|
||||||
|
ext = sniff_image_ext(content)
|
||||||
|
if ext is None:
|
||||||
|
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||||
|
return ext
|
||||||
|
|
||||||
|
|
||||||
|
def _write_meta(slug: str, name: str, description: str, created_at: str | None = None) -> None:
|
||||||
|
meta = {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"created_at": created_at or datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
(_dir(slug) / _META_FILE).write_text(
|
||||||
|
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
@@ -5,10 +5,16 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.errors import InvalidCredentialsError, NotFoundError, ValidationError
|
from app.core.errors import (
|
||||||
|
InvalidCredentialsError,
|
||||||
|
NicknameTakenError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
from app.core.security import verify_password
|
from app.core.security import verify_password
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User
|
from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
|
|
||||||
def authenticate_admin(session: Session, username: str, password: str) -> User:
|
def authenticate_admin(session: Session, username: str, password: str) -> User:
|
||||||
@@ -39,7 +45,14 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("Пользователь не найден.")
|
raise NotFoundError("Пользователь не найден.")
|
||||||
if nickname is not None:
|
if nickname is not None:
|
||||||
user.nickname = nickname.strip()
|
nickname = nickname.strip()
|
||||||
|
# Та же валидация, что и для самостоятельной смены ника игроком:
|
||||||
|
# корректный формат и уникальность (иначе занятый ник упал бы в 500).
|
||||||
|
if not user_service.nickname_format_ok(nickname):
|
||||||
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||||
|
if not user_service.nickname_available(session, nickname, exclude_user_id=user_id):
|
||||||
|
raise NicknameTakenError()
|
||||||
|
user.nickname = nickname
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
user.is_active = is_active
|
user.is_active = is_active
|
||||||
session.add(user)
|
session.add(user)
|
||||||
@@ -48,12 +61,8 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def delete_user(session: Session, user_id: int) -> None:
|
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
||||||
user = session.get(User, user_id)
|
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
||||||
if user is None:
|
|
||||||
raise NotFoundError("Пользователь не найден.")
|
|
||||||
session.delete(user)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ def _round(value: Any, ndigits: int) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize(row: dict) -> dict:
|
def _normalize(row: dict) -> dict:
|
||||||
|
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
|
||||||
|
avatar_url = None
|
||||||
|
if row.get("avatar_path"):
|
||||||
|
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
|
||||||
return {
|
return {
|
||||||
"user_id": row["user_id"],
|
"user_id": row["user_id"],
|
||||||
"nickname": row["nickname"],
|
"nickname": row["nickname"],
|
||||||
@@ -57,6 +61,7 @@ def _normalize(row: dict) -> dict:
|
|||||||
"win_rate": _round(row["win_rate"] or 0.0, 4),
|
"win_rate": _round(row["win_rate"] or 0.0, 4),
|
||||||
"avg_place": _round(row["avg_place"], 2),
|
"avg_place": _round(row["avg_place"], 2),
|
||||||
"score": _round(row["score"], 1),
|
"score": _round(row["score"], 1),
|
||||||
|
"avatar_url": avatar_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +70,8 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
|
|||||||
sql = f"""
|
sql = f"""
|
||||||
{SCORED_CTE}
|
{SCORED_CTE}
|
||||||
SELECT u.id AS user_id, u.nickname AS nickname,
|
SELECT u.id AS user_id, u.nickname AS nickname,
|
||||||
|
u.avatar_path AS avatar_path,
|
||||||
|
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
|
||||||
COUNT(*) AS games,
|
COUNT(*) AS games,
|
||||||
SUM(s.is_win) AS wins,
|
SUM(s.is_win) AS wins,
|
||||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||||
@@ -73,7 +80,7 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
|
|||||||
FROM scored s
|
FROM scored s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
{where}
|
{where}
|
||||||
GROUP BY u.id, u.nickname
|
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
|
||||||
"""
|
"""
|
||||||
params = {"gid": group_id} if group_id is not None else {}
|
params = {"gid": group_id} if group_id is not None else {}
|
||||||
result = session.execute(text(sql), params).mappings().all()
|
result = session.execute(text(sql), params).mappings().all()
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
"""Пользователи: создание из внешней личности, ник, активная группа."""
|
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.auth.provider import ExternalIdentity
|
from app.auth.provider import ExternalIdentity
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||||
from app.models import AuthIdentity, GroupMember, User
|
from app.models import AuthIdentity, GroupMember, User
|
||||||
|
|
||||||
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
|
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
|
||||||
|
_BIO_MAX = 500
|
||||||
|
|
||||||
|
# Аватары: файл на диске под settings.upload_dir/avatars, путь относительный.
|
||||||
|
_AVATAR_SUBDIR = "avatars"
|
||||||
|
_IMAGE_MEDIA = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||||
|
|
||||||
|
|
||||||
def get_user(session: Session, user_id: int) -> User:
|
def get_user(session: Session, user_id: int) -> User:
|
||||||
@@ -25,6 +34,11 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N
|
|||||||
return existing is None or existing.id == exclude_user_id
|
return existing is None or existing.id == exclude_user_id
|
||||||
|
|
||||||
|
|
||||||
|
def nickname_format_ok(nickname: str) -> bool:
|
||||||
|
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
|
||||||
|
return bool(_NICK_RE.match((nickname or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
def _unique_nickname(session: Session, base: str) -> str:
|
def _unique_nickname(session: Session, base: str) -> str:
|
||||||
base = (base or "Игрок").strip()[:60] or "Игрок"
|
base = (base or "Игрок").strip()[:60] or "Игрок"
|
||||||
candidate = base
|
candidate = base
|
||||||
@@ -35,18 +49,24 @@ def _unique_nickname(session: Session, base: str) -> str:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
|
def find_by_identity(session: Session, identity: ExternalIdentity) -> User | None:
|
||||||
|
"""Существующий пользователь по внешней личности (provider+external_id).
|
||||||
|
|
||||||
|
Для Telegram external_id — это НЕИЗМЕНЯЕМЫЙ числовой id аккаунта (не @тег), поэтому
|
||||||
|
вход с любого устройства/после смены тега ведёт к одному и тому же пользователю.
|
||||||
|
"""
|
||||||
link = session.exec(
|
link = session.exec(
|
||||||
select(AuthIdentity).where(
|
select(AuthIdentity).where(
|
||||||
AuthIdentity.provider == identity.provider,
|
AuthIdentity.provider == identity.provider,
|
||||||
AuthIdentity.external_id == identity.external_id,
|
AuthIdentity.external_id == identity.external_id,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if link is not None:
|
return get_user(session, link.user_id) if link is not None else None
|
||||||
return get_user(session, link.user_id)
|
|
||||||
|
|
||||||
|
|
||||||
|
def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User:
|
||||||
user = User(
|
user = User(
|
||||||
nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id),
|
nickname=nickname,
|
||||||
role="player",
|
role="player",
|
||||||
auth_provider=identity.provider,
|
auth_provider=identity.provider,
|
||||||
telegram_id=identity.telegram_id,
|
telegram_id=identity.telegram_id,
|
||||||
@@ -65,6 +85,31 @@ def get_or_create_from_identity(session: Session, identity: ExternalIdentity) ->
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
|
||||||
|
"""Найти по личности или создать, авто-подбирая уникальный ник (dev-вход по нику)."""
|
||||||
|
user = find_by_identity(session, identity)
|
||||||
|
if user is not None:
|
||||||
|
return user
|
||||||
|
nickname = _unique_nickname(session, identity.suggested_nickname or identity.external_id)
|
||||||
|
return _create_from_identity(session, identity, nickname)
|
||||||
|
|
||||||
|
|
||||||
|
def register_from_identity(
|
||||||
|
session: Session, identity: ExternalIdentity, nickname: str
|
||||||
|
) -> User:
|
||||||
|
"""Создать пользователя с ЯВНО выбранным ником (регистрация через Telegram).
|
||||||
|
|
||||||
|
В отличие от get_or_create — ник не подбирается автоматически: занятый/неподходящий
|
||||||
|
ник вызывает ошибку, чтобы пользователь выбрал другой.
|
||||||
|
"""
|
||||||
|
nickname = (nickname or "").strip()
|
||||||
|
if not nickname_format_ok(nickname):
|
||||||
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||||
|
if not nickname_available(session, nickname):
|
||||||
|
raise NicknameTakenError()
|
||||||
|
return _create_from_identity(session, identity, nickname)
|
||||||
|
|
||||||
|
|
||||||
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||||
new_nickname = (new_nickname or "").strip()
|
new_nickname = (new_nickname or "").strip()
|
||||||
if not _NICK_RE.match(new_nickname):
|
if not _NICK_RE.match(new_nickname):
|
||||||
@@ -92,3 +137,98 @@ def set_active_group(session: Session, user: User, group_id: int | None) -> User
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Профиль: «о себе», аватар ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | None) -> str | None:
|
||||||
|
"""URL аватара с кэш-бастингом по updated_at. None, если аватара нет.
|
||||||
|
|
||||||
|
Один источник для build_me, публичного профиля и лидерборда — чтобы клиент
|
||||||
|
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
|
||||||
|
if not avatar_path:
|
||||||
|
return None
|
||||||
|
version = int(updated_at.timestamp()) if updated_at else 0
|
||||||
|
return f"/api/users/{user_id}/avatar?v={version}"
|
||||||
|
|
||||||
|
|
||||||
|
def update_bio(session: Session, user: User, bio: str | None) -> User:
|
||||||
|
bio = (bio or "").strip()
|
||||||
|
if len(bio) > _BIO_MAX:
|
||||||
|
raise ValidationError(f"«О себе»: не более {_BIO_MAX} символов.")
|
||||||
|
user.bio = bio or None
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def sniff_image_ext(content: bytes) -> str | None:
|
||||||
|
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
|
||||||
|
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
return "png"
|
||||||
|
if content.startswith(b"\xff\xd8\xff"):
|
||||||
|
return "jpg"
|
||||||
|
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||||
|
return "webp"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_media_type(ext: str) -> str:
|
||||||
|
return _IMAGE_MEDIA.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_abs_path(avatar_path: str | None) -> Path | None:
|
||||||
|
"""Абсолютный путь файла аватара (или None), для отдачи через FileResponse."""
|
||||||
|
if not avatar_path:
|
||||||
|
return None
|
||||||
|
return Path(settings.upload_dir) / avatar_path
|
||||||
|
|
||||||
|
|
||||||
|
def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
|
||||||
|
avatars_dir = Path(settings.upload_dir) / _AVATAR_SUBDIR
|
||||||
|
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Удаляем прежний файл (мог быть с другим расширением), затем пишем новый.
|
||||||
|
old = avatar_abs_path(user.avatar_path)
|
||||||
|
if old and old.exists():
|
||||||
|
try:
|
||||||
|
old.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
|
||||||
|
(Path(settings.upload_dir) / rel).write_bytes(content)
|
||||||
|
user.avatar_path = rel
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def clear_avatar(session: Session, user: User) -> User:
|
||||||
|
path = avatar_abs_path(user.avatar_path)
|
||||||
|
if path and path.exists():
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
user.avatar_path = None
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def public_profile(session: Session, user_id: int) -> dict:
|
||||||
|
"""Публичный профиль игрока: ник/«о себе»/аватар + глобальная статистика."""
|
||||||
|
from app.services import stats_service # ленивый импорт (избегаем цикла)
|
||||||
|
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"bio": user.bio,
|
||||||
|
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
|
"stats": stats_service.profile_stats(session, user_id, group_id=None),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Ачивки: админ создаёт/правит/удаляет, грузит иконку; condition хранится как текст."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import csrf_headers
|
||||||
|
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||||
|
|
||||||
|
|
||||||
|
def _admin(client: TestClient, make_admin) -> None:
|
||||||
|
make_admin("admin", "secret123")
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "admin", "password": "secret123"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def _use_tmp_achievements(monkeypatch, tmp_path) -> Path:
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "dev_achievements_dir", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_list_and_files(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||||
|
root = _use_tmp_achievements(monkeypatch, tmp_path)
|
||||||
|
_admin(client, make_admin)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/achievements",
|
||||||
|
json={"name": "Великий полководец", "description": "10 побед подряд"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
ach = r.json()
|
||||||
|
slug = ach["slug"]
|
||||||
|
assert slug and ach["name"] == "Великий полководец"
|
||||||
|
|
||||||
|
# На диске создана папка с meta.json и condition.py (задел, не исполняется).
|
||||||
|
folder = root / slug
|
||||||
|
assert (folder / "meta.json").is_file()
|
||||||
|
assert (folder / "condition.py").is_file()
|
||||||
|
meta = json.loads((folder / "meta.json").read_text(encoding="utf-8"))
|
||||||
|
assert meta["name"] == "Великий полководец"
|
||||||
|
|
||||||
|
# Виден в админском списке (редактор использует именно его).
|
||||||
|
assert any(a["slug"] == slug for a in client.get("/api/admin/achievements").json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_name_conflicts(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||||
|
_use_tmp_achievements(monkeypatch, tmp_path)
|
||||||
|
_admin(client, make_admin)
|
||||||
|
body = {"name": "Берсерк"}
|
||||||
|
assert client.post("/api/admin/achievements", json=body, headers=csrf_headers(client)).status_code == 200
|
||||||
|
r = client.post("/api/admin/achievements", json=body, headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_upload_and_serve(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||||
|
_use_tmp_achievements(monkeypatch, tmp_path)
|
||||||
|
_admin(client, make_admin)
|
||||||
|
slug = client.post(
|
||||||
|
"/api/admin/achievements", json={"name": "Герой"}, headers=csrf_headers(client)
|
||||||
|
).json()["slug"]
|
||||||
|
|
||||||
|
# Не картинка → 422.
|
||||||
|
bad = client.put(
|
||||||
|
f"/api/admin/achievements/{slug}/icon",
|
||||||
|
files={"file": ("x.txt", b"nope", "text/plain")},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert bad.status_code == 422, bad.text
|
||||||
|
|
||||||
|
# Валидный PNG → icon_url, файл отдаётся.
|
||||||
|
ok = client.put(
|
||||||
|
f"/api/admin/achievements/{slug}/icon",
|
||||||
|
files={"file": ("h.png", PNG, "image/png")},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200 and ok.json()["icon_url"], ok.text
|
||||||
|
g = client.get(f"/api/achievements/{slug}/icon")
|
||||||
|
assert g.status_code == 200 and g.content == PNG
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||||
|
_use_tmp_achievements(monkeypatch, tmp_path)
|
||||||
|
_admin(client, make_admin)
|
||||||
|
slug = client.post(
|
||||||
|
"/api/admin/achievements", json={"name": "Стратег"}, headers=csrf_headers(client)
|
||||||
|
).json()["slug"]
|
||||||
|
|
||||||
|
# Правка описания и текста условия (condition хранится как текст).
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/admin/achievements/{slug}",
|
||||||
|
json={"description": "Победа по целям", "condition": "wins_by_objectives >= 5"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["description"] == "Победа по целям"
|
||||||
|
assert r.json()["has_condition"] is True
|
||||||
|
assert "wins_by_objectives" in r.json()["condition"]
|
||||||
|
|
||||||
|
# Удаление.
|
||||||
|
assert client.delete(
|
||||||
|
f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
|
||||||
|
).status_code == 200
|
||||||
|
assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json())
|
||||||
@@ -106,6 +106,87 @@ def test_admin_edit_match_and_filters(client: TestClient, make_admin):
|
|||||||
assert bp["faction_name"] == "Хаоситы"
|
assert bp["faction_name"] == "Хаоситы"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_rename_account(client: TestClient, make_admin):
|
||||||
|
"""Админ переименовывает аккаунт: валидируется формат и уникальность ника."""
|
||||||
|
a = login(client, "Старое")
|
||||||
|
client.post("/api/auth/dev/users", json={"nickname": "Занятое"}, headers=csrf_headers(client))
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/admin/users/{a['id']}", json={"nickname": "Новое"}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["nickname"] == "Новое"
|
||||||
|
|
||||||
|
# Занятый ник → 409, некорректный формат → 422.
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/admin/users/{a['id']}", json={"nickname": "Занятое"}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 409 and r.json()["error"]["code"] == "NICKNAME_TAKEN", r.text
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/admin/users/{a['id']}", json={"nickname": "x"}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin):
|
||||||
|
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
|
||||||
|
его созданные партии и группы переназначаются на админа."""
|
||||||
|
me = login(client, "Аня") # владелец группы и создатель партии
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
|
||||||
|
b = client.post(
|
||||||
|
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
|
||||||
|
).json()["user_id"]
|
||||||
|
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||||
|
mid = create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": factions["eldar"], "place": 2},
|
||||||
|
],
|
||||||
|
)["id"]
|
||||||
|
|
||||||
|
admin_id = make_admin("admin", "secret123")
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "admin", "password": "secret123"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Удаляем участника Борю → партия жива, его строка участия вычеркнута.
|
||||||
|
r = client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert all(u["id"] != b for u in client.get("/api/admin/users").json())
|
||||||
|
detail = client.get(f"/api/admin/matches/{mid}").json()
|
||||||
|
assert [p["user_id"] for p in detail["participants"]] == [me["id"]] # Боря вычеркнут
|
||||||
|
assert detail["player_count"] == 2 # player_count НЕ пересчитывается (осознанно)
|
||||||
|
|
||||||
|
# Удаляем создателя/владельца Аню → партия и группа сохраняются, переходят к админу.
|
||||||
|
r = client.delete(f"/api/admin/dev/users/{me['id']}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert client.get(f"/api/admin/matches/{mid}").json()["created_by"] == admin_id
|
||||||
|
grp = next(g for g in client.get("/api/admin/groups").json() if g["id"] == gid)
|
||||||
|
assert grp["owner_id"] == admin_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_delete_account_rejects_admin(client: TestClient, make_admin):
|
||||||
|
"""Админа удалить нельзя."""
|
||||||
|
admin_id = make_admin("admin", "secret123")
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "admin", "password": "secret123"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
r = client.delete(f"/api/admin/dev/users/{admin_id}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
||||||
me = login(client, "Кто-то")
|
me = login(client, "Кто-то")
|
||||||
gid = client.post(
|
gid = client.post(
|
||||||
|
|||||||
@@ -79,3 +79,64 @@ def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch):
|
|||||||
r = client.post("/api/auth/telegram", json=payload)
|
r = client.post("/api/auth/telegram", json=payload)
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
_ = csrf_headers # (для единообразия импорта)
|
_ = csrf_headers # (для единообразия импорта)
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_same_id_no_duplicate_account(client: TestClient, monkeypatch):
|
||||||
|
"""Вход с двух устройств / после смены тега — один аккаунт (ключ = числовой id)."""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
r1 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="firsttag"))
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
|
||||||
|
client.cookies.clear() # «другое устройство» — без сессии
|
||||||
|
# Тот же telegram id (777), но другой тег и свежий auth_date.
|
||||||
|
r2 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="changedtag"))
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["id"] == r1.json()["id"] # тот же пользователь
|
||||||
|
assert r2.json()["nickname"] == r1.json()["nickname"] # ник не пересоздаётся
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_register_nickname_collision_then_pick(client: TestClient, engine, monkeypatch):
|
||||||
|
"""Первый вход через ТГ, тег совпал с чужим ником → просим выбрать ник → регистрируем."""
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
with Session(engine) as s: # существующий игрок с ником = тегу из payload (ivan_tg)
|
||||||
|
s.add(User(nickname="ivan_tg", role="player", auth_provider="stub"))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
payload = _telegram_payload("TEST_BOT_TOKEN") # id=777, username=ivan_tg
|
||||||
|
r = client.post("/api/auth/telegram", json=payload)
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
err = r.json()["error"]
|
||||||
|
assert err["code"] == "TELEGRAM_NICKNAME_REQUIRED"
|
||||||
|
assert err["details"]["suggested"] == "ivan_tg"
|
||||||
|
|
||||||
|
# Повтор с выбранным свободным ником (та же подпись виджета).
|
||||||
|
r2 = client.post("/api/auth/telegram/register", json={**payload, "nickname": "Иван-новый"})
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["nickname"] == "Иван-новый"
|
||||||
|
assert r2.json()["telegram_id"] == 777
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_register_rejects_taken_nickname(client: TestClient, engine, monkeypatch):
|
||||||
|
"""На шаге выбора ника всё ещё занятый ник отклоняется (NICKNAME_TAKEN)."""
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
with Session(engine) as s:
|
||||||
|
s.add(User(nickname="ivan_tg", role="player", auth_provider="stub"))
|
||||||
|
s.add(User(nickname="занятый", role="player", auth_provider="stub"))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
payload = _telegram_payload("TEST_BOT_TOKEN")
|
||||||
|
r = client.post("/api/auth/telegram/register", json={**payload, "nickname": "занятый"})
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
assert r.json()["error"]["code"] == "NICKNAME_TAKEN"
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import create_finished_match, csrf_headers, login
|
||||||
|
|
||||||
|
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||||
|
|
||||||
|
|
||||||
|
def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _finished_match_for(client: TestClient, me: dict) -> int:
|
||||||
|
"""Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд)."""
|
||||||
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client))
|
||||||
|
p2 = client.post(
|
||||||
|
f"/api/groups/{gid}/members", json={"nickname": "Соперник"}, headers=csrf_headers(client)
|
||||||
|
).json()["user_id"]
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": p2, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return gid
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_bio(client: TestClient):
|
||||||
|
login(client, "Игрок")
|
||||||
|
r = client.patch("/api/users/me/profile", json={"bio": "Люблю орков"}, headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["bio"] == "Люблю орков"
|
||||||
|
# Слишком длинное «о себе» → 422.
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile", json={"bio": "x" * 501}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_avatar_upload_get_delete(client: TestClient, monkeypatch, tmp_path):
|
||||||
|
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||||
|
me = login(client, "Аватарыч")
|
||||||
|
|
||||||
|
# Загрузка валидного PNG → avatar_url выставлен.
|
||||||
|
r = client.put(
|
||||||
|
"/api/users/me/avatar",
|
||||||
|
files={"file": ("a.png", PNG, "image/png")},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
avatar_url = r.json()["avatar_url"]
|
||||||
|
assert avatar_url and "/avatar?v=" in avatar_url
|
||||||
|
|
||||||
|
# Отдача файла.
|
||||||
|
g = client.get(f"/api/users/{me['id']}/avatar")
|
||||||
|
assert g.status_code == 200, g.text
|
||||||
|
assert g.content == PNG
|
||||||
|
assert g.headers["content-type"] == "image/png"
|
||||||
|
|
||||||
|
# Удаление → avatar_url пропадает, файл больше не отдаётся.
|
||||||
|
r = client.delete("/api/users/me/avatar", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200 and r.json()["avatar_url"] is None, r.text
|
||||||
|
assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_avatar_rejects_non_image_and_oversize(client: TestClient, monkeypatch, tmp_path):
|
||||||
|
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||||
|
login(client, "Игрок")
|
||||||
|
|
||||||
|
r = client.put(
|
||||||
|
"/api/users/me/avatar",
|
||||||
|
files={"file": ("x.txt", b"not an image", "text/plain")},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
big = PNG + b"\x00" * (2 * 1024 * 1024 + 10)
|
||||||
|
r = client.put(
|
||||||
|
"/api/users/me/avatar",
|
||||||
|
files={"file": ("big.png", big, "image/png")},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_profile(client: TestClient):
|
||||||
|
me = login(client, "Публичный")
|
||||||
|
client.patch("/api/users/me/profile", json={"bio": "Привет"}, headers=csrf_headers(client))
|
||||||
|
|
||||||
|
r = client.get(f"/api/users/{me['id']}/profile")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["nickname"] == "Публичный"
|
||||||
|
assert body["bio"] == "Привет"
|
||||||
|
assert body["avatar_url"] is None
|
||||||
|
assert "overall" in body["stats"]
|
||||||
|
|
||||||
|
assert client.get("/api/users/999999/profile").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path):
|
||||||
|
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||||
|
me = login(client, "Топчик")
|
||||||
|
_finished_match_for(client, me)
|
||||||
|
|
||||||
|
def entry_for(uid: int) -> dict:
|
||||||
|
board = client.get("/api/stats/leaderboard").json()
|
||||||
|
return next(e for e in board["entries"] + board["provisional"] if e["user_id"] == uid)
|
||||||
|
|
||||||
|
assert entry_for(me["id"])["avatar_url"] is None # без аватара — null
|
||||||
|
|
||||||
|
client.put(
|
||||||
|
"/api/users/me/avatar",
|
||||||
|
files={"file": ("a.png", PNG, "image/png")},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
|
||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- db-data-test:/data # изолированные тестовые данные
|
- db-data-test:/data # изолированные тестовые данные
|
||||||
- uploads-data-test:/data/uploads
|
- uploads-data-test:/data/uploads
|
||||||
|
- achievements-data-test:/data/achievements # определения ачивок (файлы)
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
@@ -58,3 +59,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
db-data-test:
|
db-data-test:
|
||||||
uploads-data-test:
|
uploads-data-test:
|
||||||
|
achievements-data-test:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- db-data:/data # БД SQLite + WAL-сайдкары
|
- db-data:/data # БД SQLite + WAL-сайдкары
|
||||||
- uploads-data:/data/uploads # задел под фото/видео
|
- uploads-data:/data/uploads # задел под фото/видео
|
||||||
|
- achievements-data:/data/achievements # определения ачивок (файлы)
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
@@ -56,3 +57,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
uploads-data:
|
uploads-data:
|
||||||
|
achievements-data:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const qk = {
|
|||||||
expansions: ["expansions"] as const,
|
expansions: ["expansions"] as const,
|
||||||
factions: ["factions"] as const,
|
factions: ["factions"] as const,
|
||||||
myStats: ["myStats"] as const,
|
myStats: ["myStats"] as const,
|
||||||
|
publicProfile: (id: number) => ["publicProfile", id] as const,
|
||||||
groups: ["groups"] as const,
|
groups: ["groups"] as const,
|
||||||
group: (id: number) => ["group", id] as const,
|
group: (id: number) => ["group", id] as const,
|
||||||
groupFactions: (id: number) => ["group", id, "factions"] as const,
|
groupFactions: (id: number) => ["group", id, "factions"] as const,
|
||||||
@@ -19,4 +20,5 @@ export const qk = {
|
|||||||
adminGroups: ["adminGroups"] as const,
|
adminGroups: ["adminGroups"] as const,
|
||||||
adminMatches: ["adminMatches"] as const,
|
adminMatches: ["adminMatches"] as const,
|
||||||
adminLogs: ["adminLogs"] as const,
|
adminLogs: ["adminLogs"] as const,
|
||||||
|
adminAchievements: ["adminAchievements"] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+724
-34
@@ -30,7 +30,15 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Telegram Login */
|
/**
|
||||||
|
* Telegram Login
|
||||||
|
* @description Вход/регистрация через Telegram.
|
||||||
|
*
|
||||||
|
* Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход —
|
||||||
|
* регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409
|
||||||
|
* TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register
|
||||||
|
* с выбранным ником.
|
||||||
|
*/
|
||||||
post: operations["telegram_login_api_auth_telegram_post"];
|
post: operations["telegram_login_api_auth_telegram_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -38,6 +46,30 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/auth/telegram/register": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Telegram Register
|
||||||
|
* @description Завершение регистрации через Telegram с выбранным ником.
|
||||||
|
*
|
||||||
|
* Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность
|
||||||
|
* доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел
|
||||||
|
* зарегистрироваться (гонка) — просто входим.
|
||||||
|
*/
|
||||||
|
post: operations["telegram_register_api_auth_telegram_register_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/auth/logout": {
|
"/api/auth/logout": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -73,6 +105,75 @@ export interface paths {
|
|||||||
patch: operations["update_me_api_users_me_patch"];
|
patch: operations["update_me_api_users_me_patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/users/me/profile": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Update My Profile */
|
||||||
|
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/users/me/avatar": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/** Upload My Avatar */
|
||||||
|
put: operations["upload_my_avatar_api_users_me_avatar_put"];
|
||||||
|
post?: never;
|
||||||
|
/** Delete My Avatar */
|
||||||
|
delete: operations["delete_my_avatar_api_users_me_avatar_delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/users/{user_id}/avatar": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get User Avatar */
|
||||||
|
get: operations["get_user_avatar_api_users__user_id__avatar_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/users/{user_id}/profile": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get User Profile */
|
||||||
|
get: operations["get_user_profile_api_users__user_id__profile_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/users/me/stats": {
|
"/api/users/me/stats": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -408,6 +509,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/achievements": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Achievements */
|
||||||
|
get: operations["list_achievements_api_achievements_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/achievements/{slug}/icon": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Icon */
|
||||||
|
get: operations["get_icon_api_achievements__slug__icon_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/auth/login": {
|
"/api/admin/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -486,8 +621,7 @@ export interface paths {
|
|||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
/** Delete User */
|
delete?: never;
|
||||||
delete: operations["delete_user_api_admin_users__user_id__delete"];
|
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
/** Update User */
|
/** Update User */
|
||||||
@@ -598,6 +732,59 @@ export interface paths {
|
|||||||
patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
|
patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/achievements": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Achievements */
|
||||||
|
get: operations["list_achievements_api_admin_achievements_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Create Achievement */
|
||||||
|
post: operations["create_achievement_api_admin_achievements_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/admin/achievements/{slug}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Achievement */
|
||||||
|
delete: operations["delete_achievement_api_admin_achievements__slug__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Update Achievement */
|
||||||
|
patch: operations["update_achievement_api_admin_achievements__slug__patch"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/admin/achievements/{slug}/icon": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/** Upload Achievement Icon */
|
||||||
|
put: operations["upload_achievement_icon_api_admin_achievements__slug__icon_put"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/audit-logs": {
|
"/api/admin/audit-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -650,6 +837,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/dev/users/{user_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete User Hard */
|
||||||
|
delete: operations["delete_user_hard_api_admin_dev_users__user_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/health": {
|
"/api/health": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -671,6 +875,48 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
/** AchievementCreate */
|
||||||
|
AchievementCreate: {
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
/** Description */
|
||||||
|
description?: string | null;
|
||||||
|
/** Condition */
|
||||||
|
condition?: string | null;
|
||||||
|
};
|
||||||
|
/** AchievementRead */
|
||||||
|
AchievementRead: {
|
||||||
|
/** Slug */
|
||||||
|
slug: string;
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Description
|
||||||
|
* @default
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* Condition
|
||||||
|
* @default
|
||||||
|
*/
|
||||||
|
condition: string;
|
||||||
|
/**
|
||||||
|
* Has Condition
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
has_condition: boolean;
|
||||||
|
/** Icon Url */
|
||||||
|
icon_url?: string | null;
|
||||||
|
};
|
||||||
|
/** AchievementUpdate */
|
||||||
|
AchievementUpdate: {
|
||||||
|
/** Name */
|
||||||
|
name?: string | null;
|
||||||
|
/** Description */
|
||||||
|
description?: string | null;
|
||||||
|
/** Condition */
|
||||||
|
condition?: string | null;
|
||||||
|
};
|
||||||
/** ActiveGroupUpdate */
|
/** ActiveGroupUpdate */
|
||||||
ActiveGroupUpdate: {
|
ActiveGroupUpdate: {
|
||||||
/** Group Id */
|
/** Group Id */
|
||||||
@@ -795,6 +1041,16 @@ export interface components {
|
|||||||
/** Telegram Bot Username */
|
/** Telegram Bot Username */
|
||||||
telegram_bot_username?: string | null;
|
telegram_bot_username?: string | null;
|
||||||
};
|
};
|
||||||
|
/** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */
|
||||||
|
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
|
||||||
|
/** File */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
/** Body_upload_my_avatar_api_users_me_avatar_put */
|
||||||
|
Body_upload_my_avatar_api_users_me_avatar_put: {
|
||||||
|
/** File */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
/** DevLogin */
|
/** DevLogin */
|
||||||
DevLogin: {
|
DevLogin: {
|
||||||
/** Nickname */
|
/** Nickname */
|
||||||
@@ -1049,6 +1305,8 @@ export interface components {
|
|||||||
nickname: string;
|
nickname: string;
|
||||||
/** Rank */
|
/** Rank */
|
||||||
rank?: number | null;
|
rank?: number | null;
|
||||||
|
/** Avatar Url */
|
||||||
|
avatar_url?: string | null;
|
||||||
};
|
};
|
||||||
/** MatchCreate */
|
/** MatchCreate */
|
||||||
MatchCreate: {
|
MatchCreate: {
|
||||||
@@ -1224,6 +1482,10 @@ export interface components {
|
|||||||
telegram_id?: number | null;
|
telegram_id?: number | null;
|
||||||
/** Active Group Id */
|
/** Active Group Id */
|
||||||
active_group_id?: number | null;
|
active_group_id?: number | null;
|
||||||
|
/** Bio */
|
||||||
|
bio?: string | null;
|
||||||
|
/** Avatar Url */
|
||||||
|
avatar_url?: string | null;
|
||||||
/**
|
/**
|
||||||
* Groups
|
* Groups
|
||||||
* @default []
|
* @default []
|
||||||
@@ -1315,6 +1577,23 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
recent_form: components["schemas"]["RecentFormItem"][];
|
recent_form: components["schemas"]["RecentFormItem"][];
|
||||||
};
|
};
|
||||||
|
/** ProfileUpdate */
|
||||||
|
ProfileUpdate: {
|
||||||
|
/** Bio */
|
||||||
|
bio?: string | null;
|
||||||
|
};
|
||||||
|
/** PublicProfile */
|
||||||
|
PublicProfile: {
|
||||||
|
/** User Id */
|
||||||
|
user_id: number;
|
||||||
|
/** Nickname */
|
||||||
|
nickname: string;
|
||||||
|
/** Bio */
|
||||||
|
bio?: string | null;
|
||||||
|
/** Avatar Url */
|
||||||
|
avatar_url?: string | null;
|
||||||
|
stats: components["schemas"]["ProfileStats"];
|
||||||
|
};
|
||||||
/** RandomizeRequest */
|
/** RandomizeRequest */
|
||||||
RandomizeRequest: {
|
RandomizeRequest: {
|
||||||
/** Group Id */
|
/** Group Id */
|
||||||
@@ -1369,6 +1648,27 @@ export interface components {
|
|||||||
} & {
|
} & {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
/** TelegramRegister */
|
||||||
|
TelegramRegister: {
|
||||||
|
/** Id */
|
||||||
|
id: number;
|
||||||
|
/** Auth Date */
|
||||||
|
auth_date: number;
|
||||||
|
/** Hash */
|
||||||
|
hash: string;
|
||||||
|
/** First Name */
|
||||||
|
first_name?: string | null;
|
||||||
|
/** Last Name */
|
||||||
|
last_name?: string | null;
|
||||||
|
/** Username */
|
||||||
|
username?: string | null;
|
||||||
|
/** Photo Url */
|
||||||
|
photo_url?: string | null;
|
||||||
|
/** Nickname */
|
||||||
|
nickname: string;
|
||||||
|
} & {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
/** UserRead */
|
/** UserRead */
|
||||||
UserRead: {
|
UserRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1383,6 +1683,10 @@ export interface components {
|
|||||||
telegram_id?: number | null;
|
telegram_id?: number | null;
|
||||||
/** Active Group Id */
|
/** Active Group Id */
|
||||||
active_group_id?: number | null;
|
active_group_id?: number | null;
|
||||||
|
/** Bio */
|
||||||
|
bio?: string | null;
|
||||||
|
/** Avatar Url */
|
||||||
|
avatar_url?: string | null;
|
||||||
};
|
};
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
@@ -1459,6 +1763,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
telegram_register_api_auth_telegram_register_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TelegramRegister"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
logout_api_auth_logout_post: {
|
logout_api_auth_logout_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1532,6 +1869,154 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
update_my_profile_api_users_me_profile_patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProfileUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
upload_my_avatar_api_users_me_avatar_put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["Body_upload_my_avatar_api_users_me_avatar_put"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_my_avatar_api_users_me_avatar_delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_user_avatar_api_users__user_id__avatar_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_user_profile_api_users__user_id__profile_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PublicProfile"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
my_stats_api_users_me_stats_get: {
|
my_stats_api_users_me_stats_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2289,6 +2774,57 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_achievements_api_achievements_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AchievementRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_icon_api_achievements__slug__icon_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
admin_login_api_admin_auth_login_post: {
|
admin_login_api_admin_auth_login_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2393,37 +2929,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
delete_user_api_admin_users__user_id__delete: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
user_id: number;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["OkResponse"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation Error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HTTPValidationError"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
update_user_api_admin_users__user_id__patch: {
|
update_user_api_admin_users__user_id__patch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2697,6 +3202,160 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_achievements_api_admin_achievements_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AchievementRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_achievement_api_admin_achievements_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AchievementCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AchievementRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_achievement_api_admin_achievements__slug__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["OkResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update_achievement_api_admin_achievements__slug__patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AchievementUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AchievementRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
upload_achievement_icon_api_admin_achievements__slug__icon_put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["Body_upload_achievement_icon_api_admin_achievements__slug__icon_put"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AchievementRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
audit_logs_api_admin_audit_logs_get: {
|
audit_logs_api_admin_audit_logs_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -2817,6 +3476,37 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
delete_user_hard_api_admin_dev_users__user_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["OkResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
health_api_health_get: {
|
health_api_health_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { GroupSettingsPage } from "../pages/GroupSettingsPage";
|
|||||||
import { HomePage } from "../pages/HomePage";
|
import { HomePage } from "../pages/HomePage";
|
||||||
import { LoginPage } from "../pages/LoginPage";
|
import { LoginPage } from "../pages/LoginPage";
|
||||||
import { MatchDetailPage } from "../pages/MatchDetailPage";
|
import { MatchDetailPage } from "../pages/MatchDetailPage";
|
||||||
|
import { OverallStatsPage } from "../pages/OverallStatsPage";
|
||||||
|
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
||||||
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
||||||
|
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
|
||||||
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
||||||
import { AdminLayout } from "../pages/admin/AdminLayout";
|
import { AdminLayout } from "../pages/admin/AdminLayout";
|
||||||
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
||||||
@@ -37,6 +40,8 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ path: "match/:matchId", element: <MatchDetailPage /> },
|
{ path: "match/:matchId", element: <MatchDetailPage /> },
|
||||||
|
{ path: "stats", element: <OverallStatsPage /> },
|
||||||
|
{ path: "u/:userId", element: <PublicProfilePage /> },
|
||||||
{ path: "group", element: <GroupPage /> },
|
{ path: "group", element: <GroupPage /> },
|
||||||
{
|
{
|
||||||
path: "group/settings",
|
path: "group/settings",
|
||||||
@@ -61,6 +66,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "records", element: <AdminRecordsPage /> },
|
{ path: "records", element: <AdminRecordsPage /> },
|
||||||
{ path: "accounts", element: <AdminAccountsPage /> },
|
{ path: "accounts", element: <AdminAccountsPage /> },
|
||||||
{ path: "factions", element: <AdminFactionsPage /> },
|
{ path: "factions", element: <AdminFactionsPage /> },
|
||||||
|
{ path: "achievements", element: <AdminAchievementsPage /> },
|
||||||
{ path: "logs", element: <AdminLogsPage /> },
|
{ path: "logs", element: <AdminLogsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ const TITLES: Record<string, string> = {
|
|||||||
"/group/settings": "Настройки группы",
|
"/group/settings": "Настройки группы",
|
||||||
"/account": "Аккаунт",
|
"/account": "Аккаунт",
|
||||||
"/match/new": "Новая партия",
|
"/match/new": "Новая партия",
|
||||||
|
"/stats": "Общая статистика",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const title = TITLES[location.pathname] ?? "Forbidden Stars";
|
const title =
|
||||||
|
TITLES[location.pathname] ??
|
||||||
|
(location.pathname.startsWith("/u/") ? "Профиль игрока" : "Forbidden Stars");
|
||||||
const isHome = location.pathname === "/";
|
const isHome = location.pathname === "/";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чисто презентационный аватар: картинка по url или инициал ника как заглушка.
|
||||||
|
* Используется и в своём, и в чужом профиле, и в списках (общий примитив без логики).
|
||||||
|
*/
|
||||||
|
export function Avatar({
|
||||||
|
url,
|
||||||
|
nickname,
|
||||||
|
size = 40,
|
||||||
|
}: {
|
||||||
|
url?: string | null;
|
||||||
|
nickname: string;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const box: CSSProperties = {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={nickname}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
style={{ ...box, objectFit: "cover", border: "1px solid var(--border)" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const initial = (nickname.trim()[0] ?? "?").toUpperCase();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
...box,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: size * 0.42,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import type { LeaderboardEntry } from "../domain/types";
|
import type { LeaderboardEntry } from "../domain/types";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
|
||||||
function pct(v: number | null | undefined): string {
|
function pct(v: number | null | undefined): string {
|
||||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||||
@@ -10,8 +13,16 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
|
|||||||
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
|
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
|
||||||
{entry.rank ?? "—"}
|
{entry.rank ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
|
<Avatar url={entry.avatar_url} nickname={entry.nickname} size={32} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600 }}>{entry.nickname}</div>
|
<Link
|
||||||
|
to={`/u/${entry.user_id}`}
|
||||||
|
// stopPropagation: ник кликабелен даже когда вся карточка-обёртка кликабельна.
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ fontWeight: 600, color: "inherit" }}
|
||||||
|
>
|
||||||
|
{entry.nickname}
|
||||||
|
</Link>
|
||||||
<div className="lb-metrics">
|
<div className="lb-metrics">
|
||||||
<span>Игр: {entry.games}</span>
|
<span>Игр: {entry.games}</span>
|
||||||
<span>Побед: {entry.wins}</span>
|
<span>Побед: {entry.wins}</span>
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ function pct(v: number | null | undefined): string {
|
|||||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileStatsCard({ stats }: { stats: ProfileStats }) {
|
export function ProfileStatsCard({
|
||||||
|
stats,
|
||||||
|
title = "Моя статистика",
|
||||||
|
}: {
|
||||||
|
stats: ProfileStats;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
const o = stats.overall;
|
const o = stats.overall;
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Моя статистика</h3>
|
<h3>{title}</h3>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>Игр</div>
|
<div>Игр</div>
|
||||||
<b>{o.games}</b>
|
<b>{o.games}</b>
|
||||||
|
|||||||
@@ -28,9 +28,18 @@ export function TelegramLoginButton({
|
|||||||
onAuth: (user: TelegramUser) => void;
|
onAuth: (user: TelegramUser) => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
// onAuth держим в ref, чтобы он НЕ был в зависимостях эффекта: иначе новая инлайн-
|
||||||
|
// функция на каждый рендер пересоздавала бы виджет (а cleanup сносит его innerHTML
|
||||||
|
// в момент отрисовки — в dev со StrictMode кнопка из-за этого не появлялась).
|
||||||
|
const onAuthRef = useRef(onAuth);
|
||||||
|
onAuthRef.current = onAuth;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.__fsTelegramAuth = (user) => onAuth(user);
|
window.__fsTelegramAuth = (user) => onAuthRef.current(user);
|
||||||
|
const node = ref.current;
|
||||||
|
if (!node) return;
|
||||||
|
node.innerHTML = ""; // на случай повторного монтирования (StrictMode)
|
||||||
|
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||||||
script.async = true;
|
script.async = true;
|
||||||
@@ -39,14 +48,12 @@ export function TelegramLoginButton({
|
|||||||
script.setAttribute("data-userpic", "false");
|
script.setAttribute("data-userpic", "false");
|
||||||
script.setAttribute("data-request-access", "write");
|
script.setAttribute("data-request-access", "write");
|
||||||
script.setAttribute("data-onauth", "__fsTelegramAuth(user)");
|
script.setAttribute("data-onauth", "__fsTelegramAuth(user)");
|
||||||
|
node.appendChild(script);
|
||||||
|
|
||||||
const node = ref.current;
|
|
||||||
node?.appendChild(script);
|
|
||||||
return () => {
|
return () => {
|
||||||
if (node) node.innerHTML = "";
|
node.innerHTML = "";
|
||||||
delete window.__fsTelegramAuth;
|
|
||||||
};
|
};
|
||||||
}, [botUsername, onAuth]);
|
}, [botUsername]); // только смена бота пересоздаёт виджет
|
||||||
|
|
||||||
return <div ref={ref} />;
|
return <div ref={ref} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type MatchListItem = S["MatchListItem"];
|
|||||||
export type Leaderboard = S["Leaderboard"];
|
export type Leaderboard = S["Leaderboard"];
|
||||||
export type LeaderboardEntry = S["LeaderboardEntry"];
|
export type LeaderboardEntry = S["LeaderboardEntry"];
|
||||||
export type ProfileStats = S["ProfileStats"];
|
export type ProfileStats = S["ProfileStats"];
|
||||||
|
export type PublicProfile = S["PublicProfile"];
|
||||||
export type GroupStats = S["GroupStats"];
|
export type GroupStats = S["GroupStats"];
|
||||||
export type HomeResponse = S["HomeResponse"];
|
export type HomeResponse = S["HomeResponse"];
|
||||||
export type HomeInProgressMatch = S["HomeInProgressMatch"];
|
export type HomeInProgressMatch = S["HomeInProgressMatch"];
|
||||||
@@ -25,6 +26,7 @@ export type AdminUserRead = S["AdminUserRead"];
|
|||||||
export type AdminGroupRead = S["AdminGroupRead"];
|
export type AdminGroupRead = S["AdminGroupRead"];
|
||||||
export type AdminMatchRead = S["AdminMatchRead"];
|
export type AdminMatchRead = S["AdminMatchRead"];
|
||||||
export type AuditLogList = S["AuditLogList"];
|
export type AuditLogList = S["AuditLogList"];
|
||||||
|
export type AchievementRead = S["AchievementRead"];
|
||||||
export type ParticipantInput = S["ParticipantInput"];
|
export type ParticipantInput = S["ParticipantInput"];
|
||||||
export type MatchUpdate = S["MatchUpdate"];
|
export type MatchUpdate = S["MatchUpdate"];
|
||||||
export type MatchCreate = S["MatchCreate"];
|
export type MatchCreate = S["MatchCreate"];
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api, unwrap } from "../api/client";
|
import { ApiError, api, unwrap } from "../api/client";
|
||||||
import { qk } from "../api/queryKeys";
|
import { qk } from "../api/queryKeys";
|
||||||
import type { AdminMe, MatchUpdate } from "../domain/types";
|
import type { AdminMe, MatchUpdate } from "../domain/types";
|
||||||
|
|
||||||
|
function readCsrfToken(): string | null {
|
||||||
|
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAdminMe() {
|
export function useAdminMe() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.adminMe,
|
queryKey: qk.adminMe,
|
||||||
@@ -163,3 +168,84 @@ export function useAdminUpdateUser() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
|
||||||
|
|
||||||
|
interface AchievementInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
condition?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminAchievements() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.adminAchievements,
|
||||||
|
queryFn: async () => unwrap(await api.GET("/api/admin/achievements")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAchievement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AchievementInput & { name: string }) =>
|
||||||
|
unwrap(await api.POST("/api/admin/achievements", { body })),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAchievement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (args: { slug: string; body: AchievementInput }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH("/api/admin/achievements/{slug}", {
|
||||||
|
params: { path: { slug: args.slug } },
|
||||||
|
body: args.body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAchievement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (slug: string) =>
|
||||||
|
unwrap(
|
||||||
|
await api.DELETE("/api/admin/achievements/{slug}", {
|
||||||
|
params: { path: { slug } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иконка ачивки — multipart отдельным fetch (как аватар), с CSRF из cookie.
|
||||||
|
export function useUploadAchievementIcon() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ slug, file }: { slug: string; file: File }) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file, file.name);
|
||||||
|
const csrf = readCsrfToken();
|
||||||
|
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
|
const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: form,
|
||||||
|
credentials: "include",
|
||||||
|
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
let env: { code?: string; message?: string } | undefined;
|
||||||
|
try {
|
||||||
|
env = ((await r.json()) as { error?: typeof env }).error;
|
||||||
|
} catch {
|
||||||
|
/* тело без JSON */
|
||||||
|
}
|
||||||
|
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
|
||||||
|
}
|
||||||
|
return await r.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+116
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api, unwrap } from "../api/client";
|
import { ApiError, api, unwrap } from "../api/client";
|
||||||
import { qk } from "../api/queryKeys";
|
import { qk } from "../api/queryKeys";
|
||||||
import type { AuthConfig, Me } from "../domain/types";
|
import type { AuthConfig, Me } from "../domain/types";
|
||||||
|
|
||||||
@@ -44,6 +44,8 @@ export function useStubLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
||||||
|
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
||||||
|
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
||||||
export function useTelegramLogin() {
|
export function useTelegramLogin() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -56,6 +58,20 @@ export function useTelegramLogin() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Регистрация через Telegram с выбранным ником (когда тег занят/некорректен).
|
||||||
|
// payload — те же данные виджета + nickname; подпись проверяется заново на сервере.
|
||||||
|
export function useTelegramRegister() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: Record<string, unknown>) =>
|
||||||
|
unwrap(await api.POST("/api/auth/telegram/register", { body: payload as never })),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
qc.invalidateQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateDevUser() {
|
export function useCreateDevUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -88,6 +104,105 @@ export function useUpdateNickname() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «О себе».
|
||||||
|
export function useUpdateBio() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (bio: string): Promise<Me> =>
|
||||||
|
unwrap(await api.PATCH("/api/users/me/profile", { body: { bio } })),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
qc.invalidateQueries({ queryKey: qk.home });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
|
||||||
|
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
|
||||||
|
function readCsrfToken(): string | null {
|
||||||
|
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resizeImage(file: File, max = 512): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const dataUrl = await new Promise<string>((res, rej) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => res(fr.result as string);
|
||||||
|
fr.onerror = () => rej(fr.error);
|
||||||
|
fr.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
const img = await new Promise<HTMLImageElement>((res, rej) => {
|
||||||
|
const i = new Image();
|
||||||
|
i.onload = () => res(i);
|
||||||
|
i.onerror = () => rej(new Error("image load failed"));
|
||||||
|
i.src = dataUrl;
|
||||||
|
});
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width > max || height > max) {
|
||||||
|
const scale = Math.min(max / width, max / height);
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return file;
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
const blob = await new Promise<Blob | null>((res) =>
|
||||||
|
canvas.toBlob(res, "image/jpeg", 0.85),
|
||||||
|
);
|
||||||
|
return blob ?? file;
|
||||||
|
} catch {
|
||||||
|
return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadAvatar() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (file: File): Promise<Me> => {
|
||||||
|
const blob = await resizeImage(file);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", blob, "avatar.jpg");
|
||||||
|
const csrf = readCsrfToken();
|
||||||
|
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
|
const r = await fetch(`${base}/api/users/me/avatar`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: form,
|
||||||
|
credentials: "include",
|
||||||
|
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
let env: { code?: string; message?: string; details?: unknown } | undefined;
|
||||||
|
try {
|
||||||
|
env = ((await r.json()) as { error?: typeof env }).error;
|
||||||
|
} catch {
|
||||||
|
/* тело без JSON */
|
||||||
|
}
|
||||||
|
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details);
|
||||||
|
}
|
||||||
|
return (await r.json()) as Me;
|
||||||
|
},
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
qc.invalidateQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAvatar() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (): Promise<Me> => unwrap(await api.DELETE("/api/users/me/avatar")),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
qc.invalidateQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSetActiveGroup() {
|
export function useSetActiveGroup() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -24,3 +24,16 @@ export function useMyStats() {
|
|||||||
queryFn: async () => unwrap(await api.GET("/api/users/me/stats")),
|
queryFn: async () => unwrap(await api.GET("/api/users/me/stats")),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Публичный профиль другого игрока (read-only).
|
||||||
|
export function usePublicProfile(userId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.publicProfile(userId),
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await api.GET("/api/users/{user_id}/profile", {
|
||||||
|
params: { path: { user_id: userId } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { Avatar } from "../components/Avatar";
|
||||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useLogout, useMe, useUpdateNickname } from "../hooks/auth";
|
import {
|
||||||
|
useDeleteAvatar,
|
||||||
|
useLogout,
|
||||||
|
useMe,
|
||||||
|
useUpdateBio,
|
||||||
|
useUpdateNickname,
|
||||||
|
useUploadAvatar,
|
||||||
|
} from "../hooks/auth";
|
||||||
import { useMyStats } from "../hooks/stats";
|
import { useMyStats } from "../hooks/stats";
|
||||||
|
|
||||||
export function AccountPage() {
|
export function AccountPage() {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading } = useMe();
|
||||||
const { data: stats } = useMyStats();
|
const { data: stats } = useMyStats();
|
||||||
const updateNick = useUpdateNickname();
|
const updateNick = useUpdateNickname();
|
||||||
|
const updateBio = useUpdateBio();
|
||||||
|
const uploadAvatar = useUploadAvatar();
|
||||||
|
const deleteAvatar = useDeleteAvatar();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [nickname, setNickname] = useState("");
|
const [nickname, setNickname] = useState("");
|
||||||
|
const [bio, setBio] = useState<string | null>(null);
|
||||||
|
|
||||||
if (isLoading || !me) return <Spinner />;
|
if (isLoading || !me) return <Spinner />;
|
||||||
|
|
||||||
const save = async () => {
|
// bio редактируется локально; до первого ввода берём значение из профиля.
|
||||||
|
const bioValue = bio ?? me.bio ?? "";
|
||||||
|
|
||||||
|
const saveNick = async () => {
|
||||||
const nick = nickname.trim();
|
const nick = nickname.trim();
|
||||||
if (!nick) return;
|
if (!nick) return;
|
||||||
try {
|
try {
|
||||||
@@ -32,6 +48,36 @@ export function AccountPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveBio = async () => {
|
||||||
|
try {
|
||||||
|
await updateBio.mutateAsync(bioValue.trim());
|
||||||
|
toast.show("Сохранено");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = ""; // позволяем выбрать тот же файл повторно
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
await uploadAvatar.mutateAsync(file);
|
||||||
|
toast.show("Аватар обновлён");
|
||||||
|
} catch (err) {
|
||||||
|
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAvatar = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAvatar.mutateAsync();
|
||||||
|
toast.show("Аватар удалён");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const doLogout = async () => {
|
const doLogout = async () => {
|
||||||
await logout.mutateAsync().catch(() => {});
|
await logout.mutateAsync().catch(() => {});
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
@@ -41,15 +87,64 @@ export function AccountPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Профиль</h3>
|
<h3>Профиль</h3>
|
||||||
<div className="row-between">
|
<div className="row" style={{ gap: 14 }}>
|
||||||
<span className="muted">Никнейм</span>
|
<Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
|
||||||
<b>{me.nickname}</b>
|
<div style={{ flex: 1 }}>
|
||||||
|
<b style={{ fontSize: 18 }}>{me.nickname}</b>
|
||||||
|
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn small"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploadAvatar.isPending}
|
||||||
|
>
|
||||||
|
{me.avatar_url ? "Сменить аватар" : "Загрузить аватар"}
|
||||||
|
</button>
|
||||||
|
{me.avatar_url && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-danger small"
|
||||||
|
onClick={removeAvatar}
|
||||||
|
disabled={deleteAvatar.isPending}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={onPickFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="field" style={{ marginTop: 14 }}>
|
||||||
|
<label className="label">О себе</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="Пара слов о себе…"
|
||||||
|
value={bioValue}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="row-between" style={{ marginTop: 6 }}>
|
||||||
|
<span className="muted small">{bioValue.length}/500</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary small"
|
||||||
|
onClick={saveBio}
|
||||||
|
disabled={updateBio.isPending || bioValue.trim() === (me.bio ?? "")}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="field" style={{ marginTop: 10 }}>
|
<div className="field" style={{ marginTop: 10 }}>
|
||||||
<label className="label">Сменить никнейм</label>
|
<label className="label">Сменить никнейм</label>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
|
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
|
||||||
<button className="btn btn-primary" onClick={save} disabled={updateNick.isPending}>
|
<button className="btn btn-primary" onClick={saveNick} disabled={updateNick.isPending}>
|
||||||
ОК
|
ОК
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,10 +160,7 @@ export function AccountPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="center" style={{ marginTop: 24 }}>
|
<p className="center" style={{ marginTop: 24 }}>
|
||||||
<button
|
<button className="btn btn-ghost small muted" onClick={() => navigate("/admin/login")}>
|
||||||
className="btn btn-ghost small muted"
|
|
||||||
onClick={() => navigate("/admin/login")}
|
|
||||||
>
|
|
||||||
Вход для администратора
|
Вход для администратора
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { InProgressMatches } from "../components/InProgressMatches";
|
import { InProgressMatches } from "../components/InProgressMatches";
|
||||||
import { Leaderboard } from "../components/Leaderboard";
|
import { Leaderboard } from "../components/Leaderboard";
|
||||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||||
@@ -6,15 +8,23 @@ import { useHome } from "../hooks/stats";
|
|||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { data, isLoading, isError } = useHome();
|
const { data, isLoading, isError } = useHome();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
if (isError || !data) return <div className="muted">Не удалось загрузить данные.</div>;
|
if (isError || !data) return <div className="muted">Не удалось загрузить данные.</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card">
|
{/* Карточка кликабельна целиком → общая статистика; клик по нику внутри
|
||||||
<h3>Топ игроков</h3>
|
ведёт в профиль (Leaderboard останавливает всплытие на ссылках ников). */}
|
||||||
<Leaderboard entries={data.leaderboard} provisional={data.provisional} />
|
<div className="card" style={{ cursor: "pointer" }} onClick={() => navigate("/stats")}>
|
||||||
|
<div className="row-between">
|
||||||
|
<h3 style={{ margin: 0 }}>Топ игроков</h3>
|
||||||
|
<span className="muted small">Вся статистика →</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Leaderboard entries={data.leaderboard} provisional={data.provisional} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProfileStatsCard stats={data.profile} />
|
<ProfileStatsCard stats={data.profile} />
|
||||||
|
|||||||
@@ -11,13 +11,21 @@ import {
|
|||||||
useMe,
|
useMe,
|
||||||
useStubLogin,
|
useStubLogin,
|
||||||
useTelegramLogin,
|
useTelegramLogin,
|
||||||
|
useTelegramRegister,
|
||||||
} from "../hooks/auth";
|
} from "../hooks/auth";
|
||||||
|
|
||||||
|
// Данные виджета Telegram, отложенные до выбора ника (когда тег занят).
|
||||||
|
interface PendingTelegram {
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
suggested: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading } = useMe();
|
||||||
const { data: config } = useAuthConfig();
|
const { data: config } = useAuthConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
||||||
|
|
||||||
const methods = config?.methods ?? [];
|
const methods = config?.methods ?? [];
|
||||||
const hasTelegram = methods.includes("telegram");
|
const hasTelegram = methods.includes("telegram");
|
||||||
@@ -36,6 +44,12 @@ export function LoginPage() {
|
|||||||
await telegram.mutateAsync(user);
|
await telegram.mutateAsync(user);
|
||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Тег занят/некорректен → просим выбрать ник и регистрируем тем же payload.
|
||||||
|
if (e instanceof ApiError && e.code === "TELEGRAM_NICKNAME_REQUIRED") {
|
||||||
|
const suggested = (e.details as { suggested?: string } | undefined)?.suggested ?? "";
|
||||||
|
setPendingTg({ payload: user, suggested });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram");
|
setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -48,24 +62,35 @@ export function LoginPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="muted center">Учёт партий</p>
|
<p className="muted center">Учёт партий</p>
|
||||||
|
|
||||||
{hasTelegram && (
|
{pendingTg ? (
|
||||||
<div className="card">
|
<TelegramNicknamePicker
|
||||||
<h3>Вход через Telegram</h3>
|
pending={pendingTg}
|
||||||
{config?.telegram_bot_username ? (
|
onCancel={() => {
|
||||||
<TelegramLoginButton
|
setPendingTg(null);
|
||||||
botUsername={config.telegram_bot_username}
|
setError(null);
|
||||||
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
}}
|
||||||
/>
|
onDone={() => navigate("/", { replace: true })}
|
||||||
) : (
|
/>
|
||||||
<p className="muted small">
|
) : (
|
||||||
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
|
hasTelegram && (
|
||||||
в .env (бот от @BotFather + /setdomain на ваш домен).
|
<div className="card">
|
||||||
</p>
|
<h3>Вход через Telegram</h3>
|
||||||
)}
|
{config?.telegram_bot_username ? (
|
||||||
</div>
|
<TelegramLoginButton
|
||||||
|
botUsername={config.telegram_bot_username}
|
||||||
|
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="muted small">
|
||||||
|
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
|
||||||
|
в .env (бот от @BotFather + /setdomain на ваш домен).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStub && <DevLogin onError={setError} />}
|
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
||||||
|
|
||||||
{error && <p className="error-text center">{error}</p>}
|
{error && <p className="error-text center">{error}</p>}
|
||||||
|
|
||||||
@@ -83,6 +108,68 @@ export function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
|
||||||
|
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
||||||
|
function TelegramNicknamePicker({
|
||||||
|
pending,
|
||||||
|
onCancel,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
pending: PendingTelegram;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const register = useTelegramRegister();
|
||||||
|
const [nickname, setNickname] = useState(pending.suggested);
|
||||||
|
const [error, setError] = useState<string | null>(
|
||||||
|
pending.suggested ? `Ник «${pending.suggested}» уже занят — выберите другой.` : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const nick = nickname.trim();
|
||||||
|
if (!nick) {
|
||||||
|
setError("Введите никнейм.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await register.mutateAsync({ ...pending.payload, nickname: nick });
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? e.message : "Не удалось зарегистрироваться");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Выберите никнейм</h3>
|
||||||
|
<p className="muted small">
|
||||||
|
Ваш ник в Telegram уже занят другим игроком. Укажите другой никнейм для регистрации.
|
||||||
|
</p>
|
||||||
|
<div className="field">
|
||||||
|
<div className="row">
|
||||||
|
<input
|
||||||
|
placeholder="Никнейм"
|
||||||
|
value={nickname}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={submit} disabled={register.isPending}>
|
||||||
|
Зарегистрироваться
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<button className="btn btn-ghost small" onClick={onCancel}>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке
|
// DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке
|
||||||
// LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом.
|
// LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом.
|
||||||
function DevLogin({ onError }: { onError: (msg: string | null) => void }) {
|
function DevLogin({ onError }: { onError: (msg: string | null) => void }) {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { Spinner } from "../components/Spinner";
|
||||||
|
import type { LeaderboardEntry } from "../domain/types";
|
||||||
|
import { useLeaderboard } from "../hooks/stats";
|
||||||
|
|
||||||
|
type SortKey = "rank" | "games" | "wins" | "win_rate" | "score";
|
||||||
|
|
||||||
|
function pct(v: number | null | undefined): string {
|
||||||
|
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverallStatsPage() {
|
||||||
|
const { data, isLoading, isError } = useLeaderboard(200);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("rank");
|
||||||
|
const [dir, setDir] = useState<1 | -1>(1); // 1 — по возрастанию
|
||||||
|
|
||||||
|
const toggleSort = (k: SortKey) => {
|
||||||
|
if (k === sortKey) setDir((d) => (d === 1 ? -1 : 1));
|
||||||
|
else {
|
||||||
|
setSortKey(k);
|
||||||
|
setDir(k === "rank" ? 1 : -1); // для метрик по умолчанию «лучшие сверху»
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterSort = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
const num = (e: LeaderboardEntry) => Number(e[sortKey] ?? 0);
|
||||||
|
return (list: LeaderboardEntry[]) =>
|
||||||
|
list
|
||||||
|
.filter((e) => e.nickname.toLowerCase().includes(needle))
|
||||||
|
.sort((a, b) => (num(a) - num(b)) * dir);
|
||||||
|
}, [q, sortKey, dir]);
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError || !data) return <div className="muted">Не удалось загрузить статистику.</div>;
|
||||||
|
|
||||||
|
const entries = filterSort(data.entries);
|
||||||
|
const provisional = filterSort(data.provisional);
|
||||||
|
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
|
||||||
|
|
||||||
|
const row = (e: LeaderboardEntry) => (
|
||||||
|
<Link key={e.user_id} to={`/u/${e.user_id}`} className="st-row" style={{ color: "inherit" }}>
|
||||||
|
<span className={"st-num" + (e.rank && e.rank <= 3 ? " gold" : "")}>{e.rank ?? "—"}</span>
|
||||||
|
<span className="st-player">
|
||||||
|
<Avatar url={e.avatar_url} nickname={e.nickname} size={28} />
|
||||||
|
<span className="nick">{e.nickname}</span>
|
||||||
|
</span>
|
||||||
|
<span className="st-num">{e.games}</span>
|
||||||
|
<span className="st-num">{e.wins}</span>
|
||||||
|
<span className="st-num">{pct(e.win_rate)}</span>
|
||||||
|
<span className="st-num lb-score">{e.score ?? "—"}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Общая статистика</h3>
|
||||||
|
<input placeholder="Поиск по нику…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="st-head">
|
||||||
|
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
|
||||||
|
<span>Игрок</span>
|
||||||
|
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
|
||||||
|
<button onClick={() => toggleSort("wins")}>Поб{arrow("wins")}</button>
|
||||||
|
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
|
||||||
|
<button onClick={() => toggleSort("score")}>Очки{arrow("score")}</button>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="muted small" style={{ paddingTop: 8 }}>
|
||||||
|
Никого не найдено.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
entries.map(row)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provisional.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="label">Новички (меньше {data.min_games} игр)</div>
|
||||||
|
{provisional.map(row)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||||
|
import { Spinner } from "../components/Spinner";
|
||||||
|
import { usePublicProfile } from "../hooks/stats";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
|
||||||
|
* редактируемого AccountPage: здесь нет никаких контролов правки, только просмотр).
|
||||||
|
*/
|
||||||
|
export function PublicProfilePage() {
|
||||||
|
const { userId } = useParams();
|
||||||
|
const id = Number(userId);
|
||||||
|
const { data, isLoading, isError } = usePublicProfile(id);
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError || !data) return <div className="muted">Профиль не найден.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="row" style={{ gap: 14 }}>
|
||||||
|
<Avatar url={data.avatar_url} nickname={data.nickname} size={64} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<b style={{ fontSize: 18 }}>{data.nickname}</b>
|
||||||
|
{data.bio ? (
|
||||||
|
<div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>
|
||||||
|
{data.bio}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="muted small" style={{ marginTop: 6 }}>
|
||||||
|
Игрок пока не рассказал о себе.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProfileStatsCard stats={data.stats} title="Статистика" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,41 +1,101 @@
|
|||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||||
|
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
||||||
|
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
||||||
|
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||||
|
|
||||||
export function AdminAccountsPage() {
|
export function AdminAccountsPage() {
|
||||||
const { data: users, isLoading } = useAdminUsers();
|
const { data: users, isLoading } = useAdminUsers();
|
||||||
const update = useAdminUpdateUser();
|
const update = useAdminUpdateUser();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
|
const startEdit = (id: number, nickname: string) => {
|
||||||
|
setEditId(id);
|
||||||
|
setEditName(nickname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveName = async () => {
|
||||||
|
const name = editName.trim();
|
||||||
|
if (editId == null || !name) return;
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ userId: editId, nickname: name });
|
||||||
|
toast.show("Имя обновлено");
|
||||||
|
setEditId(null);
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleActive = async (id: number, isActive: boolean) => {
|
||||||
|
await update.mutateAsync({ userId: id, is_active: !isActive }).catch(() => {});
|
||||||
|
toast.show("Сохранено");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3>Аккаунты</h3>
|
<h3>Аккаунты</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{(users ?? []).map((u) => (
|
{(users ?? []).map((u) => (
|
||||||
<div key={u.id} className="card" style={{ margin: 0 }}>
|
<div key={u.id} className="card" style={{ margin: 0 }}>
|
||||||
<div className="row-between">
|
{editId === u.id ? (
|
||||||
<div>
|
// Режим переименования.
|
||||||
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">admin</span>}
|
<div className="row">
|
||||||
<div className="muted small">
|
<input
|
||||||
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
value={editName}
|
||||||
</div>
|
autoFocus
|
||||||
</div>
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
{u.role !== "admin" && (
|
onKeyDown={(e) => {
|
||||||
<button
|
if (e.key === "Enter") saveName();
|
||||||
className="btn small"
|
if (e.key === "Escape") setEditId(null);
|
||||||
onClick={async () => {
|
|
||||||
await update
|
|
||||||
.mutateAsync({ userId: u.id, is_active: !u.is_active })
|
|
||||||
.catch(() => {});
|
|
||||||
toast.show("Сохранено");
|
|
||||||
}}
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary small"
|
||||||
|
disabled={update.isPending || !editName.trim() || editName.trim() === u.nickname}
|
||||||
|
onClick={saveName}
|
||||||
>
|
>
|
||||||
{u.is_active ? "Отключить" : "Включить"}
|
ОК
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button className="btn btn-ghost small" onClick={() => setEditId(null)}>
|
||||||
</div>
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row-between">
|
||||||
|
<div>
|
||||||
|
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">admin</span>}
|
||||||
|
<div className="muted small">
|
||||||
|
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{u.role !== "admin" && (
|
||||||
|
<div className="row" style={{ gap: 4 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost small"
|
||||||
|
title="Переименовать"
|
||||||
|
onClick={() => startEdit(u.id, u.nickname)}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
||||||
|
)}
|
||||||
|
<button className="btn small" onClick={() => toggleActive(u.id, u.is_active)}>
|
||||||
|
{u.is_active ? "Отключить" : "Включить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import { Avatar } from "../../components/Avatar";
|
||||||
|
import { Spinner } from "../../components/Spinner";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
import {
|
||||||
|
useAdminAchievements,
|
||||||
|
useCreateAchievement,
|
||||||
|
useDeleteAchievement,
|
||||||
|
useUpdateAchievement,
|
||||||
|
useUploadAchievementIcon,
|
||||||
|
} from "../../hooks/admin";
|
||||||
|
import type { AchievementRead } from "../../domain/types";
|
||||||
|
|
||||||
|
export function AdminAchievementsPage() {
|
||||||
|
const { data: items, isLoading } = useAdminAchievements();
|
||||||
|
const create = useCreateAchievement();
|
||||||
|
const toast = useToast();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [condition, setCondition] = useState("");
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
try {
|
||||||
|
await create.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
condition: condition.trim() || undefined,
|
||||||
|
});
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setCondition("");
|
||||||
|
toast.show("Ачивка создана");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Новая ачивка</h3>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">Название</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Например: Великий полководец" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">Краткое описание</label>
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="За что выдаётся"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">Скрипт-условие (задел на будущее, пока не исполняется)</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={condition}
|
||||||
|
onChange={(e) => setCondition(e.target.value)}
|
||||||
|
placeholder="# условие выдачи — добавится позже"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={onCreate} disabled={create.isPending || !name.trim()}>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stack">
|
||||||
|
{(items ?? []).map((a) => (
|
||||||
|
<AchievementCard key={a.slug} ach={a} />
|
||||||
|
))}
|
||||||
|
{(items ?? []).length === 0 && <div className="muted small">Ачивок пока нет.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AchievementCard({ ach }: { ach: AchievementRead }) {
|
||||||
|
const update = useUpdateAchievement();
|
||||||
|
const del = useDeleteAchievement();
|
||||||
|
const uploadIcon = useUploadAchievementIcon();
|
||||||
|
const toast = useToast();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [name, setName] = useState(ach.name);
|
||||||
|
const [description, setDescription] = useState(ach.description);
|
||||||
|
const [condition, setCondition] = useState(ach.condition);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({
|
||||||
|
slug: ach.slug,
|
||||||
|
body: { name: name.trim(), description, condition },
|
||||||
|
});
|
||||||
|
setEditing(false);
|
||||||
|
toast.show("Сохранено");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onIcon = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
await uploadIcon.mutateAsync({ slug: ach.slug, file });
|
||||||
|
toast.show("Иконка обновлена");
|
||||||
|
} catch (err) {
|
||||||
|
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(ach.slug);
|
||||||
|
toast.show("Удалено");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ margin: 0 }}>
|
||||||
|
<div className="row" style={{ gap: 12, alignItems: "flex-start" }}>
|
||||||
|
<Avatar url={ach.icon_url} nickname={ach.name} size={48} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{editing ? (
|
||||||
|
<div className="stack" style={{ gap: 8 }}>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Название" />
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Описание"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={condition}
|
||||||
|
onChange={(e) => setCondition(e.target.value)}
|
||||||
|
placeholder="Скрипт-условие (задел)"
|
||||||
|
/>
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<button className="btn btn-primary small" onClick={save} disabled={update.isPending || !name.trim()}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost small" onClick={() => setEditing(false)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<b>{ach.name}</b>
|
||||||
|
<div className="muted small">{ach.slug}</div>
|
||||||
|
{ach.description && <div style={{ marginTop: 4 }}>{ach.description}</div>}
|
||||||
|
<div className="muted small" style={{ marginTop: 4 }}>
|
||||||
|
{ach.has_condition ? "условие задано (не исполняется)" : "условие не задано"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<div className="row" style={{ gap: 8, marginTop: 10 }}>
|
||||||
|
<button className="btn small" onClick={() => fileRef.current?.click()} disabled={uploadIcon.isPending}>
|
||||||
|
Иконка
|
||||||
|
</button>
|
||||||
|
<button className="btn small" onClick={() => setEditing(true)}>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-danger small" onClick={remove} disabled={del.isPending}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={onIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ export function AdminLayout() {
|
|||||||
<NavLink to="/admin/factions" className={tab}>
|
<NavLink to="/admin/factions" className={tab}>
|
||||||
Фракции
|
Фракции
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/admin/achievements" className={tab}>
|
||||||
|
Ачивки
|
||||||
|
</NavLink>
|
||||||
<NavLink to="/admin/logs" className={tab}>
|
<NavLink to="/admin/logs" className={tab}>
|
||||||
Логи
|
Логи
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { ApiError, api, unwrap } from "../../api/client";
|
||||||
|
import { qk } from "../../api/queryKeys";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта.
|
||||||
|
*
|
||||||
|
* Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда
|
||||||
|
* (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль
|
||||||
|
* рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке
|
||||||
|
* он не используется и вырезается тришейкингом — в прод/тест удаление недоступно.
|
||||||
|
*/
|
||||||
|
export function DevDeleteAccountButton({
|
||||||
|
userId,
|
||||||
|
nickname,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
nickname: string;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const toast = useToast();
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await api.DELETE("/api/admin/dev/users/{user_id}", {
|
||||||
|
params: { path: { user_id: userId } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn small"
|
||||||
|
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
|
||||||
|
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
|
||||||
|
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
|
||||||
|
disabled={del.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Удалить аккаунт «${nickname}»? Партии сохранятся, но игрок будет ` +
|
||||||
|
`вычеркнут из них. Действие необратимо.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await del.mutateAsync();
|
||||||
|
toast.show("Аккаунт удалён");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
/* Таблицы / списки лидерборда */
|
/* Таблицы / списки лидерборда */
|
||||||
.lb-row {
|
.lb-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 28px 1fr auto;
|
grid-template-columns: 28px 32px 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
@@ -102,6 +102,26 @@
|
|||||||
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
|
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
|
||||||
.lb-score { font-weight: 700; color: var(--accent-2); }
|
.lb-score { font-weight: 700; color: var(--accent-2); }
|
||||||
|
|
||||||
|
/* Таблица общей статистики (сортируемая, кликабельные строки) */
|
||||||
|
.st-row, .st-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 26px 1fr 40px 40px 48px 52px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 2px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.st-head { color: var(--text-muted); font-size: 12px; }
|
||||||
|
.st-head button {
|
||||||
|
background: none; border: none; color: inherit; font: inherit;
|
||||||
|
cursor: pointer; padding: 0; text-align: right;
|
||||||
|
}
|
||||||
|
.st-row:last-child { border-bottom: none; }
|
||||||
|
.st-num { text-align: right; font-variant-numeric: tabular-nums; font-size: 14px; }
|
||||||
|
.st-num.gold { color: var(--gold); font-weight: 700; }
|
||||||
|
.st-player { display: flex; align-items: center; gap: 8px; min-width: 0; font-weight: 600; }
|
||||||
|
.st-player .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
.participant-row {
|
.participant-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[33mcommit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mdev[m[33m)[m
|
||||||
|
Author: NotBigGhost <ivan@arseniev.info>
|
||||||
|
Date: Wed Jun 17 04:45:33 2026 +0300
|
||||||
|
|
||||||
|
Добавление ssh-ключа к контейнеру tunnel
|
||||||
|
|
||||||
|
[33mcommit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366[m
|
||||||
|
Author: NotBigGhost <ivan@arseniev.info>
|
||||||
|
Date: Wed Jun 17 04:27:11 2026 +0300
|
||||||
|
|
||||||
|
Правки локального размещения дева, перенос тунеля в контейнер
|
||||||
|
|
||||||
|
[33mcommit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90[m
|
||||||
|
Author: NotBigGhost <ivan@arseniev.info>
|
||||||
|
Date: Tue Jun 16 19:15:54 2026 +0300
|
||||||
|
|
||||||
|
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
|
||||||
|
|
||||||
|
[33mcommit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f[m[33m ([m[1;31morigin/main[m[33m, [m[1;32mmain[m[33m)[m
|
||||||
|
Author: NotBigGhost <ivan@arseniev.info>
|
||||||
|
Date: Tue Jun 16 17:41:06 2026 +0300
|
||||||
|
|
||||||
|
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
|
||||||
|
|
||||||
|
[33mcommit 6ab74f01aaf1ac042a269c7660b3e564b93350c7[m
|
||||||
|
Author: NotBigGhost <ivan@arseniev.info>
|
||||||
|
Date: Tue Jun 16 16:53:41 2026 +0300
|
||||||
|
|
||||||
|
first commit
|
||||||
Reference in New Issue
Block a user