Добавлена форма изменения ника при регистрации аккаунта на случай, когда уже существует аакунт с ником, совпадающим с ТГ тегом регистрируемого аккаунта
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,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 +40,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 +76,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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Vendored
+87
-1
@@ -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;
|
||||||
@@ -1369,6 +1401,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 */
|
||||||
@@ -1459,6 +1512,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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,7 +62,17 @@ export function LoginPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="muted center">Учёт партий</p>
|
<p className="muted center">Учёт партий</p>
|
||||||
|
|
||||||
{hasTelegram && (
|
{pendingTg ? (
|
||||||
|
<TelegramNicknamePicker
|
||||||
|
pending={pendingTg}
|
||||||
|
onCancel={() => {
|
||||||
|
setPendingTg(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onDone={() => navigate("/", { replace: true })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
hasTelegram && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Вход через Telegram</h3>
|
<h3>Вход через Telegram</h3>
|
||||||
{config?.telegram_bot_username ? (
|
{config?.telegram_bot_username ? (
|
||||||
@@ -63,9 +87,10 @@ export function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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,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