From 807a8933b99073e0c287239d79c6e0833b6d3b81 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 23 Apr 2026 21:20:32 +0300 Subject: [PATCH] feat: email login, smtp codes --- .env.example | 29 +- README.md | 22 +- bot/app/web/subscription_webapp.py | 398 ++++++++++- bot/app/web/templates/subscription_webapp.css | 266 ++++++++ .../web/templates/subscription_webapp.html | 89 ++- bot/app/web/templates/subscription_webapp.js | 620 +++++++++++++++++- bot/handlers/admin/logs_admin.py | 9 +- bot/handlers/admin/sync_admin.py | 60 +- bot/handlers/admin/user_management.py | 62 +- bot/keyboards/inline/admin_keyboards.py | 4 + bot/middlewares/profile_sync.py | 17 +- bot/services/email_auth_service.py | 328 +++++++++ bot/services/panel_webhook_service.py | 9 +- bot/services/subscription_service.py | 130 ++-- config/settings.py | 48 ++ db/dal/user_dal.py | 241 +++++++ db/migrator.py | 82 +++ db/models.py | 24 + locales/en.json | 10 +- locales/ru.json | 10 +- 20 files changed, 2321 insertions(+), 137 deletions(-) create mode 100644 bot/services/email_auth_service.py diff --git a/.env.example b/.env.example index aed8619..a953570 100644 --- a/.env.example +++ b/.env.example @@ -43,16 +43,30 @@ WEBAPP_SESSION_TTL_SECONDS=2592000 # WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime +# Email login and account linking via SMTP (Brevo SMTP relay defaults) +SMTP_HOST=smtp-relay.brevo.com # SMTP server +SMTP_PORT=587 # Brevo recommends 587 with STARTTLS +SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically +SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout +SMTP_USERNAME= # Brevo SMTP login +SMTP_PASSWORD= # Brevo SMTP key/password +SMTP_FROM_EMAIL= # Verified sender email +SMTP_FROM_NAME= # Optional sender name +SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT +SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465 +EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime +EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends +EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code + # Payment Method Toggles YOOKASSA_ENABLED=True # Turn on YOOKASSA FREEKASSA_ENABLED=True # Turn on FreeKassa STARS_ENABLED=True # Turn on STARS -TRIBUTE_ENABLED=True # Turn on TRIBUTE CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY PLATEGA_ENABLED=False # Turn on PLATEGA SEVERPAY_ENABLED=False # Turn on SeverPay -# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay -PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars +# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay +PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars # YooKassa Payment Gateway Configuration YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa @@ -97,31 +111,22 @@ SEVERPAY_TOKEN= # SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link) SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default) -# Tribute Payment Gateway Configuration -TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures -TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments -TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments - # Subscription Options. Specify cost parameters or payment links here. 1_MONTH_ENABLED=True RUB_PRICE_1_MONTH=150 STARS_PRICE_1_MONTH=0 -TRIBUTE_LINK_1_MONTH= 3_MONTHS_ENABLED=True RUB_PRICE_3_MONTHS=300 STARS_PRICE_3_MONTHS=0 -TRIBUTE_LINK_3_MONTHS= 6_MONTHS_ENABLED=True RUB_PRICE_6_MONTHS=500 STARS_PRICE_6_MONTHS=0 -TRIBUTE_LINK_6_MONTHS= 12_MONTHS_ENABLED=True RUB_PRICE_12_MONTHS=900 STARS_PRICE_12_MONTHS=0 -TRIBUTE_LINK_12_MONTHS= # Traffic Packages (enables traffic sale mode when set) TRAFFIC_PACKAGES=10:199,50:799 # Format: ":", comma-separated diff --git a/README.md b/README.md index 6007701..ed21019 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - **Регистрация и выбор языка:** Поддержка русского и английского языков. - **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию. - **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки. +- **Вход по email:** вход и регистрация в Web App по коду из письма, а также привязка email и Telegram к одному аккаунту. - **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`). - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. @@ -75,6 +76,13 @@ | `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` | | `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` | | `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` | +| `SMTP_HOST` | SMTP-сервер для кодов входа по email. Для Brevo: `smtp-relay.brevo.com`. | `smtp-relay.brevo.com` | +| `SMTP_PORT` | SMTP-порт. Для Brevo обычно используется 587 с STARTTLS. | `587` | +| `SMTP_FALLBACK_PORTS` | Дополнительные SMTP-порты через запятую. Пробуются после `SMTP_PORT`; порт `465` автоматически используется через SSL. Для Brevo удобно оставить `2525,465`. | `2525,465` | +| `SMTP_TIMEOUT_SECONDS` | Timeout для каждой SMTP-попытки подключения и отправки. | `30` | +| `SMTP_USERNAME` / `SMTP_PASSWORD` | Логин и SMTP key/password из Brevo. Если не заданы вместе с `SMTP_FROM_EMAIL`, вход по email скрывается. | `user@smtp-brevo.com` | +| `SMTP_FROM_EMAIL` / `SMTP_FROM_NAME` | Подтвержденный отправитель и отображаемое имя отправителя для писем с кодом. | `no-reply@example.com` | +| `EMAIL_CODE_TTL_SECONDS` | Срок действия кода подтверждения email. | `600` | | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` | @@ -194,7 +202,7 @@ ### Настройка Web App / Mini App -Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. После успешного входа страница обновляет данные сразу, без сообщений боту. +Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту. 1. Укажите в `.env` публичный URL Web App и порт: @@ -206,8 +214,16 @@ Web App запускается в том же контейнере, что и б WEBAPP_TITLE="Моя подписка" WEBAPP_PRIMARY_COLOR="#00fe7a" WEBAPP_LOGO_URL= + SMTP_HOST=smtp-relay.brevo.com + SMTP_PORT=587 + SMTP_FALLBACK_PORTS=2525,465 + SMTP_USERNAME= + SMTP_PASSWORD= + SMTP_FROM_EMAIL=no-reply@domain.com ``` + Если основной порт не отвечает, отправка письма автоматически пробует fallback-порты из `SMTP_FALLBACK_PORTS`. Для Brevo типичная схема: `587` с STARTTLS, затем `2525`, затем `465` через SSL. + 2. Убедитесь, что `docker-compose.yml` публикует порт Web App: ```yaml @@ -250,7 +266,9 @@ Web App запускается в том же контейнере, что и б docker compose up -d --build ``` -После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени и трафик. Оплата открывается отдельной кнопкой «Продлить подписку/Добавить дни»: пользователь пошагово выбирает срок, способ оплаты и создает платежную ссылку через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками. +После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Login Widget. После привязки вход работает обоими способами. + +Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_`. Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов. diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 691b7c2..55d432f 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -17,6 +17,7 @@ from bot.app.web.webapp_auth import ( verify_webapp_session_token, ) from bot.services.crypto_pay_service import CryptoPayService +from bot.services.email_auth_service import EmailAuthService, normalize_email from bot.services.freekassa_service import FreeKassaService from bot.services.platega_service import PlategaService from bot.services.severpay_service import SeverPayService @@ -25,6 +26,7 @@ from bot.services.yookassa_service import YooKassaService from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username from config.settings import Settings from db.dal import payment_dal, subscription_dal, user_dal +from db.dal.user_dal import UserMergeConflictError from db.models import Payment, User logger = logging.getLogger(__name__) @@ -54,6 +56,7 @@ def create_subscription_webapp_application( app["settings"] = settings app["async_session_factory"] = async_session_factory app["i18n"] = dp.get("i18n_instance") + app["email_auth_service"] = EmailAuthService(settings) for key in ( "subscription_service", @@ -81,7 +84,12 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get("/subscription_webapp.js", js_asset_route) app.router.add_post("/api/auth/token", auth_token_route) + app.router.add_post("/api/auth/email/request", email_auth_request_route) + app.router.add_post("/api/auth/email/verify", email_auth_verify_route) app.router.add_get("/api/me", me_route) + app.router.add_post("/api/account/email/request", account_email_request_route) + app.router.add_post("/api/account/email/verify", account_email_verify_route) + app.router.add_post("/api/account/telegram/link", account_telegram_link_route) app.router.add_post("/api/payments", create_payment_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route) @@ -272,6 +280,7 @@ async def index_route(request: web.Request) -> web.Response: "userAgreementUrl": settings.USER_AGREEMENT_URL or "", "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "language": _normalize_language(settings.DEFAULT_LANGUAGE), + "emailAuthEnabled": settings.email_auth_configured, } html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) html = html.replace( @@ -340,22 +349,220 @@ async def auth_token_route(request: web.Request) -> web.Response: return _json_error(401, "invalid_auth", "Invalid Telegram auth data") async_session_factory: sessionmaker = request.app["async_session_factory"] + authenticated_user_id: Optional[int] = None async with async_session_factory() as session: try: db_user = await _ensure_user_from_telegram(session, telegram_user, settings) if db_user.is_banned: await session.rollback() return _json_error(403, "banned", "Access denied") + authenticated_user_id = int(db_user.user_id) await session.commit() except Exception as exc: await session.rollback() logger.error("WebApp auth failed: %s", exc, exc_info=True) return _json_error(500, "auth_failed", "Auth failed") - token = create_webapp_session_token(settings, int(telegram_user["id"])) + token = create_webapp_session_token(settings, int(authenticated_user_id)) return web.json_response({"ok": True, "token": token}) +async def email_auth_request_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + payload = await _read_json(request) + email = normalize_email(str(payload.get("email") or "")) + lang = _normalize_language(str(payload.get("language") or settings.DEFAULT_LANGUAGE)) + return await _request_email_code( + request, + email=email, + purpose="login", + language_code=lang, + target_user_id=None, + ) + + +async def email_auth_verify_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + payload = await _read_json(request) + email = normalize_email(str(payload.get("email") or "")) + code = str(payload.get("code") or "") + email_service: EmailAuthService = request.app["email_auth_service"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + try: + verify_result = await email_service.verify_code( + session, + email=email, + purpose="login", + code=code, + target_user_id=None, + ) + if not verify_result.ok: + await session.rollback() + return _json_error(400, verify_result.error or "invalid_code", "Invalid code") + + db_user = await user_dal.get_user_by_email(session, email) + if not db_user: + db_user, _ = await user_dal.create_email_user( + session, + email=email, + language_code=_normalize_language(settings.DEFAULT_LANGUAGE), + email_verified_at=datetime.now(timezone.utc), + ) + elif not db_user.email_verified_at: + db_user.email_verified_at = datetime.now(timezone.utc) + + if db_user.is_banned: + await session.rollback() + return _json_error(403, "banned", "Access denied") + + await session.commit() + except Exception as exc: + await session.rollback() + logger.error("Email WebApp auth failed: %s", exc, exc_info=True) + return _json_error(500, "auth_failed", "Auth failed") + + token = create_webapp_session_token(settings, int(db_user.user_id)) + return web.json_response( + { + "ok": True, + "token": token, + "user_id": int(db_user.user_id), + "telegram_id": _telegram_id_for_user(db_user), + } + ) + + +async def account_email_request_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + email = normalize_email(str(payload.get("email") or "")) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return _json_error(403, "access_denied", "Access denied") + if db_user.email == email and db_user.email_verified_at: + return web.json_response({"ok": True, "already_linked": True}) + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + + return await _request_email_code( + request, + email=email, + purpose="link_email", + language_code=lang, + target_user_id=user_id, + ) + + +async def account_email_verify_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + email = normalize_email(str(payload.get("email") or "")) + code = str(payload.get("code") or "") + email_service: EmailAuthService = request.app["email_auth_service"] + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + try: + verify_result = await email_service.verify_code( + session, + email=email, + purpose="link_email", + code=code, + target_user_id=user_id, + ) + if not verify_result.ok: + await session.rollback() + return _json_error(400, verify_result.error or "invalid_code", "Invalid code") + + current_user = await user_dal.get_user_by_id(session, user_id) + if not current_user or current_user.is_banned: + await session.rollback() + return _json_error(403, "access_denied", "Access denied") + + existing_email_user = await user_dal.get_user_by_email(session, email) + if existing_email_user and existing_email_user.user_id != current_user.user_id: + current_user = await user_dal.merge_users( + session, + source_user_id=existing_email_user.user_id, + target_user_id=current_user.user_id, + ) + current_user.email = email + current_user.email_verified_at = datetime.now(timezone.utc) + await _sync_panel_identity_for_user(request, current_user) + await session.commit() + except UserMergeConflictError as exc: + await session.rollback() + return _json_error(409, "account_merge_conflict", str(exc)) + except Exception as exc: + await session.rollback() + logger.error("Email account link failed: %s", exc, exc_info=True) + return _json_error(500, "link_failed", "Link failed") + + token = create_webapp_session_token(settings, int(current_user.user_id)) + return web.json_response({"ok": True, "token": token}) + + +async def account_telegram_link_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + init_data = str(payload.get("init_data") or "") + auth_data = payload.get("auth_data") + telegram_user = None + if init_data: + telegram_user = validate_telegram_webapp_init_data( + init_data, + settings.BOT_TOKEN, + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + elif auth_data is not None: + telegram_user = validate_telegram_login_widget_data( + auth_data, + settings.BOT_TOKEN, + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + if not telegram_user: + return _json_error(401, "invalid_auth", "Invalid Telegram auth data") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + try: + db_user = await _link_telegram_to_user( + request, + session, + current_user_id=user_id, + telegram_user=telegram_user, + settings=settings, + ) + if db_user.is_banned: + await session.rollback() + return _json_error(403, "banned", "Access denied") + await session.commit() + except UserMergeConflictError as exc: + await session.rollback() + return _json_error(409, "account_merge_conflict", str(exc)) + except Exception as exc: + await session.rollback() + logger.error("Telegram account link failed: %s", exc, exc_info=True) + return _json_error(500, "link_failed", "Link failed") + + token = create_webapp_session_token(settings, int(db_user.user_id)) + return web.json_response( + { + "ok": True, + "token": token, + "user_id": int(db_user.user_id), + "telegram_id": _telegram_id_for_user(db_user), + } + ) + + async def me_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) data = await _build_user_payload(request, user_id) @@ -448,6 +655,183 @@ def _require_user_id(request: web.Request) -> int: return user_id +async def _request_email_code( + request: web.Request, + *, + email: str, + purpose: str, + language_code: str, + target_user_id: Optional[int], +) -> web.Response: + email_service: EmailAuthService = request.app["email_auth_service"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + try: + result = await email_service.request_code( + session, + email=email, + purpose=purpose, + language_code=language_code, + target_user_id=target_user_id, + ) + if not result.ok: + await session.rollback() + status = 429 if result.error == "rate_limited" else 400 + if result.error == "email_auth_not_configured": + status = 503 + return web.json_response( + { + "ok": False, + "error": result.error, + "retry_after": result.retry_after, + }, + status=status, + ) + await session.commit() + return web.json_response({"ok": True}) + except Exception as exc: + await session.rollback() + logger.error("Failed to send email verification code: %s", exc, exc_info=True) + return _json_error(502, "email_send_failed", "Failed to send email") + + +def _telegram_id_for_user(user: User) -> Optional[int]: + if user.telegram_id: + return int(user.telegram_id) + if user.user_id and int(user.user_id) > 0: + return int(user.user_id) + return None + + +def _panel_description_for_user(user: User) -> str: + lines = [ + user.email or "", + user.username or "", + user.first_name or "", + user.last_name or "", + ] + return "\n".join(line for line in lines if line).strip() + + +async def _sync_panel_identity_for_user(request: web.Request, user: User) -> None: + if not user.panel_user_uuid: + return + subscription_service: SubscriptionService = request.app.get("subscription_service") + if not subscription_service or not subscription_service.panel_service: + return + + payload: Dict[str, Any] = { + "description": _panel_description_for_user(user), + } + telegram_id = _telegram_id_for_user(user) + if telegram_id: + payload["telegramId"] = telegram_id + if user.email: + payload["email"] = user.email + + try: + await subscription_service.panel_service.update_user_details_on_panel( + user.panel_user_uuid, + payload, + log_response=False, + ) + except Exception as exc: + logger.warning( + "Failed to sync linked identities to panel for user %s: %s", + user.user_id, + exc, + ) + + +def _apply_telegram_profile_to_user( + user: User, + telegram_user: Dict[str, Any], + settings: Settings, +) -> None: + language_code = telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE + if language_code not in {"ru", "en"}: + language_code = user.language_code or settings.DEFAULT_LANGUAGE + + user.telegram_id = int(telegram_user["id"]) + user.username = sanitize_username(telegram_user.get("username")) + user.first_name = sanitize_display_name(telegram_user.get("first_name")) + user.last_name = sanitize_display_name(telegram_user.get("last_name")) + user.language_code = language_code + + +async def _link_telegram_to_user( + request: web.Request, + session: AsyncSession, + *, + current_user_id: int, + telegram_user: Dict[str, Any], + settings: Settings, +) -> User: + telegram_id = int(telegram_user["id"]) + current_user = await user_dal.get_user_by_id(session, current_user_id) + if not current_user: + raise ValueError("Current user not found.") + + existing_telegram_user = await user_dal.get_user_by_telegram_id(session, telegram_id) + if not existing_telegram_user: + existing_telegram_user = await user_dal.get_user_by_id(session, telegram_id) + + if existing_telegram_user and existing_telegram_user.user_id != current_user.user_id: + if ( + current_user.email + and existing_telegram_user.email + and current_user.email != existing_telegram_user.email + ): + raise UserMergeConflictError( + "Telegram account is already linked to a different email." + ) + merged_user = await user_dal.merge_users( + session, + source_user_id=current_user.user_id, + target_user_id=existing_telegram_user.user_id, + ) + _apply_telegram_profile_to_user(merged_user, telegram_user, settings) + await session.flush() + await _sync_panel_identity_for_user(request, merged_user) + return merged_user + + if not existing_telegram_user and int(current_user.user_id) < 0: + language_code = telegram_user.get("language_code") or current_user.language_code or settings.DEFAULT_LANGUAGE + if language_code not in {"ru", "en"}: + language_code = current_user.language_code or settings.DEFAULT_LANGUAGE + target_user, _ = await user_dal.create_user( + session, + { + "user_id": telegram_id, + "telegram_id": telegram_id, + "username": sanitize_username(telegram_user.get("username")), + "first_name": sanitize_display_name(telegram_user.get("first_name")), + "last_name": sanitize_display_name(telegram_user.get("last_name")), + "language_code": language_code, + "registration_date": current_user.registration_date or datetime.now(timezone.utc), + }, + ) + target_user.referral_code = None + await session.flush() + merged_user = await user_dal.merge_users( + session, + source_user_id=current_user.user_id, + target_user_id=target_user.user_id, + ) + _apply_telegram_profile_to_user(merged_user, telegram_user, settings) + await session.flush() + await _sync_panel_identity_for_user(request, merged_user) + return merged_user + + if current_user.telegram_id and int(current_user.telegram_id) != telegram_id: + raise UserMergeConflictError("Current account is already linked to Telegram.") + + _apply_telegram_profile_to_user(current_user, telegram_user, settings) + await session.flush() + await _sync_panel_identity_for_user(request, current_user) + return current_user + + async def _ensure_user_from_telegram( session: AsyncSession, telegram_user: Dict[str, Any], @@ -459,13 +843,16 @@ async def _ensure_user_from_telegram( language_code = settings.DEFAULT_LANGUAGE update_data = { + "telegram_id": user_id, "username": sanitize_username(telegram_user.get("username")), "first_name": sanitize_display_name(telegram_user.get("first_name")), "last_name": sanitize_display_name(telegram_user.get("last_name")), "language_code": language_code, } - db_user = await user_dal.get_user_by_id(session, user_id) + db_user = await user_dal.get_user_by_telegram_id(session, user_id) + if not db_user: + db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: db_user, _ = await user_dal.create_user( session, @@ -483,7 +870,7 @@ async def _ensure_user_from_telegram( if getattr(db_user, key) != value } if changed: - db_user = await user_dal.update_user(session, user_id, changed) or db_user + db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user return db_user @@ -518,6 +905,10 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "user": { "id": user_id, "username": db_user.username, + "email": db_user.email, + "email_verified": bool(db_user.email_verified_at), + "telegram_id": db_user.telegram_id, + "telegram_linked": bool(_telegram_id_for_user(db_user)), "first_name": db_user.first_name, "language_code": lang, }, @@ -527,6 +918,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "settings": { "support_url": settings.SUPPORT_LINK, "traffic_mode": bool(settings.traffic_sale_mode), + "email_auth_enabled": settings.email_auth_configured, }, } diff --git a/bot/app/web/templates/subscription_webapp.css b/bot/app/web/templates/subscription_webapp.css index 2748170..f71e0d8 100644 --- a/bot/app/web/templates/subscription_webapp.css +++ b/bot/app/web/templates/subscription_webapp.css @@ -176,6 +176,96 @@ gap: 16px; } + .account-panel { + padding: 16px; + display: grid; + gap: 14px; + } + + .panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + .section-title { + font-size: 18px; + font-weight: 850; + line-height: 1.2; + } + + .account-grid { + display: grid; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + } + + .account-row { + min-height: 46px; + padding: 10px 12px; + display: grid; + grid-template-columns: minmax(0, 0.68fr) minmax(0, 1.32fr); + align-items: center; + gap: 10px; + background: rgba(255, 255, 255, 0.02); + } + + .account-row + .account-row { + border-top: 1px solid var(--border); + } + + .link-box, + .field-stack { + display: grid; + gap: 8px; + } + + .field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(120px, auto); + gap: 8px; + align-items: stretch; + } + + .input { + width: 100%; + min-height: 46px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.035); + color: var(--text-primary); + padding: 11px 12px; + font: inherit; + font-weight: 700; + outline: none; + transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); + } + + .input:focus { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); + } + + .input[aria-invalid="true"] { + border-color: color-mix(in srgb, var(--danger) 72%, var(--border)); + } + + .input[aria-invalid="true"]:focus { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); + } + + .input::placeholder { + color: var(--text-muted); + } + + .code-input { + font-family: var(--font-mono); + text-align: center; + letter-spacing: 0; + } + .badge-row { display: flex; align-items: center; @@ -474,6 +564,22 @@ gap: 14px; } + .code-modal-stack { + position: relative; + z-index: 1; + width: min(100%, 420px); + display: grid; + gap: 10px; + justify-items: stretch; + } + + .code-modal-card { + width: 100%; + padding: 16px; + display: grid; + gap: 14px; + } + .flow-head { display: flex; align-items: flex-start; @@ -493,6 +599,91 @@ font-size: 13px; } + .code-summary { + min-height: 46px; + padding: 10px 12px; + display: grid; + grid-template-columns: minmax(0, 0.6fr) minmax(0, 1.4fr); + align-items: center; + gap: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.02); + } + + .otp-field { + position: relative; + display: block; + min-height: 56px; + } + + .otp-input { + position: absolute; + inset: 0; + z-index: 2; + width: 100%; + height: 100%; + border: 0; + background: transparent; + color: transparent; + caret-color: transparent; + opacity: 0.01; + outline: none; + } + + .otp-slots { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + } + + .otp-slots span { + min-width: 0; + aspect-ratio: 1; + display: grid; + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.035); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 20px; + font-weight: 850; + line-height: 1; + transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); + } + + .otp-slots span.filled { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035)); + } + + .otp-slots span.active { + border-color: color-mix(in srgb, var(--accent) 72%, var(--border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); + } + + .code-modal-actions { + display: grid; + gap: 8px; + } + + .code-modal-resend { + justify-self: center; + min-height: 28px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 13px; + font-weight: 750; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor); + text-underline-offset: 3px; + transition: color var(--transition), opacity var(--transition); + } + .stepper { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -654,6 +845,36 @@ text-align: center; } + .auth-tabs { + min-height: 46px; + padding: 4px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.02); + } + + .auth-tab { + min-height: 36px; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-weight: 850; + transition: color var(--transition), background var(--transition); + } + + .auth-tab.active { + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + } + + .auth-pane { + display: grid; + gap: 10px; + } + .login-brand { display: flex; align-items: center; @@ -747,6 +968,10 @@ background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent); } + + .code-modal-resend:hover:not(:disabled) { + color: var(--accent); + } } @media (max-width: 380px) { @@ -767,6 +992,11 @@ grid-template-columns: 1fr; } + .account-row, + .field-row { + grid-template-columns: 1fr; + } + .metric-value { text-align: left; } @@ -775,6 +1005,18 @@ grid-template-columns: 1fr; } + .code-summary { + grid-template-columns: 1fr; + } + + .otp-slots { + gap: 6px; + } + + .otp-slots span { + font-size: 18px; + } + .stepper { gap: 6px; } @@ -830,6 +1072,30 @@ border-radius: 0; } + .auth-code-modal { + align-items: center; + justify-items: center; + top: 0; + bottom: 0; + padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px); + } + + .auth-code-modal .code-modal-stack { + width: min(100%, 420px); + } + + .auth-code-modal .modal-card { + width: 100%; + height: auto; + max-height: min(90vh, 620px); + border-radius: var(--radius-lg); + transform: translateY(12px) scale(0.98); + } + + .auth-code-modal.show .modal-card { + transform: translateY(0) scale(1); + } + .legal-link { flex: 1 1 100%; } diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html index 152c803..f0bff04 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/bot/app/web/templates/subscription_webapp.html @@ -74,6 +74,42 @@ + + + +