feat: email login, smtp codes
This commit is contained in:
+17
-12
@@ -43,16 +43,30 @@ WEBAPP_SESSION_TTL_SECONDS=2592000 #
|
|||||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
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
|
# Payment Method Toggles
|
||||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||||
STARS_ENABLED=True # Turn on STARS
|
STARS_ENABLED=True # Turn on STARS
|
||||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
|
||||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
|
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
|
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||||
|
|
||||||
# YooKassa Payment Gateway Configuration
|
# YooKassa Payment Gateway Configuration
|
||||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
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_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)
|
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.
|
# Subscription Options. Specify cost parameters or payment links here.
|
||||||
1_MONTH_ENABLED=True
|
1_MONTH_ENABLED=True
|
||||||
RUB_PRICE_1_MONTH=150
|
RUB_PRICE_1_MONTH=150
|
||||||
STARS_PRICE_1_MONTH=0
|
STARS_PRICE_1_MONTH=0
|
||||||
TRIBUTE_LINK_1_MONTH=
|
|
||||||
|
|
||||||
3_MONTHS_ENABLED=True
|
3_MONTHS_ENABLED=True
|
||||||
RUB_PRICE_3_MONTHS=300
|
RUB_PRICE_3_MONTHS=300
|
||||||
STARS_PRICE_3_MONTHS=0
|
STARS_PRICE_3_MONTHS=0
|
||||||
TRIBUTE_LINK_3_MONTHS=
|
|
||||||
|
|
||||||
6_MONTHS_ENABLED=True
|
6_MONTHS_ENABLED=True
|
||||||
RUB_PRICE_6_MONTHS=500
|
RUB_PRICE_6_MONTHS=500
|
||||||
STARS_PRICE_6_MONTHS=0
|
STARS_PRICE_6_MONTHS=0
|
||||||
TRIBUTE_LINK_6_MONTHS=
|
|
||||||
|
|
||||||
12_MONTHS_ENABLED=True
|
12_MONTHS_ENABLED=True
|
||||||
RUB_PRICE_12_MONTHS=900
|
RUB_PRICE_12_MONTHS=900
|
||||||
STARS_PRICE_12_MONTHS=0
|
STARS_PRICE_12_MONTHS=0
|
||||||
TRIBUTE_LINK_12_MONTHS=
|
|
||||||
|
|
||||||
# Traffic Packages (enables traffic sale mode when set)
|
# Traffic Packages (enables traffic sale mode when set)
|
||||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||||
- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки.
|
- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки.
|
||||||
|
- **Вход по email:** вход и регистрация в Web App по коду из письма, а также привязка email и Telegram к одному аккаунту.
|
||||||
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||||
@@ -75,6 +76,13 @@
|
|||||||
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
|
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
|
||||||
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
|
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
|
||||||
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` |
|
| `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` |
|
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||||
@@ -194,7 +202,7 @@
|
|||||||
|
|
||||||
### Настройка Web App / Mini App
|
### Настройка 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 и порт:
|
1. Укажите в `.env` публичный URL Web App и порт:
|
||||||
|
|
||||||
@@ -206,8 +214,16 @@ Web App запускается в том же контейнере, что и б
|
|||||||
WEBAPP_TITLE="Моя подписка"
|
WEBAPP_TITLE="Моя подписка"
|
||||||
WEBAPP_PRIMARY_COLOR="#00fe7a"
|
WEBAPP_PRIMARY_COLOR="#00fe7a"
|
||||||
WEBAPP_LOGO_URL=
|
WEBAPP_LOGO_URL=
|
||||||
|
SMTP_HOST=smtp-relay.brevo.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_FALLBACK_PORTS=2525,465
|
||||||
|
SMTP_USERNAME=<brevo-smtp-login>
|
||||||
|
SMTP_PASSWORD=<brevo-smtp-key>
|
||||||
|
SMTP_FROM_EMAIL=no-reply@domain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Если основной порт не отвечает, отправка письма автоматически пробует fallback-порты из `SMTP_FALLBACK_PORTS`. Для Brevo типичная схема: `587` с STARTTLS, затем `2525`, затем `465` через SSL.
|
||||||
|
|
||||||
2. Убедитесь, что `docker-compose.yml` публикует порт Web App:
|
2. Убедитесь, что `docker-compose.yml` публикует порт Web App:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -250,7 +266,9 @@ Web App запускается в том же контейнере, что и б
|
|||||||
docker compose up -d --build
|
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_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
|
||||||
|
|
||||||
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
|
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from bot.app.web.webapp_auth import (
|
|||||||
verify_webapp_session_token,
|
verify_webapp_session_token,
|
||||||
)
|
)
|
||||||
from bot.services.crypto_pay_service import CryptoPayService
|
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.freekassa_service import FreeKassaService
|
||||||
from bot.services.platega_service import PlategaService
|
from bot.services.platega_service import PlategaService
|
||||||
from bot.services.severpay_service import SeverPayService
|
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 bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import payment_dal, subscription_dal, user_dal
|
from db.dal import payment_dal, subscription_dal, user_dal
|
||||||
|
from db.dal.user_dal import UserMergeConflictError
|
||||||
from db.models import Payment, User
|
from db.models import Payment, User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -54,6 +56,7 @@ def create_subscription_webapp_application(
|
|||||||
app["settings"] = settings
|
app["settings"] = settings
|
||||||
app["async_session_factory"] = async_session_factory
|
app["async_session_factory"] = async_session_factory
|
||||||
app["i18n"] = dp.get("i18n_instance")
|
app["i18n"] = dp.get("i18n_instance")
|
||||||
|
app["email_auth_service"] = EmailAuthService(settings)
|
||||||
|
|
||||||
for key in (
|
for key in (
|
||||||
"subscription_service",
|
"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.css", css_asset_route)
|
||||||
app.router.add_get("/subscription_webapp.js", js_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/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_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_post("/api/payments", create_payment_route)
|
||||||
app.router.add_get("/api/payments/{payment_id}", payment_status_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 "",
|
"userAgreementUrl": settings.USER_AGREEMENT_URL or "",
|
||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||||
"language": _normalize_language(settings.DEFAULT_LANGUAGE),
|
"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 = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
||||||
html = html.replace(
|
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")
|
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
|
||||||
|
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
authenticated_user_id: Optional[int] = None
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
try:
|
try:
|
||||||
db_user = await _ensure_user_from_telegram(session, telegram_user, settings)
|
db_user = await _ensure_user_from_telegram(session, telegram_user, settings)
|
||||||
if db_user.is_banned:
|
if db_user.is_banned:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
return _json_error(403, "banned", "Access denied")
|
return _json_error(403, "banned", "Access denied")
|
||||||
|
authenticated_user_id = int(db_user.user_id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logger.error("WebApp auth failed: %s", exc, exc_info=True)
|
logger.error("WebApp auth failed: %s", exc, exc_info=True)
|
||||||
return _json_error(500, "auth_failed", "Auth failed")
|
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})
|
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:
|
async def me_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
data = await _build_user_payload(request, user_id)
|
data = await _build_user_payload(request, user_id)
|
||||||
@@ -448,6 +655,183 @@ def _require_user_id(request: web.Request) -> int:
|
|||||||
return user_id
|
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(
|
async def _ensure_user_from_telegram(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
telegram_user: Dict[str, Any],
|
telegram_user: Dict[str, Any],
|
||||||
@@ -459,12 +843,15 @@ async def _ensure_user_from_telegram(
|
|||||||
language_code = settings.DEFAULT_LANGUAGE
|
language_code = settings.DEFAULT_LANGUAGE
|
||||||
|
|
||||||
update_data = {
|
update_data = {
|
||||||
|
"telegram_id": user_id,
|
||||||
"username": sanitize_username(telegram_user.get("username")),
|
"username": sanitize_username(telegram_user.get("username")),
|
||||||
"first_name": sanitize_display_name(telegram_user.get("first_name")),
|
"first_name": sanitize_display_name(telegram_user.get("first_name")),
|
||||||
"last_name": sanitize_display_name(telegram_user.get("last_name")),
|
"last_name": sanitize_display_name(telegram_user.get("last_name")),
|
||||||
"language_code": language_code,
|
"language_code": language_code,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
db_user, _ = await user_dal.create_user(
|
db_user, _ = await user_dal.create_user(
|
||||||
@@ -483,7 +870,7 @@ async def _ensure_user_from_telegram(
|
|||||||
if getattr(db_user, key) != value
|
if getattr(db_user, key) != value
|
||||||
}
|
}
|
||||||
if changed:
|
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
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
@@ -518,6 +905,10 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
"user": {
|
"user": {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
"username": db_user.username,
|
"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,
|
"first_name": db_user.first_name,
|
||||||
"language_code": lang,
|
"language_code": lang,
|
||||||
},
|
},
|
||||||
@@ -527,6 +918,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
"settings": {
|
"settings": {
|
||||||
"support_url": settings.SUPPORT_LINK,
|
"support_url": settings.SUPPORT_LINK,
|
||||||
"traffic_mode": bool(settings.traffic_sale_mode),
|
"traffic_mode": bool(settings.traffic_sale_mode),
|
||||||
|
"email_auth_enabled": settings.email_auth_configured,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,96 @@
|
|||||||
gap: 16px;
|
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 {
|
.badge-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -474,6 +564,22 @@
|
|||||||
gap: 14px;
|
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 {
|
.flow-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -493,6 +599,91 @@
|
|||||||
font-size: 13px;
|
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 {
|
.stepper {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -654,6 +845,36 @@
|
|||||||
text-align: center;
|
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 {
|
.login-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -747,6 +968,10 @@
|
|||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
box-shadow: 0 10px 24px 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) {
|
@media (max-width: 380px) {
|
||||||
@@ -767,6 +992,11 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-row,
|
||||||
|
.field-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-value {
|
.metric-value {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -775,6 +1005,18 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.code-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-slots {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-slots span {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.stepper {
|
.stepper {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
@@ -830,6 +1072,30 @@
|
|||||||
border-radius: 0;
|
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 {
|
.legal-link {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel account-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<div class="section-title" data-i18n="account_title">Аккаунт</div>
|
||||||
|
<div class="flow-caption" data-i18n="account_caption">Способы входа</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="account-grid">
|
||||||
|
<div class="account-row">
|
||||||
|
<div class="metric-label" data-i18n="email_label">Email</div>
|
||||||
|
<div id="account-email" class="metric-value">...</div>
|
||||||
|
</div>
|
||||||
|
<div class="account-row">
|
||||||
|
<div class="metric-label" data-i18n="telegram_label">Telegram</div>
|
||||||
|
<div id="account-telegram" class="metric-value">...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="email-link-box" class="link-box hidden">
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="email-link-input" class="input" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||||
|
<button id="email-link-send-btn" class="btn" type="button" onclick="requestEmailLinkCode()" data-i18n="send_code">Отправить код</button>
|
||||||
|
</div>
|
||||||
|
<div id="email-link-code-row" class="field-row hidden">
|
||||||
|
<input id="email-link-code-input" class="input code-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
|
||||||
|
<button id="email-link-verify-btn" class="btn primary" type="button" onclick="verifyEmailLinkCode()" data-i18n="confirm">Подтвердить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="telegram-link-box" class="link-box hidden">
|
||||||
|
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||||
|
<div id="telegram-link-status" class="login-text login-status hidden" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<a id="support-link" class="btn support-link hidden" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
|
<a id="support-link" class="btn support-link hidden" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
|
||||||
<div id="legal-links-app" class="legal-links hidden">
|
<div id="legal-links-app" class="legal-links hidden">
|
||||||
<a class="legal-link" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
<a class="legal-link" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||||
@@ -134,13 +170,64 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
|
||||||
|
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()"></button>
|
||||||
|
<div class="code-modal-stack">
|
||||||
|
<section class="panel modal-card code-modal-card">
|
||||||
|
<div class="flow-head">
|
||||||
|
<div>
|
||||||
|
<div id="email-code-title" class="flow-title" data-i18n="email_code_title">Подтвердите вход</div>
|
||||||
|
<div id="email-code-caption" class="flow-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="code-summary">
|
||||||
|
<div class="metric-label" data-i18n="email_label">Email</div>
|
||||||
|
<div id="email-code-address" class="metric-value">...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="otp-field" for="email-login-code-input">
|
||||||
|
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
|
||||||
|
<span class="otp-slots" aria-hidden="true">
|
||||||
|
<span id="email-code-slot-0"></span>
|
||||||
|
<span id="email-code-slot-1"></span>
|
||||||
|
<span id="email-code-slot-2"></span>
|
||||||
|
<span id="email-code-slot-3"></span>
|
||||||
|
<span id="email-code-slot-4"></span>
|
||||||
|
<span id="email-code-slot-5"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="email-code-status" class="login-text login-status hidden" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div class="code-modal-actions">
|
||||||
|
<button id="email-login-verify-btn" class="btn primary full" type="button" onclick="verifyEmailLoginCode()" data-i18n="login">Войти</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<button id="email-login-resend-btn" class="code-modal-resend" type="button" onclick="resendEmailLoginCode()" data-i18n="resend_code">Отправить еще раз</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section id="login" class="login">
|
<section id="login" class="login">
|
||||||
<div class="panel login-card">
|
<div class="panel login-card">
|
||||||
<div class="login-brand">
|
<div class="login-brand">
|
||||||
<img id="login-brand-logo" class="brand-logo brand-logo--lg hidden" data-brand-logo alt="" aria-hidden="true">
|
<img id="login-brand-logo" class="brand-logo brand-logo--lg hidden" data-brand-logo alt="" aria-hidden="true">
|
||||||
<div id="login-brand-title" class="login-brand-title" data-brand-title>Моя подписка</div>
|
<div id="login-brand-title" class="login-brand-title" data-brand-title>Моя подписка</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="auth-tabs" role="tablist">
|
||||||
|
<button id="email-auth-tab" class="auth-tab active" type="button" onclick="setAuthMode('email')" data-i18n="email_login_tab">Email</button>
|
||||||
|
<button id="telegram-auth-tab" class="auth-tab" type="button" onclick="setAuthMode('telegram')" data-i18n="telegram_login_tab">Telegram</button>
|
||||||
|
</div>
|
||||||
|
<div id="email-login-pane" class="auth-pane">
|
||||||
|
<div class="field-stack">
|
||||||
|
<input id="email-login-input" class="input" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||||
|
<button id="email-login-send-btn" class="btn primary full" type="button" onclick="requestEmailLoginCode()" data-i18n="send_code">Отправить код</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="telegram-login-pane" class="auth-pane hidden">
|
||||||
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
|
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
<div id="auth-status" class="login-text login-status hidden" aria-live="polite"></div>
|
<div id="auth-status" class="login-text login-status hidden" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="legal-links-login" class="legal-links hidden">
|
<div id="legal-links-login" class="legal-links hidden">
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ window.__WEBAPP_DEV_MOCK__ = {
|
|||||||
privacyPolicyUrl: 'https://example.com/privacy',
|
privacyPolicyUrl: 'https://example.com/privacy',
|
||||||
userAgreementUrl: 'https://example.com/agreement',
|
userAgreementUrl: 'https://example.com/agreement',
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
language: 'ru'
|
language: 'ru',
|
||||||
|
emailAuthEnabled: true,
|
||||||
|
telegramLoginBotUsername: 'preview_bot'
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
ok: true,
|
ok: true,
|
||||||
user: {
|
user: {
|
||||||
id: 100200300,
|
id: 100200300,
|
||||||
username: 'preview',
|
username: 'preview',
|
||||||
|
email: '',
|
||||||
|
email_verified: false,
|
||||||
|
telegram_id: 100200300,
|
||||||
|
telegram_linked: true,
|
||||||
first_name: 'Preview',
|
first_name: 'Preview',
|
||||||
language_code: 'ru'
|
language_code: 'ru'
|
||||||
},
|
},
|
||||||
@@ -45,7 +51,8 @@ window.__WEBAPP_DEV_MOCK__ = {
|
|||||||
],
|
],
|
||||||
settings: {
|
settings: {
|
||||||
support_url: 'https://t.me/support',
|
support_url: 'https://t.me/support',
|
||||||
traffic_mode: false
|
traffic_mode: false,
|
||||||
|
email_auth_enabled: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -70,6 +77,16 @@ const MOCK = (() => {
|
|||||||
paymentStep: 'plan',
|
paymentStep: 'plan',
|
||||||
creatingPayment: false,
|
creatingPayment: false,
|
||||||
authInProgress: false,
|
authInProgress: false,
|
||||||
|
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
|
||||||
|
emailLoginPending: false,
|
||||||
|
emailLoginEmail: '',
|
||||||
|
emailLoginCodeModalOpen: false,
|
||||||
|
emailLoginVerifying: false,
|
||||||
|
emailLoginResending: false,
|
||||||
|
emailLinkPending: false,
|
||||||
|
emailLinkEmail: '',
|
||||||
|
telegramLinkRendered: false,
|
||||||
|
telegramLinkInProgress: false,
|
||||||
toastTimer: null
|
toastTimer: null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,9 +102,10 @@ const MOCK = (() => {
|
|||||||
traffic: 'Трафик',
|
traffic: 'Трафик',
|
||||||
connect: 'Подключиться',
|
connect: 'Подключиться',
|
||||||
copy_link: 'Скопировать ссылку',
|
copy_link: 'Скопировать ссылку',
|
||||||
extend_subscription: 'Продлить подписку/Добавить дни',
|
extend_subscription: 'Купить подписку / Добавить дни',
|
||||||
payment_title: 'Оплата подписки',
|
payment_title: 'Оплата подписки',
|
||||||
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
|
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
|
||||||
|
close: 'Закрыть',
|
||||||
close_payment: 'Закрыть оплату',
|
close_payment: 'Закрыть оплату',
|
||||||
payment_steps: 'Шаги оплаты',
|
payment_steps: 'Шаги оплаты',
|
||||||
step_plan: 'Срок',
|
step_plan: 'Срок',
|
||||||
@@ -103,6 +121,35 @@ const MOCK = (() => {
|
|||||||
check_payment: 'Проверить оплату',
|
check_payment: 'Проверить оплату',
|
||||||
choose_other_method: 'Выбрать другой способ',
|
choose_other_method: 'Выбрать другой способ',
|
||||||
support: 'Поддержка',
|
support: 'Поддержка',
|
||||||
|
account_title: 'Аккаунт',
|
||||||
|
account_caption: 'Способы входа',
|
||||||
|
email_label: 'Email',
|
||||||
|
telegram_label: 'Telegram',
|
||||||
|
linked: 'Привязан',
|
||||||
|
not_linked: 'Не привязан',
|
||||||
|
email_login_tab: 'Email',
|
||||||
|
telegram_login_tab: 'Telegram',
|
||||||
|
email_placeholder: 'mail@example.com',
|
||||||
|
code_placeholder: '000000',
|
||||||
|
send_code: 'Отправить код',
|
||||||
|
resend_code: 'Отправить еще раз',
|
||||||
|
confirm: 'Подтвердить',
|
||||||
|
login: 'Войти',
|
||||||
|
email_code_title: 'Подтвердите вход',
|
||||||
|
email_code_caption: 'Введите 6-значный код из письма.',
|
||||||
|
email_code_aria: 'Код подтверждения',
|
||||||
|
email_auth_disabled: 'Вход по email пока не настроен.',
|
||||||
|
email_required: 'Введите email',
|
||||||
|
email_invalid: 'Введите корректный email',
|
||||||
|
email_code_sending: 'Отправляю код...',
|
||||||
|
email_code_sent: 'Код отправлен на почту',
|
||||||
|
email_code_send_failed: 'Не удалось отправить код',
|
||||||
|
email_code_invalid: 'Неверный код',
|
||||||
|
email_code_expired: 'Код устарел',
|
||||||
|
email_rate_limited: 'Повторная отправка доступна через {seconds} сек.',
|
||||||
|
email_linked: 'Email привязан',
|
||||||
|
telegram_linked: 'Telegram привязан',
|
||||||
|
account_merge_conflict: 'Этот аккаунт уже связан с другими данными.',
|
||||||
telegram_auth: 'Telegram auth',
|
telegram_auth: 'Telegram auth',
|
||||||
telegram_auth_verifying: 'Проверяю вход...',
|
telegram_auth_verifying: 'Проверяю вход...',
|
||||||
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
|
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
|
||||||
@@ -145,6 +192,7 @@ const MOCK = (() => {
|
|||||||
extend_subscription: 'Renew subscription/Add days',
|
extend_subscription: 'Renew subscription/Add days',
|
||||||
payment_title: 'Subscription payment',
|
payment_title: 'Subscription payment',
|
||||||
payment_caption: 'Choose a period, payment method, and create a payment.',
|
payment_caption: 'Choose a period, payment method, and create a payment.',
|
||||||
|
close: 'Close',
|
||||||
close_payment: 'Close payment',
|
close_payment: 'Close payment',
|
||||||
payment_steps: 'Payment steps',
|
payment_steps: 'Payment steps',
|
||||||
step_plan: 'Period',
|
step_plan: 'Period',
|
||||||
@@ -160,6 +208,35 @@ const MOCK = (() => {
|
|||||||
check_payment: 'Check payment',
|
check_payment: 'Check payment',
|
||||||
choose_other_method: 'Choose another method',
|
choose_other_method: 'Choose another method',
|
||||||
support: 'Support',
|
support: 'Support',
|
||||||
|
account_title: 'Account',
|
||||||
|
account_caption: 'Sign-in methods',
|
||||||
|
email_label: 'Email',
|
||||||
|
telegram_label: 'Telegram',
|
||||||
|
linked: 'Linked',
|
||||||
|
not_linked: 'Not linked',
|
||||||
|
email_login_tab: 'Email',
|
||||||
|
telegram_login_tab: 'Telegram',
|
||||||
|
email_placeholder: 'mail@example.com',
|
||||||
|
code_placeholder: '000000',
|
||||||
|
send_code: 'Send code',
|
||||||
|
resend_code: 'Send again',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
login: 'Log in',
|
||||||
|
email_code_title: 'Confirm login',
|
||||||
|
email_code_caption: 'Enter the 6-digit code from the email.',
|
||||||
|
email_code_aria: 'Verification code',
|
||||||
|
email_auth_disabled: 'Email sign-in is not configured yet.',
|
||||||
|
email_required: 'Enter your email',
|
||||||
|
email_invalid: 'Enter a valid email address',
|
||||||
|
email_code_sending: 'Sending code...',
|
||||||
|
email_code_sent: 'Code sent to email',
|
||||||
|
email_code_send_failed: 'Could not send code',
|
||||||
|
email_code_invalid: 'Invalid code',
|
||||||
|
email_code_expired: 'Code expired',
|
||||||
|
email_rate_limited: 'Try again in {seconds} sec.',
|
||||||
|
email_linked: 'Email linked',
|
||||||
|
telegram_linked: 'Telegram linked',
|
||||||
|
account_merge_conflict: 'This account is already linked to different data.',
|
||||||
telegram_auth: 'Telegram auth',
|
telegram_auth: 'Telegram auth',
|
||||||
telegram_auth_verifying: 'Verifying login...',
|
telegram_auth_verifying: 'Verifying login...',
|
||||||
telegram_auth_failed: 'Could not verify Telegram login. Try again.',
|
telegram_auth_failed: 'Could not verify Telegram login. Try again.',
|
||||||
@@ -213,8 +290,14 @@ const MOCK = (() => {
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bindEmailLoginInput();
|
||||||
|
bindEmailCodeInput();
|
||||||
|
|
||||||
document.addEventListener('keydown', event => {
|
document.addEventListener('keydown', event => {
|
||||||
if (event.key === 'Escape' && state.paymentFlowOpen) {
|
if (event.key !== 'Escape') return;
|
||||||
|
if (state.emailLoginCodeModalOpen) {
|
||||||
|
closeEmailLoginCodeModal();
|
||||||
|
} else if (state.paymentFlowOpen) {
|
||||||
closePaymentFlow();
|
closePaymentFlow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -339,6 +422,34 @@ const MOCK = (() => {
|
|||||||
await finalizeTelegramAuth(user, 'auth_data');
|
await finalizeTelegramAuth(user, 'auth_data');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAuthMode(mode) {
|
||||||
|
state.authMode = mode === 'telegram' ? 'telegram' : 'email';
|
||||||
|
renderAuthMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthMode() {
|
||||||
|
const emailTab = document.getElementById('email-auth-tab');
|
||||||
|
const telegramTab = document.getElementById('telegram-auth-tab');
|
||||||
|
const emailPane = document.getElementById('email-login-pane');
|
||||||
|
const telegramPane = document.getElementById('telegram-login-pane');
|
||||||
|
const emailEnabled = CFG.emailAuthEnabled !== false;
|
||||||
|
if (!emailEnabled && state.authMode === 'email') {
|
||||||
|
state.authMode = 'telegram';
|
||||||
|
}
|
||||||
|
|
||||||
|
emailTab.classList.toggle('active', state.authMode === 'email');
|
||||||
|
telegramTab.classList.toggle('active', state.authMode === 'telegram');
|
||||||
|
emailPane.classList.toggle('hidden', state.authMode !== 'email');
|
||||||
|
telegramPane.classList.toggle('hidden', state.authMode !== 'telegram');
|
||||||
|
|
||||||
|
emailTab.disabled = !emailEnabled;
|
||||||
|
if (state.authMode === 'telegram') {
|
||||||
|
renderTelegramLoginWidget();
|
||||||
|
} else if (!emailEnabled) {
|
||||||
|
setAuthStatus(t('email_auth_disabled'), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderTelegramLoginWidget() {
|
function renderTelegramLoginWidget() {
|
||||||
const container = document.getElementById('telegram-login-widget');
|
const container = document.getElementById('telegram-login-widget');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -368,13 +479,250 @@ const MOCK = (() => {
|
|||||||
container.appendChild(script);
|
container.appendChild(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindEmailLoginInput() {
|
||||||
|
const input = document.getElementById('email-login-input');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
input.addEventListener('keydown', event => {
|
||||||
|
if (event.key !== 'Enter') return;
|
||||||
|
event.preventDefault();
|
||||||
|
requestEmailLoginCode();
|
||||||
|
});
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
if (input.getAttribute('aria-invalid') !== 'true') return;
|
||||||
|
input.removeAttribute('aria-invalid');
|
||||||
|
setAuthStatus('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEmailCodeInput() {
|
||||||
|
const input = document.getElementById('email-login-code-input');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
input.value = sanitizeCode(input.value);
|
||||||
|
updateEmailLoginCodeSlots();
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', updateEmailLoginCodeSlots);
|
||||||
|
input.addEventListener('blur', updateEmailLoginCodeSlots);
|
||||||
|
input.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (sanitizeCode(input.value).length === 6 && !state.emailLoginVerifying) {
|
||||||
|
verifyEmailLoginCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const field = input.closest('.otp-field');
|
||||||
|
if (field) {
|
||||||
|
field.addEventListener('click', () => input.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCode(value) {
|
||||||
|
return String(value || '').replace(/\D/g, '').slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEmail(value) {
|
||||||
|
const email = normalizeEmail(value);
|
||||||
|
return Boolean(email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValidEmailLoginInput() {
|
||||||
|
const input = document.getElementById('email-login-input');
|
||||||
|
const email = normalizeEmail(input && input.value);
|
||||||
|
if (!email || !isValidEmail(email)) {
|
||||||
|
if (input) {
|
||||||
|
input.setAttribute('aria-invalid', 'true');
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
setAuthStatus(t(email ? 'email_invalid' : 'email_required'), true);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.value = email;
|
||||||
|
input.removeAttribute('aria-invalid');
|
||||||
|
}
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEmailLoginCodeModal(email, message, isError = false) {
|
||||||
|
state.emailLoginEmail = email;
|
||||||
|
state.emailLoginCodeModalOpen = true;
|
||||||
|
const input = document.getElementById('email-login-code-input');
|
||||||
|
if (input) input.value = '';
|
||||||
|
renderEmailLoginCodeModal();
|
||||||
|
setEmailCodeStatus(message || t('email_code_sent'), isError);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const codeInput = document.getElementById('email-login-code-input');
|
||||||
|
if (state.emailLoginCodeModalOpen && codeInput) codeInput.focus();
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEmailLoginCodeModal() {
|
||||||
|
state.emailLoginCodeModalOpen = false;
|
||||||
|
const input = document.getElementById('email-login-code-input');
|
||||||
|
if (input) input.value = '';
|
||||||
|
setEmailCodeStatus('');
|
||||||
|
renderEmailLoginCodeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmailLoginCodeModal() {
|
||||||
|
const modal = document.getElementById('email-code-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
if (!state.emailLoginCodeModalOpen) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
syncModalLock();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!state.emailLoginCodeModalOpen) modal.classList.add('hidden');
|
||||||
|
}, 180);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = document.getElementById('email-code-address');
|
||||||
|
if (address) address.textContent = state.emailLoginEmail || '...';
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
syncModalLock();
|
||||||
|
applyI18n(modal);
|
||||||
|
updateEmailLoginCodeSlots();
|
||||||
|
window.requestAnimationFrame(() => modal.classList.add('show'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEmailLoginCodeSlots() {
|
||||||
|
const input = document.getElementById('email-login-code-input');
|
||||||
|
const value = sanitizeCode(input && input.value);
|
||||||
|
if (input && input.value !== value) input.value = value;
|
||||||
|
|
||||||
|
for (let index = 0; index < 6; index += 1) {
|
||||||
|
const slot = document.getElementById('email-code-slot-' + index);
|
||||||
|
if (!slot) continue;
|
||||||
|
const character = value[index] || '';
|
||||||
|
slot.textContent = character;
|
||||||
|
slot.classList.toggle('filled', Boolean(character));
|
||||||
|
slot.classList.toggle(
|
||||||
|
'active',
|
||||||
|
state.emailLoginCodeModalOpen
|
||||||
|
&& document.activeElement === input
|
||||||
|
&& index === Math.min(value.length, 5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyButton = document.getElementById('email-login-verify-btn');
|
||||||
|
if (verifyButton) {
|
||||||
|
verifyButton.disabled = value.length !== 6 || state.emailLoginVerifying || state.emailLoginPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resendButton = document.getElementById('email-login-resend-btn');
|
||||||
|
if (resendButton) {
|
||||||
|
resendButton.disabled = state.emailLoginPending || state.emailLoginResending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestEmailLoginCode() {
|
||||||
|
if (state.emailLoginPending) return;
|
||||||
|
if (CFG.emailAuthEnabled === false) {
|
||||||
|
setAuthStatus(t('email_auth_disabled'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const email = readValidEmailLoginInput();
|
||||||
|
if (!email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.emailLoginPending = true;
|
||||||
|
setButtonBusy('email-login-send-btn', true);
|
||||||
|
openEmailLoginCodeModal(email, t('email_code_sending'));
|
||||||
|
setAuthStatus('');
|
||||||
|
try {
|
||||||
|
const data = await publicApi('/auth/email/request', {
|
||||||
|
email,
|
||||||
|
language: getLanguage()
|
||||||
|
});
|
||||||
|
if (!data.ok) throw data;
|
||||||
|
setEmailCodeStatus(t('email_code_sent'));
|
||||||
|
} catch (e) {
|
||||||
|
const message = emailErrorMessage(e, 'email_code_send_failed');
|
||||||
|
if (e && e.error === 'invalid_email') {
|
||||||
|
closeEmailLoginCodeModal();
|
||||||
|
const input = document.getElementById('email-login-input');
|
||||||
|
if (input) input.setAttribute('aria-invalid', 'true');
|
||||||
|
setAuthStatus(message, true);
|
||||||
|
} else {
|
||||||
|
setEmailCodeStatus(message, true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.emailLoginPending = false;
|
||||||
|
setButtonBusy('email-login-send-btn', false);
|
||||||
|
updateEmailLoginCodeSlots();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendEmailLoginCode() {
|
||||||
|
if (state.emailLoginPending || state.emailLoginResending) return;
|
||||||
|
const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value);
|
||||||
|
if (!email || !isValidEmail(email)) {
|
||||||
|
setEmailCodeStatus(t(email ? 'email_invalid' : 'email_required'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.emailLoginResending = true;
|
||||||
|
updateEmailLoginCodeSlots();
|
||||||
|
setEmailCodeStatus(t('email_code_sending'));
|
||||||
|
try {
|
||||||
|
const data = await publicApi('/auth/email/request', {
|
||||||
|
email,
|
||||||
|
language: getLanguage()
|
||||||
|
});
|
||||||
|
if (!data.ok) throw data;
|
||||||
|
state.emailLoginEmail = email;
|
||||||
|
const input = document.getElementById('email-login-code-input');
|
||||||
|
if (input) input.value = '';
|
||||||
|
setEmailCodeStatus(t('email_code_sent'));
|
||||||
|
} catch (e) {
|
||||||
|
setEmailCodeStatus(emailErrorMessage(e, 'email_code_send_failed'), true);
|
||||||
|
} finally {
|
||||||
|
state.emailLoginResending = false;
|
||||||
|
updateEmailLoginCodeSlots();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyEmailLoginCode() {
|
||||||
|
const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value);
|
||||||
|
const input = document.getElementById('email-login-code-input');
|
||||||
|
const code = sanitizeCode(input && input.value);
|
||||||
|
if (!email || code.length !== 6) {
|
||||||
|
setEmailCodeStatus(t('email_code_invalid'), true);
|
||||||
|
updateEmailLoginCodeSlots();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.emailLoginVerifying = true;
|
||||||
|
updateEmailLoginCodeSlots();
|
||||||
|
setEmailCodeStatus(t('telegram_auth_verifying'));
|
||||||
|
try {
|
||||||
|
const data = await publicApi('/auth/email/verify', {email, code});
|
||||||
|
if (!data.ok || !data.token) throw data;
|
||||||
|
setToken(data.token);
|
||||||
|
closeEmailLoginCodeModal();
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
clearToken();
|
||||||
|
setEmailCodeStatus(emailErrorMessage(e, 'email_code_invalid'), true);
|
||||||
|
} finally {
|
||||||
|
state.emailLoginVerifying = false;
|
||||||
|
updateEmailLoginCodeSlots();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startExternalAuth(options = {}) {
|
function startExternalAuth(options = {}) {
|
||||||
const resetStatus = options.resetStatus !== false;
|
const resetStatus = options.resetStatus !== false;
|
||||||
showLogin();
|
showLogin();
|
||||||
if (resetStatus) {
|
if (resetStatus) {
|
||||||
setAuthStatus('');
|
setAuthStatus('');
|
||||||
}
|
}
|
||||||
renderTelegramLoginWidget();
|
renderAuthMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@@ -390,6 +738,7 @@ const MOCK = (() => {
|
|||||||
state.selectedMethod = null;
|
state.selectedMethod = null;
|
||||||
state.payment = null;
|
state.payment = null;
|
||||||
state.paymentStep = 'plan';
|
state.paymentStep = 'plan';
|
||||||
|
state.telegramLinkRendered = false;
|
||||||
render();
|
render();
|
||||||
showApp();
|
showApp();
|
||||||
}
|
}
|
||||||
@@ -401,6 +750,7 @@ const MOCK = (() => {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
renderSubscription(state.data.subscription);
|
renderSubscription(state.data.subscription);
|
||||||
|
renderAccount(state.data.user || {});
|
||||||
renderPaymentFlow();
|
renderPaymentFlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +764,168 @@ const MOCK = (() => {
|
|||||||
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
|
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAccount(user) {
|
||||||
|
const emailLinked = Boolean(user.email && user.email_verified);
|
||||||
|
const telegramLinked = Boolean(user.telegram_linked);
|
||||||
|
document.getElementById('account-email').textContent = emailLinked ? user.email : t('not_linked');
|
||||||
|
document.getElementById('account-telegram').textContent = telegramLinked
|
||||||
|
? (user.telegram_id ? String(user.telegram_id) : t('linked'))
|
||||||
|
: t('not_linked');
|
||||||
|
|
||||||
|
const emailBox = document.getElementById('email-link-box');
|
||||||
|
const telegramBox = document.getElementById('telegram-link-box');
|
||||||
|
emailBox.classList.toggle('hidden', emailLinked || !state.data.settings.email_auth_enabled);
|
||||||
|
telegramBox.classList.toggle('hidden', telegramLinked);
|
||||||
|
|
||||||
|
if (!telegramLinked) {
|
||||||
|
renderTelegramLinkWidget();
|
||||||
|
} else {
|
||||||
|
state.telegramLinkRendered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestEmailLinkCode() {
|
||||||
|
const input = document.getElementById('email-link-input');
|
||||||
|
const email = normalizeEmail(input.value);
|
||||||
|
if (!email) {
|
||||||
|
showToast(t('email_code_send_failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.emailLinkPending = true;
|
||||||
|
setButtonBusy('email-link-send-btn', true);
|
||||||
|
try {
|
||||||
|
const data = await api('/account/email/request', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({email})
|
||||||
|
});
|
||||||
|
if (!data.ok) throw data;
|
||||||
|
if (data.already_linked) {
|
||||||
|
showToast(t('email_linked'));
|
||||||
|
await loadData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.emailLinkEmail = email;
|
||||||
|
document.getElementById('email-link-code-row').classList.remove('hidden');
|
||||||
|
showToast(t('email_code_sent'));
|
||||||
|
} catch (e) {
|
||||||
|
showToast(emailErrorMessage(e, 'email_code_send_failed'));
|
||||||
|
} finally {
|
||||||
|
state.emailLinkPending = false;
|
||||||
|
setButtonBusy('email-link-send-btn', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyEmailLinkCode() {
|
||||||
|
const email = state.emailLinkEmail || normalizeEmail(document.getElementById('email-link-input').value);
|
||||||
|
const code = document.getElementById('email-link-code-input').value;
|
||||||
|
setButtonBusy('email-link-verify-btn', true);
|
||||||
|
try {
|
||||||
|
const data = await api('/account/email/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({email, code})
|
||||||
|
});
|
||||||
|
if (!data.ok) throw data;
|
||||||
|
if (data.token) setToken(data.token);
|
||||||
|
showToast(t('email_linked'));
|
||||||
|
state.emailLinkEmail = '';
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(emailErrorMessage(e, 'email_code_invalid'));
|
||||||
|
} finally {
|
||||||
|
setButtonBusy('email-link-verify-btn', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTelegramLinkWidget() {
|
||||||
|
const container = document.getElementById('telegram-link-widget');
|
||||||
|
if (!container || state.telegramLinkRendered) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
const botUsername = String(CFG.telegramLoginBotUsername || '').trim();
|
||||||
|
if (!botUsername) {
|
||||||
|
setTelegramLinkStatus(t('telegram_auth_unavailable'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onTelegramLinkAuth = async function(user) {
|
||||||
|
await linkTelegramAccount(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = TELEGRAM_LOGIN_WIDGET_URL;
|
||||||
|
script.setAttribute('data-telegram-login', botUsername);
|
||||||
|
script.setAttribute('data-size', 'large');
|
||||||
|
script.setAttribute('data-userpic', 'false');
|
||||||
|
script.setAttribute('data-request-access', 'write');
|
||||||
|
script.setAttribute('data-onauth', 'onTelegramLinkAuth(user)');
|
||||||
|
script.onerror = () => setTelegramLinkStatus(t('telegram_auth_unavailable'), true);
|
||||||
|
container.appendChild(script);
|
||||||
|
state.telegramLinkRendered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkTelegramAccount(user) {
|
||||||
|
if (!user || state.telegramLinkInProgress) return;
|
||||||
|
state.telegramLinkInProgress = true;
|
||||||
|
setTelegramLinkStatus(t('telegram_auth_verifying'));
|
||||||
|
try {
|
||||||
|
const data = await api('/account/telegram/link', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({auth_data: user})
|
||||||
|
});
|
||||||
|
if (!data.ok) throw data;
|
||||||
|
await finishTelegramLink(data, user.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (await refreshLinkedTelegramAfterError()) {
|
||||||
|
setTelegramLinkStatus('');
|
||||||
|
try {
|
||||||
|
showToast(t('telegram_linked'));
|
||||||
|
} catch (toastError) {
|
||||||
|
console.warn('Telegram link success toast failed', toastError);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTelegramLinkStatus(emailErrorMessage(e, 'telegram_auth_failed'), true);
|
||||||
|
} finally {
|
||||||
|
state.telegramLinkInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishTelegramLink(data, fallbackTelegramId) {
|
||||||
|
if (data.token) setToken(data.token);
|
||||||
|
markTelegramLinked(data.telegram_id || fallbackTelegramId);
|
||||||
|
setTelegramLinkStatus('');
|
||||||
|
state.telegramLinkRendered = false;
|
||||||
|
try {
|
||||||
|
showToast(t('telegram_linked'));
|
||||||
|
} catch (toastError) {
|
||||||
|
console.warn('Telegram link success toast failed', toastError);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.warn('Telegram account linked, but data refresh failed', refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTelegramLinked(telegramId) {
|
||||||
|
if (!state.data || !state.data.user) return;
|
||||||
|
state.data.user.telegram_linked = true;
|
||||||
|
if (telegramId) {
|
||||||
|
state.data.user.telegram_id = Number(telegramId);
|
||||||
|
}
|
||||||
|
renderAccount(state.data.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLinkedTelegramAfterError() {
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
return Boolean(state.data && state.data.user && state.data.user.telegram_linked);
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.warn('Telegram link status refresh failed', refreshError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function togglePaymentFlow() {
|
function togglePaymentFlow() {
|
||||||
if (state.paymentFlowOpen) {
|
if (state.paymentFlowOpen) {
|
||||||
closePaymentFlow();
|
closePaymentFlow();
|
||||||
@@ -438,7 +950,7 @@ const MOCK = (() => {
|
|||||||
const modal = document.getElementById('payment-modal');
|
const modal = document.getElementById('payment-modal');
|
||||||
if (!state.paymentFlowOpen) {
|
if (!state.paymentFlowOpen) {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
document.body.classList.remove('modal-open');
|
syncModalLock();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (!state.paymentFlowOpen) modal.classList.add('hidden');
|
if (!state.paymentFlowOpen) modal.classList.add('hidden');
|
||||||
}, 180);
|
}, 180);
|
||||||
@@ -446,7 +958,7 @@ const MOCK = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
document.body.classList.add('modal-open');
|
syncModalLock();
|
||||||
window.requestAnimationFrame(() => modal.classList.add('show'));
|
window.requestAnimationFrame(() => modal.classList.add('show'));
|
||||||
applyI18n(modal);
|
applyI18n(modal);
|
||||||
renderStepState();
|
renderStepState();
|
||||||
@@ -455,6 +967,13 @@ const MOCK = (() => {
|
|||||||
renderPaymentResult();
|
renderPaymentResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncModalLock() {
|
||||||
|
document.body.classList.toggle(
|
||||||
|
'modal-open',
|
||||||
|
Boolean(state.paymentFlowOpen || state.emailLoginCodeModalOpen)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderStepState() {
|
function renderStepState() {
|
||||||
const order = ['plan', 'method', 'result'];
|
const order = ['plan', 'method', 'result'];
|
||||||
order.forEach((step, index) => {
|
order.forEach((step, index) => {
|
||||||
@@ -694,11 +1213,42 @@ const MOCK = (() => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publicApi(path, payload = {}) {
|
||||||
|
if (MOCK) {
|
||||||
|
return mockApi(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const response = await fetch(CFG.apiBase + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
async function mockApi(path, options = {}) {
|
async function mockApi(path, options = {}) {
|
||||||
await new Promise(resolve => window.setTimeout(resolve, 120));
|
await new Promise(resolve => window.setTimeout(resolve, 120));
|
||||||
if (path === '/me') {
|
if (path === '/me') {
|
||||||
return JSON.parse(JSON.stringify(MOCK.data));
|
return JSON.parse(JSON.stringify(MOCK.data));
|
||||||
}
|
}
|
||||||
|
if (path === '/auth/email/request' || path === '/account/email/request') {
|
||||||
|
return {ok: true};
|
||||||
|
}
|
||||||
|
if (path === '/auth/email/verify') {
|
||||||
|
return {ok: true, token: 'local-preview'};
|
||||||
|
}
|
||||||
|
if (path === '/account/email/verify') {
|
||||||
|
MOCK.data.user.email = 'preview@example.com';
|
||||||
|
MOCK.data.user.email_verified = true;
|
||||||
|
return {ok: true, token: 'local-preview'};
|
||||||
|
}
|
||||||
|
if (path === '/account/telegram/link') {
|
||||||
|
MOCK.data.user.telegram_linked = true;
|
||||||
|
MOCK.data.user.telegram_id = 100200300;
|
||||||
|
return {ok: true, token: 'local-preview'};
|
||||||
|
}
|
||||||
if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') {
|
if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -728,9 +1278,33 @@ const MOCK = (() => {
|
|||||||
localStorage.removeItem('rw_webapp_token');
|
localStorage.removeItem('rw_webapp_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailErrorMessage(error, fallbackKey) {
|
||||||
|
const code = error && error.error;
|
||||||
|
if (code === 'rate_limited') {
|
||||||
|
return t('email_rate_limited', {seconds: error.retry_after || 60});
|
||||||
|
}
|
||||||
|
if (code === 'invalid_email') return t('email_invalid');
|
||||||
|
if (code === 'expired_code') return t('email_code_expired');
|
||||||
|
if (code === 'invalid_code' || code === 'too_many_attempts') return t('email_code_invalid');
|
||||||
|
if (code === 'email_auth_not_configured') return t('email_auth_disabled');
|
||||||
|
if (code === 'account_merge_conflict') return t('account_merge_conflict');
|
||||||
|
if (code === 'invalid_auth') return t('telegram_auth_failed');
|
||||||
|
return t(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonBusy(id, busy) {
|
||||||
|
const button = document.getElementById(id);
|
||||||
|
if (button) button.disabled = Boolean(busy);
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearToken();
|
clearToken();
|
||||||
closePaymentFlow();
|
closePaymentFlow();
|
||||||
|
closeEmailLoginCodeModal();
|
||||||
startExternalAuth();
|
startExternalAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,6 +1341,35 @@ const MOCK = (() => {
|
|||||||
status.classList.toggle('error', Boolean(isError));
|
status.classList.toggle('error', Boolean(isError));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setEmailCodeStatus(message, isError = false) {
|
||||||
|
const status = document.getElementById('email-code-status');
|
||||||
|
if (!status) return;
|
||||||
|
if (!message) {
|
||||||
|
status.textContent = '';
|
||||||
|
status.classList.add('hidden');
|
||||||
|
status.classList.remove('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = message;
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
status.classList.toggle('error', Boolean(isError));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTelegramLinkStatus(message, isError = false) {
|
||||||
|
const status = document.getElementById('telegram-link-status');
|
||||||
|
if (!status) return;
|
||||||
|
if (!message) {
|
||||||
|
status.textContent = '';
|
||||||
|
status.classList.add('hidden');
|
||||||
|
status.classList.remove('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = message;
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
status.classList.toggle('error', Boolean(isError));
|
||||||
|
}
|
||||||
|
|
||||||
function showToast(message) {
|
function showToast(message) {
|
||||||
if (tg && tg.showAlert) {
|
if (tg && tg.showAlert) {
|
||||||
tg.showAlert(message);
|
tg.showAlert(message);
|
||||||
@@ -826,6 +1429,9 @@ const MOCK = (() => {
|
|||||||
root.querySelectorAll('[data-aria-i18n]').forEach(node => {
|
root.querySelectorAll('[data-aria-i18n]').forEach(node => {
|
||||||
node.setAttribute('aria-label', t(node.dataset.ariaI18n));
|
node.setAttribute('aria-label', t(node.dataset.ariaI18n));
|
||||||
});
|
});
|
||||||
|
root.querySelectorAll('[data-placeholder-i18n]').forEach(node => {
|
||||||
|
node.setAttribute('placeholder', t(node.dataset.placeholderI18n));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLegalLinks(root = document) {
|
function applyLegalLinks(root = document) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
|
|
||||||
router = Router(name="admin_logs_router")
|
router = Router(name="admin_logs_router")
|
||||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||||
|
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
|
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
|
||||||
@@ -225,12 +226,14 @@ async def process_user_id_for_logs_handler(message: types.Message,
|
|||||||
input_text = message.text.strip() if message.text else ""
|
input_text = message.text.strip() if message.text else ""
|
||||||
user_model_for_logs: Optional[User] = None
|
user_model_for_logs: Optional[User] = None
|
||||||
|
|
||||||
if input_text.isdigit():
|
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
|
||||||
try:
|
try:
|
||||||
user_model_for_logs = await user_dal.get_user_by_id(
|
user_model_for_logs = await user_dal.get_user_by_id(
|
||||||
session, int(input_text))
|
session, int(input_text))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
elif EMAIL_REGEX.match(input_text):
|
||||||
|
user_model_for_logs = await user_dal.get_user_by_email(session, input_text)
|
||||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||||
user_model_for_logs = await user_dal.get_user_by_username(
|
user_model_for_logs = await user_dal.get_user_by_username(
|
||||||
session, input_text[1:])
|
session, input_text[1:])
|
||||||
@@ -245,7 +248,7 @@ async def process_user_id_for_logs_handler(message: types.Message,
|
|||||||
target_user_id = user_model_for_logs.user_id
|
target_user_id = user_model_for_logs.user_id
|
||||||
user_display_name = user_model_for_logs.first_name or (
|
user_display_name = user_model_for_logs.first_name or (
|
||||||
f"@{user_model_for_logs.username}"
|
f"@{user_model_for_logs.username}"
|
||||||
if user_model_for_logs.username else f"ID {target_user_id}")
|
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
|
||||||
|
|
||||||
logs_models = await message_log_dal.get_user_message_logs(
|
logs_models = await message_log_dal.get_user_message_logs(
|
||||||
session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
|
session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
|
||||||
@@ -292,7 +295,7 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
user_display_name = user_model_for_logs.first_name or (
|
user_display_name = user_model_for_logs.first_name or (
|
||||||
f"@{user_model_for_logs.username}"
|
f"@{user_model_for_logs.username}"
|
||||||
if user_model_for_logs.username else f"ID {target_user_id}")
|
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
|
||||||
|
|
||||||
logs_models = await message_log_dal.get_user_message_logs(
|
logs_models = await message_log_dal.get_user_message_logs(
|
||||||
session, target_user_id, settings.LOGS_PAGE_SIZE,
|
session, target_user_id, settings.LOGS_PAGE_SIZE,
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ async def perform_sync(
|
|||||||
"shortUuid"
|
"shortUuid"
|
||||||
)
|
)
|
||||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||||
|
email_from_panel = (panel_user_dict.get("email") or "").strip().lower() or None
|
||||||
|
|
||||||
if not panel_uuid:
|
if not panel_uuid:
|
||||||
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
||||||
@@ -111,6 +112,10 @@ async def perform_sync(
|
|||||||
|
|
||||||
# First, try to find by telegram ID if available
|
# First, try to find by telegram ID if available
|
||||||
if telegram_id_from_panel:
|
if telegram_id_from_panel:
|
||||||
|
existing_user = await user_dal.get_user_by_telegram_id(
|
||||||
|
session, telegram_id_from_panel
|
||||||
|
)
|
||||||
|
if not existing_user:
|
||||||
existing_user = await user_dal.get_user_by_id(
|
existing_user = await user_dal.get_user_by_id(
|
||||||
session, telegram_id_from_panel
|
session, telegram_id_from_panel
|
||||||
)
|
)
|
||||||
@@ -119,6 +124,13 @@ async def perform_sync(
|
|||||||
f"Found user by telegramId {telegram_id_from_panel}"
|
f"Found user by telegramId {telegram_id_from_panel}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not existing_user and email_from_panel:
|
||||||
|
existing_user = await user_dal.get_user_by_email(
|
||||||
|
session, email_from_panel
|
||||||
|
)
|
||||||
|
if existing_user:
|
||||||
|
logging.debug(f"Found user by email {email_from_panel}")
|
||||||
|
|
||||||
# If not found by telegram ID, try to find by panel UUID
|
# If not found by telegram ID, try to find by panel UUID
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
existing_user = await user_dal.get_user_by_panel_uuid(
|
existing_user = await user_dal.get_user_by_panel_uuid(
|
||||||
@@ -144,6 +156,8 @@ async def perform_sync(
|
|||||||
try:
|
try:
|
||||||
user_data = {
|
user_data = {
|
||||||
"user_id": telegram_id_from_panel,
|
"user_id": telegram_id_from_panel,
|
||||||
|
"telegram_id": telegram_id_from_panel,
|
||||||
|
"email": email_from_panel,
|
||||||
"username": None, # Username will be updated when user interacts with bot
|
"username": None, # Username will be updated when user interacts with bot
|
||||||
"first_name": None, # Panel doesn't provide this info
|
"first_name": None, # Panel doesn't provide this info
|
||||||
"last_name": None, # Panel doesn't provide this info
|
"last_name": None, # Panel doesn't provide this info
|
||||||
@@ -172,6 +186,28 @@ async def perform_sync(
|
|||||||
f"Error creating user {telegram_id_from_panel}: {e_create}"
|
f"Error creating user {telegram_id_from_panel}: {e_create}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
elif email_from_panel:
|
||||||
|
try:
|
||||||
|
new_user, was_created = await user_dal.create_email_user(
|
||||||
|
session,
|
||||||
|
email=email_from_panel,
|
||||||
|
language_code="ru",
|
||||||
|
)
|
||||||
|
new_user.panel_user_uuid = panel_uuid
|
||||||
|
if was_created:
|
||||||
|
users_created += 1
|
||||||
|
logging.info(
|
||||||
|
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}"
|
||||||
|
)
|
||||||
|
existing_user = new_user
|
||||||
|
except Exception as e_create_email:
|
||||||
|
sync_errors.append(
|
||||||
|
f"Error creating email user {email_from_panel}: {str(e_create_email)}"
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
f"Error creating email user {email_from_panel}: {e_create_email}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
logging.debug(
|
logging.debug(
|
||||||
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
|
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
|
||||||
@@ -193,6 +229,17 @@ async def perform_sync(
|
|||||||
logging.info(
|
logging.info(
|
||||||
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
|
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
|
||||||
)
|
)
|
||||||
|
if email_from_panel and existing_user.email != email_from_panel:
|
||||||
|
existing_user.email = email_from_panel
|
||||||
|
if not existing_user.email_verified_at:
|
||||||
|
existing_user.email_verified_at = datetime.now(timezone.utc)
|
||||||
|
user_was_updated = True
|
||||||
|
if (
|
||||||
|
telegram_id_from_panel
|
||||||
|
and existing_user.telegram_id != telegram_id_from_panel
|
||||||
|
):
|
||||||
|
existing_user.telegram_id = telegram_id_from_panel
|
||||||
|
user_was_updated = True
|
||||||
|
|
||||||
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
|
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
|
||||||
if (
|
if (
|
||||||
@@ -206,11 +253,12 @@ async def perform_sync(
|
|||||||
try:
|
try:
|
||||||
if panel_uuid and existing_user:
|
if panel_uuid and existing_user:
|
||||||
description_text = "\n".join(
|
description_text = "\n".join(
|
||||||
[
|
line for line in [
|
||||||
|
existing_user.email or "",
|
||||||
existing_user.username or "",
|
existing_user.username or "",
|
||||||
existing_user.first_name or "",
|
existing_user.first_name or "",
|
||||||
existing_user.last_name or "",
|
existing_user.last_name or "",
|
||||||
]
|
] if line
|
||||||
)
|
)
|
||||||
# Update description only when it differs from the current one on panel
|
# Update description only when it differs from the current one on panel
|
||||||
current_panel_description = (
|
current_panel_description = (
|
||||||
@@ -222,7 +270,11 @@ async def perform_sync(
|
|||||||
and desired_description != current_panel_description
|
and desired_description != current_panel_description
|
||||||
):
|
):
|
||||||
await panel_service.update_user_details_on_panel(
|
await panel_service.update_user_details_on_panel(
|
||||||
panel_uuid, {"description": description_text}
|
panel_uuid, {
|
||||||
|
"description": description_text,
|
||||||
|
**({"email": existing_user.email} if existing_user.email else {}),
|
||||||
|
**({"telegramId": existing_user.telegram_id} if existing_user.telegram_id else {}),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
except Exception as e_desc:
|
except Exception as e_desc:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from bot.utils.telegram_markup import (
|
|||||||
|
|
||||||
router = Router(name="admin_user_management_router")
|
router = Router(name="admin_user_management_router")
|
||||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||||
|
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
|
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
|
||||||
@@ -53,6 +54,24 @@ def _format_used_with_period(get_text: Callable[..., str], used_display: str, pe
|
|||||||
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
|
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_user_by_admin_input(
|
||||||
|
session: AsyncSession,
|
||||||
|
input_text: str,
|
||||||
|
) -> Optional[User]:
|
||||||
|
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
|
||||||
|
try:
|
||||||
|
return await user_dal.get_user_by_id(session, int(input_text))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if EMAIL_REGEX.match(input_text):
|
||||||
|
return await user_dal.get_user_by_email(session, input_text)
|
||||||
|
if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||||
|
return await user_dal.get_user_by_username(session, input_text[1:])
|
||||||
|
if USERNAME_REGEX.match(input_text):
|
||||||
|
return await user_dal.get_user_by_username(session, input_text)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def users_list_handler(callback: types.CallbackQuery,
|
async def users_list_handler(callback: types.CallbackQuery,
|
||||||
i18n_data: dict, settings: Settings,
|
i18n_data: dict, settings: Settings,
|
||||||
session: AsyncSession, page: int = 0):
|
session: AsyncSession, page: int = 0):
|
||||||
@@ -250,6 +269,10 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}")
|
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}")
|
||||||
card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}")
|
card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}")
|
||||||
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
|
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
|
||||||
|
if user.email:
|
||||||
|
card_parts.append(f"{_('admin_user_email_label')} {hcode(user.email)}")
|
||||||
|
if user.telegram_id and int(user.telegram_id) != int(user.user_id):
|
||||||
|
card_parts.append(f"{_('admin_user_telegram_id_label')} {hcode(str(user.telegram_id))}")
|
||||||
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}")
|
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}")
|
||||||
card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
|
card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
|
||||||
|
|
||||||
@@ -361,18 +384,7 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
input_text = message.text.strip() if message.text else ""
|
input_text = message.text.strip() if message.text else ""
|
||||||
user_model: Optional[User] = None
|
user_model = await _find_user_by_admin_input(session, input_text)
|
||||||
|
|
||||||
# Try to find user by ID or username
|
|
||||||
if input_text.isdigit():
|
|
||||||
try:
|
|
||||||
user_model = await user_dal.get_user_by_id(session, int(input_text))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
|
||||||
user_model = await user_dal.get_user_by_username(session, input_text[1:])
|
|
||||||
elif USERNAME_REGEX.match(input_text):
|
|
||||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
|
||||||
|
|
||||||
if not user_model:
|
if not user_model:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
@@ -1177,18 +1189,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
input_text = message.text.strip() if message.text else ""
|
input_text = message.text.strip() if message.text else ""
|
||||||
user_model: Optional[User] = None
|
user_model = await _find_user_by_admin_input(session, input_text)
|
||||||
|
|
||||||
# Try to find user by ID or username
|
|
||||||
if input_text.isdigit():
|
|
||||||
try:
|
|
||||||
user_model = await user_dal.get_user_by_id(session, int(input_text))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
|
||||||
user_model = await user_dal.get_user_by_username(session, input_text[1:])
|
|
||||||
elif USERNAME_REGEX.match(input_text):
|
|
||||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
|
||||||
|
|
||||||
if not user_model:
|
if not user_model:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
@@ -1244,18 +1245,7 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
input_text = message.text.strip() if message.text else ""
|
input_text = message.text.strip() if message.text else ""
|
||||||
user_model: Optional[User] = None
|
user_model = await _find_user_by_admin_input(session, input_text)
|
||||||
|
|
||||||
# Try to find user by ID or username
|
|
||||||
if input_text.isdigit():
|
|
||||||
try:
|
|
||||||
user_model = await user_dal.get_user_by_id(session, int(input_text))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
|
||||||
user_model = await user_dal.get_user_by_username(session, input_text[1:])
|
|
||||||
elif USERNAME_REGEX.match(input_text):
|
|
||||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
|
||||||
|
|
||||||
if not user_model:
|
if not user_model:
|
||||||
await message.answer(_(
|
await message.answer(_(
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
|||||||
user_display_parts.append(user_row.first_name)
|
user_display_parts.append(user_row.first_name)
|
||||||
if user_row.username:
|
if user_row.username:
|
||||||
user_display_parts.append(f"(@{user_row.username})")
|
user_display_parts.append(f"(@{user_row.username})")
|
||||||
|
elif user_row.email:
|
||||||
|
user_display_parts.append(f"({user_row.email})")
|
||||||
if not user_display_parts:
|
if not user_display_parts:
|
||||||
user_display_parts.append(f"ID: {user_row.user_id}")
|
user_display_parts.append(f"ID: {user_row.user_id}")
|
||||||
|
|
||||||
@@ -321,6 +323,8 @@ def get_users_list_keyboard(users: List[User], current_page: int,
|
|||||||
user_display_parts = []
|
user_display_parts = []
|
||||||
if user.username:
|
if user.username:
|
||||||
user_display_parts.append(f"@{user.username}")
|
user_display_parts.append(f"@{user.username}")
|
||||||
|
elif user.email:
|
||||||
|
user_display_parts.append(user.email)
|
||||||
user_display_parts.append(f"ID: {user.user_id}")
|
user_display_parts.append(f"ID: {user.user_id}")
|
||||||
if user.first_name:
|
if user.first_name:
|
||||||
user_display_parts.append(f"- {user.first_name}")
|
user_display_parts.append(f"- {user.first_name}")
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
if session and tg_user:
|
if session and tg_user:
|
||||||
try:
|
try:
|
||||||
|
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
|
||||||
|
if not db_user:
|
||||||
db_user = await user_dal.get_user_by_id(session, tg_user.id)
|
db_user = await user_dal.get_user_by_id(session, tg_user.id)
|
||||||
if db_user:
|
if db_user:
|
||||||
update_payload: Dict[str, Any] = {}
|
update_payload: Dict[str, Any] = {}
|
||||||
@@ -29,6 +31,8 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
sanitized_first_name = sanitize_display_name(tg_user.first_name)
|
sanitized_first_name = sanitize_display_name(tg_user.first_name)
|
||||||
sanitized_last_name = sanitize_display_name(tg_user.last_name)
|
sanitized_last_name = sanitize_display_name(tg_user.last_name)
|
||||||
|
|
||||||
|
if db_user.telegram_id != tg_user.id:
|
||||||
|
update_payload["telegram_id"] = tg_user.id
|
||||||
if db_user.username != sanitized_username:
|
if db_user.username != sanitized_username:
|
||||||
update_payload["username"] = sanitized_username
|
update_payload["username"] = sanitized_username
|
||||||
if db_user.first_name != sanitized_first_name:
|
if db_user.first_name != sanitized_first_name:
|
||||||
@@ -37,7 +41,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
update_payload["last_name"] = sanitized_last_name
|
update_payload["last_name"] = sanitized_last_name
|
||||||
|
|
||||||
if update_payload:
|
if update_payload:
|
||||||
await user_dal.update_user(session, tg_user.id, update_payload)
|
await user_dal.update_user(session, db_user.user_id, update_payload)
|
||||||
logging.info(
|
logging.info(
|
||||||
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}"
|
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}"
|
||||||
)
|
)
|
||||||
@@ -47,13 +51,20 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
panel_service = data.get("panel_service")
|
panel_service = data.get("panel_service")
|
||||||
if panel_service and db_user.panel_user_uuid:
|
if panel_service and db_user.panel_user_uuid:
|
||||||
description_text = "\n".join([
|
description_text = "\n".join([
|
||||||
|
db_user.email or "",
|
||||||
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
|
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
|
||||||
sanitized_first_name or "",
|
sanitized_first_name or "",
|
||||||
sanitized_last_name or "",
|
sanitized_last_name or "",
|
||||||
]).strip()
|
]).strip()
|
||||||
|
panel_payload = {
|
||||||
|
"description": description_text,
|
||||||
|
"telegramId": tg_user.id,
|
||||||
|
}
|
||||||
|
if db_user.email:
|
||||||
|
panel_payload["email"] = db_user.email
|
||||||
await panel_service.update_user_details_on_panel(
|
await panel_service.update_user_details_on_panel(
|
||||||
db_user.panel_user_uuid,
|
db_user.panel_user_uuid,
|
||||||
{"description": description_text},
|
panel_payload,
|
||||||
)
|
)
|
||||||
except Exception as e_upd_desc:
|
except Exception as e_upd_desc:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.models import EmailVerificationCode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SmtpAttempt:
|
||||||
|
port: int
|
||||||
|
use_ssl: bool
|
||||||
|
starttls: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmailCodeRequestResult:
|
||||||
|
ok: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
retry_after: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmailCodeVerifyResult:
|
||||||
|
ok: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_email(value: str) -> str:
|
||||||
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_email(value: str) -> bool:
|
||||||
|
email = normalize_email(value)
|
||||||
|
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAuthService:
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def _smtp_attempts(self) -> list[SmtpAttempt]:
|
||||||
|
attempts: list[SmtpAttempt] = []
|
||||||
|
primary_port = int(self.settings.SMTP_PORT)
|
||||||
|
|
||||||
|
for port in self.settings.smtp_ports_to_try:
|
||||||
|
if port == primary_port:
|
||||||
|
use_ssl = bool(self.settings.SMTP_USE_SSL or port == 465)
|
||||||
|
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
|
||||||
|
else:
|
||||||
|
use_ssl = port == 465
|
||||||
|
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
|
||||||
|
attempts.append(SmtpAttempt(port=port, use_ssl=use_ssl, starttls=starttls))
|
||||||
|
|
||||||
|
return attempts or [
|
||||||
|
SmtpAttempt(
|
||||||
|
port=primary_port,
|
||||||
|
use_ssl=bool(self.settings.SMTP_USE_SSL or primary_port == 465),
|
||||||
|
starttls=bool(
|
||||||
|
self.settings.SMTP_STARTTLS
|
||||||
|
and not self.settings.SMTP_USE_SSL
|
||||||
|
and primary_port != 465
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _hash_code(self, email: str, purpose: str, code: str) -> str:
|
||||||
|
secret = hmac.new(
|
||||||
|
self.settings.BOT_TOKEN.encode("utf-8"),
|
||||||
|
b"remnawave-tg-shop-email-code",
|
||||||
|
hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
payload = f"{purpose}:{email}:{code}".encode("utf-8")
|
||||||
|
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
async def request_code(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
purpose: str,
|
||||||
|
language_code: str,
|
||||||
|
target_user_id: Optional[int] = None,
|
||||||
|
) -> EmailCodeRequestResult:
|
||||||
|
normalized_email = normalize_email(email)
|
||||||
|
if not self.settings.email_auth_configured:
|
||||||
|
return EmailCodeRequestResult(ok=False, error="email_auth_not_configured")
|
||||||
|
if not is_valid_email(normalized_email):
|
||||||
|
return EmailCodeRequestResult(ok=False, error="invalid_email")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
latest_code = await self._get_latest_code(
|
||||||
|
session,
|
||||||
|
email=normalized_email,
|
||||||
|
purpose=purpose,
|
||||||
|
target_user_id=target_user_id,
|
||||||
|
)
|
||||||
|
if latest_code and latest_code.created_at:
|
||||||
|
created_at = latest_code.created_at
|
||||||
|
if created_at.tzinfo is None:
|
||||||
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||||
|
resend_after = max(1, int(self.settings.EMAIL_CODE_RESEND_SECONDS))
|
||||||
|
elapsed = int((now - created_at).total_seconds())
|
||||||
|
if elapsed < resend_after and latest_code.consumed_at is None:
|
||||||
|
return EmailCodeRequestResult(
|
||||||
|
ok=False,
|
||||||
|
error="rate_limited",
|
||||||
|
retry_after=resend_after - elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
code_model = EmailVerificationCode(
|
||||||
|
email=normalized_email,
|
||||||
|
code_hash=self._hash_code(normalized_email, purpose, code),
|
||||||
|
purpose=purpose,
|
||||||
|
target_user_id=target_user_id,
|
||||||
|
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
|
||||||
|
)
|
||||||
|
session.add(code_model)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await self._send_code_email(
|
||||||
|
email=normalized_email,
|
||||||
|
code=code,
|
||||||
|
language_code=language_code,
|
||||||
|
)
|
||||||
|
return EmailCodeRequestResult(ok=True)
|
||||||
|
|
||||||
|
async def verify_code(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
purpose: str,
|
||||||
|
code: str,
|
||||||
|
target_user_id: Optional[int] = None,
|
||||||
|
) -> EmailCodeVerifyResult:
|
||||||
|
normalized_email = normalize_email(email)
|
||||||
|
normalized_code = re.sub(r"\D", "", code or "")
|
||||||
|
if not is_valid_email(normalized_email) or len(normalized_code) != 6:
|
||||||
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||||
|
|
||||||
|
latest_code = await self._get_latest_code(
|
||||||
|
session,
|
||||||
|
email=normalized_email,
|
||||||
|
purpose=purpose,
|
||||||
|
target_user_id=target_user_id,
|
||||||
|
)
|
||||||
|
if not latest_code or latest_code.consumed_at is not None:
|
||||||
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = latest_code.expires_at
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if expires_at < now:
|
||||||
|
return EmailCodeVerifyResult(ok=False, error="expired_code")
|
||||||
|
|
||||||
|
max_attempts = max(1, int(self.settings.EMAIL_CODE_MAX_ATTEMPTS))
|
||||||
|
if int(latest_code.attempts or 0) >= max_attempts:
|
||||||
|
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
|
||||||
|
|
||||||
|
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
|
||||||
|
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
|
||||||
|
latest_code.attempts = int(latest_code.attempts or 0) + 1
|
||||||
|
await session.flush()
|
||||||
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||||
|
|
||||||
|
latest_code.consumed_at = now
|
||||||
|
await session.flush()
|
||||||
|
return EmailCodeVerifyResult(ok=True)
|
||||||
|
|
||||||
|
async def _get_latest_code(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
purpose: str,
|
||||||
|
target_user_id: Optional[int],
|
||||||
|
) -> Optional[EmailVerificationCode]:
|
||||||
|
stmt = (
|
||||||
|
select(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == email,
|
||||||
|
EmailVerificationCode.purpose == purpose,
|
||||||
|
EmailVerificationCode.target_user_id == target_user_id,
|
||||||
|
)
|
||||||
|
.order_by(EmailVerificationCode.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def _send_code_email(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
code: str,
|
||||||
|
language_code: str,
|
||||||
|
) -> None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self._send_code_email_sync,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
language_code=language_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_code_email_sync(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
code: str,
|
||||||
|
language_code: str,
|
||||||
|
) -> None:
|
||||||
|
lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||||
|
if lang == "en":
|
||||||
|
subject = "Your login code"
|
||||||
|
body = (
|
||||||
|
f"Your verification code: {code}\n\n"
|
||||||
|
f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subject = "Код подтверждения"
|
||||||
|
body = (
|
||||||
|
f"Ваш код подтверждения: {code}\n\n"
|
||||||
|
f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин."
|
||||||
|
)
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = formataddr(
|
||||||
|
(
|
||||||
|
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||||
|
self.settings.SMTP_FROM_EMAIL or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message["To"] = email
|
||||||
|
message.set_content(body)
|
||||||
|
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
smtp_host = self.settings.SMTP_HOST
|
||||||
|
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
|
||||||
|
attempts = self._smtp_attempts()
|
||||||
|
last_error: Optional[BaseException] = None
|
||||||
|
|
||||||
|
for attempt_number, attempt in enumerate(attempts, start=1):
|
||||||
|
try:
|
||||||
|
self._send_message_via_smtp(
|
||||||
|
message=message,
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
smtp_port=attempt.port,
|
||||||
|
timeout=timeout,
|
||||||
|
context=context,
|
||||||
|
use_ssl=attempt.use_ssl,
|
||||||
|
starttls=attempt.starttls,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Email verification code sent to %s via %s:%s",
|
||||||
|
email,
|
||||||
|
smtp_host,
|
||||||
|
attempt.port,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
|
||||||
|
logger.log(
|
||||||
|
log_level,
|
||||||
|
"SMTP send attempt %s/%s failed via %s:%s (ssl=%s, starttls=%s): %s",
|
||||||
|
attempt_number,
|
||||||
|
len(attempts),
|
||||||
|
smtp_host,
|
||||||
|
attempt.port,
|
||||||
|
attempt.use_ssl,
|
||||||
|
attempt.starttls,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
def _send_message_via_smtp(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message: EmailMessage,
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
timeout: int,
|
||||||
|
context: ssl.SSLContext,
|
||||||
|
use_ssl: bool,
|
||||||
|
starttls: bool,
|
||||||
|
) -> None:
|
||||||
|
if use_ssl:
|
||||||
|
with smtplib.SMTP_SSL(
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
context=context,
|
||||||
|
timeout=timeout,
|
||||||
|
) as smtp:
|
||||||
|
smtp.ehlo()
|
||||||
|
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
|
||||||
|
smtp.send_message(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
with smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) as smtp:
|
||||||
|
smtp.ehlo()
|
||||||
|
if starttls:
|
||||||
|
smtp.starttls(context=context)
|
||||||
|
smtp.ehlo()
|
||||||
|
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
|
||||||
|
smtp.send_message(message)
|
||||||
@@ -54,7 +54,10 @@ class PanelWebhookService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
|
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)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
internal_user_id = db_user.user_id if db_user else user_id
|
||||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||||
|
|
||||||
@@ -69,7 +72,7 @@ class PanelWebhookService:
|
|||||||
if subscription_service:
|
if subscription_service:
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||||
try:
|
try:
|
||||||
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
||||||
@@ -89,7 +92,7 @@ class PanelWebhookService:
|
|||||||
if days_left == 2:
|
if days_left == 2:
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id)
|
||||||
logging.info(
|
logging.info(
|
||||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -96,6 +96,42 @@ class SubscriptionService:
|
|||||||
f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
|
f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _telegram_id_for_panel(self, db_user: User) -> Optional[int]:
|
||||||
|
if db_user.telegram_id:
|
||||||
|
return int(db_user.telegram_id)
|
||||||
|
if db_user.user_id and int(db_user.user_id) > 0:
|
||||||
|
return int(db_user.user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _panel_username_for_user(
|
||||||
|
self, session: AsyncSession, db_user: User
|
||||||
|
) -> str:
|
||||||
|
telegram_id = self._telegram_id_for_panel(db_user)
|
||||||
|
if telegram_id and int(db_user.user_id) == telegram_id:
|
||||||
|
return f"tg_{telegram_id}"
|
||||||
|
referral_code = await user_dal.ensure_referral_code(session, db_user)
|
||||||
|
return f"em_{referral_code}"
|
||||||
|
|
||||||
|
def _panel_description_for_user(self, db_user: User) -> str:
|
||||||
|
lines = [
|
||||||
|
db_user.email or "",
|
||||||
|
db_user.username or "",
|
||||||
|
db_user.first_name or "",
|
||||||
|
db_user.last_name or "",
|
||||||
|
]
|
||||||
|
return "\n".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"description": self._panel_description_for_user(db_user),
|
||||||
|
}
|
||||||
|
telegram_id = self._telegram_id_for_panel(db_user)
|
||||||
|
if telegram_id:
|
||||||
|
payload["telegramId"] = telegram_id
|
||||||
|
if db_user.email:
|
||||||
|
payload["email"] = db_user.email
|
||||||
|
return payload
|
||||||
|
|
||||||
async def _get_or_create_panel_user_link_details(
|
async def _get_or_create_panel_user_link_details(
|
||||||
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
|
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
|
||||||
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
|
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
|
||||||
@@ -109,22 +145,42 @@ class SubscriptionService:
|
|||||||
return None, None, None, False
|
return None, None, None, False
|
||||||
|
|
||||||
current_local_panel_uuid = db_user.panel_user_uuid
|
current_local_panel_uuid = db_user.panel_user_uuid
|
||||||
panel_username_on_panel_standard = f"tg_{user_id}"
|
panel_username_on_panel_standard = await self._panel_username_for_user(
|
||||||
|
session, db_user
|
||||||
|
)
|
||||||
|
telegram_id_for_panel = self._telegram_id_for_panel(db_user)
|
||||||
|
|
||||||
panel_user_obj_from_api = None
|
panel_user_obj_from_api = None
|
||||||
panel_user_created_or_linked_now = False
|
panel_user_created_or_linked_now = False
|
||||||
|
|
||||||
|
panel_users_by_tg_id_list = None
|
||||||
|
if telegram_id_for_panel:
|
||||||
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
|
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
|
||||||
telegram_id=user_id
|
telegram_id=telegram_id_for_panel
|
||||||
)
|
)
|
||||||
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
|
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
|
||||||
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
|
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Found panel user by telegramId {user_id}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
|
f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
|
||||||
)
|
)
|
||||||
elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
|
elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"CRITICAL: Multiple panel users found for telegramId {user_id}. Manual intervention needed."
|
f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed."
|
||||||
|
)
|
||||||
|
return None, None, None, False
|
||||||
|
|
||||||
|
if not panel_user_obj_from_api and db_user.email:
|
||||||
|
panel_users_by_email_list = await self.panel_service.get_users_by_filter(
|
||||||
|
email=db_user.email
|
||||||
|
)
|
||||||
|
if panel_users_by_email_list and len(panel_users_by_email_list) == 1:
|
||||||
|
panel_user_obj_from_api = panel_users_by_email_list[0]
|
||||||
|
logging.info(
|
||||||
|
f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
|
||||||
|
)
|
||||||
|
elif panel_users_by_email_list and len(panel_users_by_email_list) > 1:
|
||||||
|
logging.error(
|
||||||
|
f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed."
|
||||||
)
|
)
|
||||||
return None, None, None, False
|
return None, None, None, False
|
||||||
|
|
||||||
@@ -146,12 +202,9 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
creation_response = await self.panel_service.create_panel_user(
|
creation_response = await self.panel_service.create_panel_user(
|
||||||
username_on_panel=panel_username_on_panel_standard,
|
username_on_panel=panel_username_on_panel_standard,
|
||||||
telegram_id=user_id,
|
telegram_id=telegram_id_for_panel,
|
||||||
description="\n".join([
|
email=db_user.email,
|
||||||
(db_user.username or "") if db_user else "",
|
description=self._panel_description_for_user(db_user),
|
||||||
(db_user.first_name or "") if db_user else "",
|
|
||||||
(db_user.last_name or "") if db_user else "",
|
|
||||||
]),
|
|
||||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||||
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||||
@@ -175,12 +228,9 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
creation_response = await self.panel_service.create_panel_user(
|
creation_response = await self.panel_service.create_panel_user(
|
||||||
username_on_panel=panel_username_on_panel_standard,
|
username_on_panel=panel_username_on_panel_standard,
|
||||||
telegram_id=user_id,
|
telegram_id=telegram_id_for_panel,
|
||||||
description="\n".join([
|
email=db_user.email,
|
||||||
(db_user.username or "") if db_user else "",
|
description=self._panel_description_for_user(db_user),
|
||||||
(db_user.first_name or "") if db_user else "",
|
|
||||||
(db_user.last_name or "") if db_user else "",
|
|
||||||
]),
|
|
||||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||||
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||||
@@ -226,7 +276,6 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
|
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
|
||||||
actual_panel_username_from_api = panel_user_obj_from_api.get("username")
|
|
||||||
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
|
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
|
||||||
|
|
||||||
if not actual_panel_uuid_from_api:
|
if not actual_panel_uuid_from_api:
|
||||||
@@ -293,24 +342,15 @@ class SubscriptionService:
|
|||||||
if (
|
if (
|
||||||
panel_user_obj_from_api
|
panel_user_obj_from_api
|
||||||
and current_local_panel_uuid
|
and current_local_panel_uuid
|
||||||
and panel_telegram_id_int != user_id
|
and telegram_id_for_panel
|
||||||
|
and panel_telegram_id_int != telegram_id_for_panel
|
||||||
):
|
):
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
|
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'."
|
||||||
)
|
)
|
||||||
# Also set readable description with Telegram fields
|
|
||||||
await self.panel_service.update_user_details_on_panel(
|
await self.panel_service.update_user_details_on_panel(
|
||||||
current_local_panel_uuid,
|
current_local_panel_uuid,
|
||||||
{
|
self._panel_identity_payload_for_user(db_user),
|
||||||
"telegramId": user_id,
|
|
||||||
"description": "\n".join(
|
|
||||||
[
|
|
||||||
(db_user.username or "") if db_user else "",
|
|
||||||
(db_user.first_name or "") if db_user else "",
|
|
||||||
(db_user.last_name or "") if db_user else "",
|
|
||||||
]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
panel_sub_link_id = panel_user_obj_from_api.get(
|
panel_sub_link_id = panel_user_obj_from_api.get(
|
||||||
@@ -408,14 +448,7 @@ class SubscriptionService:
|
|||||||
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add user description based on Telegram profile
|
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||||
panel_update_payload["description"] = "\n".join(
|
|
||||||
[
|
|
||||||
(db_user.username or "") if db_user else "",
|
|
||||||
(db_user.first_name or "") if db_user else "",
|
|
||||||
(db_user.last_name or "") if db_user else "",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||||
panel_user_uuid, panel_update_payload
|
panel_user_uuid, panel_update_payload
|
||||||
@@ -525,13 +558,7 @@ class SubscriptionService:
|
|||||||
traffic_limit_strategy="NO_RESET",
|
traffic_limit_strategy="NO_RESET",
|
||||||
)
|
)
|
||||||
|
|
||||||
panel_update_payload["description"] = "\n".join(
|
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||||
[
|
|
||||||
(db_user.username or "") if db_user else "",
|
|
||||||
(db_user.first_name or "") if db_user else "",
|
|
||||||
(db_user.last_name or "") if db_user else "",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||||
panel_user_uuid, panel_update_payload
|
panel_user_uuid, panel_update_payload
|
||||||
@@ -695,14 +722,7 @@ class SubscriptionService:
|
|||||||
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add user description based on Telegram profile
|
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||||
panel_update_payload["description"] = "\n".join(
|
|
||||||
[
|
|
||||||
(db_user.username or "") if db_user else "",
|
|
||||||
(db_user.first_name or "") if db_user else "",
|
|
||||||
(db_user.last_name or "") if db_user else "",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||||
panel_user_uuid, panel_update_payload
|
panel_user_uuid, panel_update_payload
|
||||||
|
|||||||
@@ -215,6 +215,20 @@ class Settings(BaseSettings):
|
|||||||
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
|
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
|
||||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
|
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
|
||||||
|
|
||||||
|
SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
|
||||||
|
SMTP_PORT: int = Field(default=587)
|
||||||
|
SMTP_FALLBACK_PORTS: Optional[str] = Field(default="2525,465")
|
||||||
|
SMTP_TIMEOUT_SECONDS: int = Field(default=30)
|
||||||
|
SMTP_USERNAME: Optional[str] = Field(default=None)
|
||||||
|
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
||||||
|
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
|
||||||
|
SMTP_FROM_NAME: Optional[str] = Field(default=None)
|
||||||
|
SMTP_STARTTLS: bool = Field(default=True)
|
||||||
|
SMTP_USE_SSL: bool = Field(default=False)
|
||||||
|
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
|
||||||
|
EMAIL_CODE_RESEND_SECONDS: int = Field(default=60)
|
||||||
|
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
|
||||||
|
|
||||||
LOGS_PAGE_SIZE: int = Field(default=10)
|
LOGS_PAGE_SIZE: int = Field(default=10)
|
||||||
|
|
||||||
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
|
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
|
||||||
@@ -533,6 +547,35 @@ class Settings(BaseSettings):
|
|||||||
methods.append(slug)
|
methods.append(slug)
|
||||||
return methods or default_order
|
return methods or default_order
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def email_auth_configured(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.SMTP_HOST
|
||||||
|
and self.SMTP_PORT
|
||||||
|
and self.SMTP_USERNAME
|
||||||
|
and self.SMTP_PASSWORD
|
||||||
|
and self.SMTP_FROM_EMAIL
|
||||||
|
)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def smtp_ports_to_try(self) -> List[int]:
|
||||||
|
ports: List[int] = []
|
||||||
|
|
||||||
|
def add_port(value: Any) -> None:
|
||||||
|
try:
|
||||||
|
port = int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
if 0 < port <= 65535 and port not in ports:
|
||||||
|
ports.append(port)
|
||||||
|
|
||||||
|
add_port(self.SMTP_PORT)
|
||||||
|
for item in (self.SMTP_FALLBACK_PORTS or "").split(","):
|
||||||
|
add_port(item)
|
||||||
|
return ports
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
LOG_LEVEL: str = Field(
|
LOG_LEVEL: str = Field(
|
||||||
default="INFO",
|
default="INFO",
|
||||||
@@ -568,6 +611,11 @@ class Settings(BaseSettings):
|
|||||||
'USER_AGREEMENT_URL',
|
'USER_AGREEMENT_URL',
|
||||||
'SUBSCRIPTION_MINI_APP_URL',
|
'SUBSCRIPTION_MINI_APP_URL',
|
||||||
'WEBAPP_LOGO_URL',
|
'WEBAPP_LOGO_URL',
|
||||||
|
'SMTP_USERNAME',
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
'SMTP_FROM_EMAIL',
|
||||||
|
'SMTP_FROM_NAME',
|
||||||
|
'SMTP_FALLBACK_PORTS',
|
||||||
mode='before',
|
mode='before',
|
||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ from ..models import (
|
|||||||
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
||||||
REFERRAL_CODE_LENGTH = 9
|
REFERRAL_CODE_LENGTH = 9
|
||||||
MAX_REFERRAL_CODE_ATTEMPTS = 25
|
MAX_REFERRAL_CODE_ATTEMPTS = 25
|
||||||
|
MAX_EMAIL_USER_ID_ATTEMPTS = 25
|
||||||
|
|
||||||
|
|
||||||
|
class UserMergeConflictError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _generate_referral_code_candidate() -> str:
|
def _generate_referral_code_candidate() -> str:
|
||||||
@@ -50,6 +55,14 @@ async def generate_unique_referral_code(session: AsyncSession) -> str:
|
|||||||
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
|
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_unique_email_user_id(session: AsyncSession) -> int:
|
||||||
|
for _ in range(MAX_EMAIL_USER_ID_ATTEMPTS):
|
||||||
|
candidate = -(secrets.randbelow(9_000_000_000_000_000) + 1)
|
||||||
|
if not await get_user_by_id(session, candidate):
|
||||||
|
return candidate
|
||||||
|
raise RuntimeError("Failed to generate a unique email user id after several attempts.")
|
||||||
|
|
||||||
|
|
||||||
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||||
"""
|
"""
|
||||||
Ensure the provided user has a referral code, generating and persisting it if missing.
|
Ensure the provided user has a referral code, generating and persisting it if missing.
|
||||||
@@ -82,6 +95,23 @@ async def get_user_by_username(session: AsyncSession, username: str) -> Optional
|
|||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_email(session: AsyncSession, email: str) -> Optional[User]:
|
||||||
|
clean_email = (email or "").strip().lower()
|
||||||
|
if not clean_email:
|
||||||
|
return None
|
||||||
|
stmt = select(User).where(func.lower(User.email) == clean_email)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_telegram_id(
|
||||||
|
session: AsyncSession, telegram_id: int
|
||||||
|
) -> Optional[User]:
|
||||||
|
stmt = select(User).where(User.telegram_id == telegram_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_panel_uuid(
|
async def get_user_by_panel_uuid(
|
||||||
session: AsyncSession, panel_uuid: str
|
session: AsyncSession, panel_uuid: str
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
@@ -135,6 +165,217 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
|||||||
return user, created
|
return user, created
|
||||||
|
|
||||||
|
|
||||||
|
async def create_email_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
language_code: str,
|
||||||
|
email_verified_at: Optional[datetime] = None,
|
||||||
|
referred_by_id: Optional[int] = None,
|
||||||
|
) -> Tuple[User, bool]:
|
||||||
|
normalized_email = (email or "").strip().lower()
|
||||||
|
user_id = await generate_unique_email_user_id(session)
|
||||||
|
return await create_user(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"email": normalized_email,
|
||||||
|
"email_verified_at": email_verified_at or datetime.now(timezone.utc),
|
||||||
|
"language_code": language_code,
|
||||||
|
"referred_by_id": referred_by_id,
|
||||||
|
"registration_date": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _has_active_panel_subscription(
|
||||||
|
session: AsyncSession, user_id: int, panel_user_uuid: str
|
||||||
|
) -> bool:
|
||||||
|
stmt = (
|
||||||
|
select(Subscription.subscription_id)
|
||||||
|
.where(
|
||||||
|
Subscription.user_id == user_id,
|
||||||
|
Subscription.panel_user_uuid == panel_user_uuid,
|
||||||
|
Subscription.is_active == True,
|
||||||
|
Subscription.end_date > datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def merge_users(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
source_user_id: int,
|
||||||
|
target_user_id: int,
|
||||||
|
) -> User:
|
||||||
|
"""Merge source user data into target user and remove the source row."""
|
||||||
|
|
||||||
|
if source_user_id == target_user_id:
|
||||||
|
target = await get_user_by_id(session, target_user_id)
|
||||||
|
if not target:
|
||||||
|
raise ValueError("Target user not found.")
|
||||||
|
return target
|
||||||
|
|
||||||
|
source = await get_user_by_id(session, source_user_id)
|
||||||
|
target = await get_user_by_id(session, target_user_id)
|
||||||
|
if not source or not target:
|
||||||
|
raise ValueError("Both source and target users are required for merge.")
|
||||||
|
|
||||||
|
if source.email and target.email and source.email != target.email:
|
||||||
|
raise UserMergeConflictError("Both accounts already have different emails.")
|
||||||
|
if (
|
||||||
|
source.telegram_id
|
||||||
|
and target.telegram_id
|
||||||
|
and int(source.telegram_id) != int(target.telegram_id)
|
||||||
|
):
|
||||||
|
raise UserMergeConflictError("Both accounts already have different Telegram IDs.")
|
||||||
|
|
||||||
|
source_panel_uuid = source.panel_user_uuid
|
||||||
|
target_panel_uuid = target.panel_user_uuid
|
||||||
|
panel_uuid_to_move = None
|
||||||
|
if source_panel_uuid and not target_panel_uuid:
|
||||||
|
panel_uuid_to_move = source_panel_uuid
|
||||||
|
elif source_panel_uuid and target_panel_uuid and source_panel_uuid != target_panel_uuid:
|
||||||
|
source_has_active = await _has_active_panel_subscription(
|
||||||
|
session, source_user_id, source_panel_uuid
|
||||||
|
)
|
||||||
|
target_has_active = await _has_active_panel_subscription(
|
||||||
|
session, target_user_id, target_panel_uuid
|
||||||
|
)
|
||||||
|
if source_has_active and target_has_active:
|
||||||
|
raise UserMergeConflictError(
|
||||||
|
"Both accounts have active subscriptions on different panel users."
|
||||||
|
)
|
||||||
|
if source_has_active and not target_has_active:
|
||||||
|
panel_uuid_to_move = source_panel_uuid
|
||||||
|
|
||||||
|
email_to_move = source.email if source.email and not target.email else None
|
||||||
|
email_verified_at_to_move = (
|
||||||
|
source.email_verified_at
|
||||||
|
if source.email and (not target.email_verified_at or email_to_move)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
telegram_id_to_move = (
|
||||||
|
source.telegram_id if source.telegram_id and not target.telegram_id else None
|
||||||
|
)
|
||||||
|
referral_code_to_move = (
|
||||||
|
source.referral_code if source.referral_code and not target.referral_code else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if email_to_move:
|
||||||
|
source.email = None
|
||||||
|
if panel_uuid_to_move:
|
||||||
|
source.panel_user_uuid = None
|
||||||
|
if telegram_id_to_move:
|
||||||
|
source.telegram_id = None
|
||||||
|
if referral_code_to_move:
|
||||||
|
source.referral_code = None
|
||||||
|
if email_to_move or panel_uuid_to_move or telegram_id_to_move or referral_code_to_move:
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if email_to_move:
|
||||||
|
target.email = email_to_move
|
||||||
|
if email_verified_at_to_move and not target.email_verified_at:
|
||||||
|
target.email_verified_at = email_verified_at_to_move
|
||||||
|
if telegram_id_to_move:
|
||||||
|
target.telegram_id = telegram_id_to_move
|
||||||
|
if panel_uuid_to_move:
|
||||||
|
target.panel_user_uuid = panel_uuid_to_move
|
||||||
|
if referral_code_to_move:
|
||||||
|
target.referral_code = referral_code_to_move
|
||||||
|
|
||||||
|
for attr in ("username", "first_name", "last_name", "language_code"):
|
||||||
|
if not getattr(target, attr) and getattr(source, attr):
|
||||||
|
setattr(target, attr, getattr(source, attr))
|
||||||
|
if not target.referred_by_id and source.referred_by_id != target_user_id:
|
||||||
|
target.referred_by_id = source.referred_by_id
|
||||||
|
if target.referred_by_id == source_user_id:
|
||||||
|
target.referred_by_id = source.referred_by_id
|
||||||
|
if target.referred_by_id == target_user_id:
|
||||||
|
target.referred_by_id = None
|
||||||
|
|
||||||
|
target_method_ids = select(UserPaymentMethod.provider_payment_method_id).where(
|
||||||
|
UserPaymentMethod.user_id == target_user_id
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(UserPaymentMethod).where(
|
||||||
|
UserPaymentMethod.user_id == source_user_id,
|
||||||
|
UserPaymentMethod.provider_payment_method_id.in_(target_method_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
target_promo_ids = select(PromoCodeActivation.promo_code_id).where(
|
||||||
|
PromoCodeActivation.user_id == target_user_id
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(PromoCodeActivation).where(
|
||||||
|
PromoCodeActivation.user_id == source_user_id,
|
||||||
|
PromoCodeActivation.promo_code_id.in_(target_promo_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
target_has_billing = (
|
||||||
|
await session.execute(
|
||||||
|
select(UserBilling.user_id).where(UserBilling.user_id == target_user_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if target_has_billing:
|
||||||
|
await session.execute(delete(UserBilling).where(UserBilling.user_id == source_user_id))
|
||||||
|
else:
|
||||||
|
await session.execute(
|
||||||
|
update(UserBilling)
|
||||||
|
.where(UserBilling.user_id == source_user_id)
|
||||||
|
.values(user_id=target_user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
target_has_attribution = (
|
||||||
|
await session.execute(
|
||||||
|
select(AdAttribution.user_id).where(AdAttribution.user_id == target_user_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if target_has_attribution:
|
||||||
|
await session.execute(
|
||||||
|
delete(AdAttribution).where(AdAttribution.user_id == source_user_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await session.execute(
|
||||||
|
update(AdAttribution)
|
||||||
|
.where(AdAttribution.user_id == source_user_id)
|
||||||
|
.values(user_id=target_user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
for model in (Subscription, Payment, PromoCodeActivation, UserPaymentMethod):
|
||||||
|
await session.execute(
|
||||||
|
update(model)
|
||||||
|
.where(model.user_id == source_user_id)
|
||||||
|
.values(user_id=target_user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(MessageLog)
|
||||||
|
.where(MessageLog.user_id == source_user_id)
|
||||||
|
.values(user_id=target_user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(MessageLog)
|
||||||
|
.where(MessageLog.target_user_id == source_user_id)
|
||||||
|
.values(target_user_id=target_user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.referred_by_id == source_user_id)
|
||||||
|
.values(referred_by_id=target_user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(source)
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||||
normalized = referral_code.strip().upper()
|
normalized = referral_code.strip().upper()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
|
|||||||
@@ -125,6 +125,83 @@ def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0005_add_email_auth_fields(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
|
||||||
|
if "email" not in columns:
|
||||||
|
connection.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR"))
|
||||||
|
if "email_verified_at" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ")
|
||||||
|
)
|
||||||
|
if "telegram_id" not in columns:
|
||||||
|
connection.execute(text("ALTER TABLE users ADD COLUMN telegram_id BIGINT"))
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET telegram_id = user_id
|
||||||
|
WHERE telegram_id IS NULL
|
||||||
|
AND user_id > 0
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email
|
||||||
|
ON users (email)
|
||||||
|
WHERE email IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_telegram_id
|
||||||
|
ON users (telegram_id)
|
||||||
|
WHERE telegram_id IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS email_verification_codes (
|
||||||
|
code_id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR NOT NULL,
|
||||||
|
code_hash VARCHAR NOT NULL,
|
||||||
|
purpose VARCHAR NOT NULL,
|
||||||
|
target_user_id BIGINT NULL REFERENCES users(user_id),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_lookup
|
||||||
|
ON email_verification_codes (email, purpose, target_user_id, created_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_expires_at
|
||||||
|
ON email_verification_codes (expires_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -146,6 +223,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Store lifetime traffic usage for users",
|
description="Store lifetime traffic usage for users",
|
||||||
upgrade=_migration_0004_add_lifetime_used_traffic,
|
upgrade=_migration_0004_add_lifetime_used_traffic,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0005_add_email_auth_fields",
|
||||||
|
description="Add email login identities and verification codes",
|
||||||
|
upgrade=_migration_0005_add_email_auth_fields,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class User(Base):
|
|||||||
|
|
||||||
user_id = Column(BigInteger, primary_key=True, index=True)
|
user_id = Column(BigInteger, primary_key=True, index=True)
|
||||||
username = Column(String, nullable=True, index=True)
|
username = Column(String, nullable=True, index=True)
|
||||||
|
email = Column(String, nullable=True, unique=True, index=True)
|
||||||
|
email_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
||||||
first_name = Column(String, nullable=True)
|
first_name = Column(String, nullable=True)
|
||||||
last_name = Column(String, nullable=True)
|
last_name = Column(String, nullable=True)
|
||||||
language_code = Column(String, default="ru")
|
language_code = Column(String, default="ru")
|
||||||
@@ -86,6 +89,27 @@ class Subscription(Base):
|
|||||||
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>"
|
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerificationCode(Base):
|
||||||
|
__tablename__ = "email_verification_codes"
|
||||||
|
|
||||||
|
code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
email = Column(String, nullable=False, index=True)
|
||||||
|
code_hash = Column(String, nullable=False)
|
||||||
|
purpose = Column(String, nullable=False, index=True)
|
||||||
|
target_user_id = Column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("users.user_id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
consumed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
attempts = Column(Integer, nullable=False, default=0)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
target_user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class Payment(Base):
|
class Payment(Base):
|
||||||
__tablename__ = "payments"
|
__tablename__ = "payments"
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -234,9 +234,9 @@
|
|||||||
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
|
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
|
||||||
"admin_promo_edit_max_activations": "🔢 Max Activations",
|
"admin_promo_edit_max_activations": "🔢 Max Activations",
|
||||||
"admin_promo_edit_validity": "⏰ Validity",
|
"admin_promo_edit_validity": "⏰ Validity",
|
||||||
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
|
"admin_ban_user_prompt": "Enter user ID, @username, or email to ban:",
|
||||||
"admin_user_already_banned": "User {user_id_or_username} is already banned.",
|
"admin_user_already_banned": "User {user_id_or_username} is already banned.",
|
||||||
"admin_unban_user_prompt": "Enter user ID or @username to unban:",
|
"admin_unban_user_prompt": "Enter user ID, @username, or email to unban:",
|
||||||
"admin_user_not_banned": "⚠️ User is not banned",
|
"admin_user_not_banned": "⚠️ User is not banned",
|
||||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||||
"prev_page_button": "⬅️ Prev.",
|
"prev_page_button": "⬅️ Prev.",
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
"admin_export_logs_csv_button": "📄 Export to CSV",
|
"admin_export_logs_csv_button": "📄 Export to CSV",
|
||||||
"admin_no_logs_found": "No logs found.",
|
"admin_no_logs_found": "No logs found.",
|
||||||
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
||||||
"admin_prompt_for_user_id_or_username_logs": "Enter user ID or @username to view logs:",
|
"admin_prompt_for_user_id_or_username_logs": "Enter user ID, @username, or email to view logs:",
|
||||||
"admin_log_user_not_found": "User \"{input}\" not found in bot database.",
|
"admin_log_user_not_found": "User \"{input}\" not found in bot database.",
|
||||||
"sync_started_simple": "🔄 Starting synchronization...",
|
"sync_started_simple": "🔄 Starting synchronization...",
|
||||||
"sync_success_simple": "✅ Synchronization completed successfully",
|
"sync_success_simple": "✅ Synchronization completed successfully",
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
||||||
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
||||||
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
||||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID, @username, or email to search:",
|
||||||
"admin_user_subscription_info": "Subscription Information:",
|
"admin_user_subscription_info": "Subscription Information:",
|
||||||
"admin_user_reset_trial_button": "🔄 Reset Trial",
|
"admin_user_reset_trial_button": "🔄 Reset Trial",
|
||||||
"admin_user_add_subscription_button": "➕ Add Days",
|
"admin_user_add_subscription_button": "➕ Add Days",
|
||||||
@@ -369,6 +369,8 @@
|
|||||||
"admin_user_id_label": "🆔 <b>ID:</b>",
|
"admin_user_id_label": "🆔 <b>ID:</b>",
|
||||||
"admin_user_name_label": "👤 <b>Name:</b>",
|
"admin_user_name_label": "👤 <b>Name:</b>",
|
||||||
"admin_user_username_label": "📱 <b>Username:</b>",
|
"admin_user_username_label": "📱 <b>Username:</b>",
|
||||||
|
"admin_user_email_label": "✉️ <b>Email:</b>",
|
||||||
|
"admin_user_telegram_id_label": "📨 <b>Telegram ID:</b>",
|
||||||
"admin_user_language_label": "🌍 <b>Language:</b>",
|
"admin_user_language_label": "🌍 <b>Language:</b>",
|
||||||
"admin_user_registration_label": "📅 <b>Registration:</b>",
|
"admin_user_registration_label": "📅 <b>Registration:</b>",
|
||||||
"admin_user_status_label": "🛡 <b>Status:</b>",
|
"admin_user_status_label": "🛡 <b>Status:</b>",
|
||||||
|
|||||||
+6
-4
@@ -243,9 +243,9 @@
|
|||||||
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
|
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
|
||||||
"admin_promo_edit_max_activations": "🔢 Макс. активации",
|
"admin_promo_edit_max_activations": "🔢 Макс. активации",
|
||||||
"admin_promo_edit_validity": "⏰ Срок действия",
|
"admin_promo_edit_validity": "⏰ Срок действия",
|
||||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
"admin_ban_user_prompt": "Введите ID, @username или email пользователя для блокировки:",
|
||||||
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
|
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
|
||||||
"admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:",
|
"admin_unban_user_prompt": "Введите ID, @username или email пользователя для разблокировки:",
|
||||||
"admin_user_not_banned": "⚠️ Пользователь не заблокирован",
|
"admin_user_not_banned": "⚠️ Пользователь не заблокирован",
|
||||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||||
"prev_page_button": "⬅️ Пред.",
|
"prev_page_button": "⬅️ Пред.",
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
"admin_export_logs_csv_button": "📄 Экспорт в CSV",
|
"admin_export_logs_csv_button": "📄 Экспорт в CSV",
|
||||||
"admin_no_logs_found": "Логи не найдены.",
|
"admin_no_logs_found": "Логи не найдены.",
|
||||||
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
||||||
"admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:",
|
"admin_prompt_for_user_id_or_username_logs": "Введите ID, @username или email пользователя для просмотра его логов:",
|
||||||
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
|
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
|
||||||
"sync_started_simple": "🔄 Начинаю синхронизацию...",
|
"sync_started_simple": "🔄 Начинаю синхронизацию...",
|
||||||
"sync_success_simple": "✅ Синхронизация успешно завершена",
|
"sync_success_simple": "✅ Синхронизация успешно завершена",
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
||||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
|
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя, @username или email для поиска:",
|
||||||
"admin_user_subscription_info": "Информация о подписке:",
|
"admin_user_subscription_info": "Информация о подписке:",
|
||||||
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
||||||
"admin_user_add_subscription_button": "➕ Добавить дни",
|
"admin_user_add_subscription_button": "➕ Добавить дни",
|
||||||
@@ -369,6 +369,8 @@
|
|||||||
"admin_user_id_label": "🆔 <b>ID:</b>",
|
"admin_user_id_label": "🆔 <b>ID:</b>",
|
||||||
"admin_user_name_label": "👤 <b>Имя:</b>",
|
"admin_user_name_label": "👤 <b>Имя:</b>",
|
||||||
"admin_user_username_label": "📱 <b>Username:</b>",
|
"admin_user_username_label": "📱 <b>Username:</b>",
|
||||||
|
"admin_user_email_label": "✉️ <b>Email:</b>",
|
||||||
|
"admin_user_telegram_id_label": "📨 <b>Telegram ID:</b>",
|
||||||
"admin_user_language_label": "🌍 <b>Язык:</b>",
|
"admin_user_language_label": "🌍 <b>Язык:</b>",
|
||||||
"admin_user_registration_label": "📅 <b>Регистрация:</b>",
|
"admin_user_registration_label": "📅 <b>Регистрация:</b>",
|
||||||
"admin_user_status_label": "🛡 <b>Статус:</b>",
|
"admin_user_status_label": "🛡 <b>Статус:</b>",
|
||||||
|
|||||||
Reference in New Issue
Block a user