feat: email login, smtp codes

This commit is contained in:
3252a8
2026-04-23 21:20:32 +03:00
parent b9cb1fec06
commit 807a8933b9
20 changed files with 2321 additions and 137 deletions
+395 -3
View File
@@ -17,6 +17,7 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token,
)
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.severpay_service import SeverPayService
@@ -25,6 +26,7 @@ from bot.services.yookassa_service import YooKassaService
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User
logger = logging.getLogger(__name__)
@@ -54,6 +56,7 @@ def create_subscription_webapp_application(
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
for key in (
"subscription_service",
@@ -81,7 +84,12 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_post("/api/auth/email/request", email_auth_request_route)
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
app.router.add_get("/api/me", me_route)
app.router.add_post("/api/account/email/request", account_email_request_route)
app.router.add_post("/api/account/email/verify", account_email_verify_route)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
app.router.add_post("/api/payments", create_payment_route)
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
@@ -272,6 +280,7 @@ async def index_route(request: web.Request) -> web.Response:
"userAgreementUrl": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"language": _normalize_language(settings.DEFAULT_LANGUAGE),
"emailAuthEnabled": settings.email_auth_configured,
}
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
html = html.replace(
@@ -340,22 +349,220 @@ async def auth_token_route(request: web.Request) -> web.Response:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
authenticated_user_id: Optional[int] = None
async with async_session_factory() as session:
try:
db_user = await _ensure_user_from_telegram(session, telegram_user, settings)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
authenticated_user_id = int(db_user.user_id)
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("WebApp auth failed: %s", exc, exc_info=True)
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(telegram_user["id"]))
token = create_webapp_session_token(settings, int(authenticated_user_id))
return web.json_response({"ok": True, "token": token})
async def email_auth_request_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
lang = _normalize_language(str(payload.get("language") or settings.DEFAULT_LANGUAGE))
return await _request_email_code(
request,
email=email,
purpose="login",
language_code=lang,
target_user_id=None,
)
async def email_auth_verify_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
code = str(payload.get("code") or "")
email_service: EmailAuthService = request.app["email_auth_service"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="login",
code=code,
target_user_id=None,
)
if not verify_result.ok:
await session.rollback()
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
db_user = await user_dal.get_user_by_email(session, email)
if not db_user:
db_user, _ = await user_dal.create_email_user(
session,
email=email,
language_code=_normalize_language(settings.DEFAULT_LANGUAGE),
email_verified_at=datetime.now(timezone.utc),
)
elif not db_user.email_verified_at:
db_user.email_verified_at = datetime.now(timezone.utc)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("Email WebApp auth failed: %s", exc, exc_info=True)
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(db_user.user_id))
return web.json_response(
{
"ok": True,
"token": token,
"user_id": int(db_user.user_id),
"telegram_id": _telegram_id_for_user(db_user),
}
)
async def account_email_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
if db_user.email == email and db_user.email_verified_at:
return web.json_response({"ok": True, "already_linked": True})
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return await _request_email_code(
request,
email=email,
purpose="link_email",
language_code=lang,
target_user_id=user_id,
)
async def account_email_verify_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
code = str(payload.get("code") or "")
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="link_email",
code=code,
target_user_id=user_id,
)
if not verify_result.ok:
await session.rollback()
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
current_user = await user_dal.get_user_by_id(session, user_id)
if not current_user or current_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
existing_email_user = await user_dal.get_user_by_email(session, email)
if existing_email_user and existing_email_user.user_id != current_user.user_id:
current_user = await user_dal.merge_users(
session,
source_user_id=existing_email_user.user_id,
target_user_id=current_user.user_id,
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception as exc:
await session.rollback()
logger.error("Email account link failed: %s", exc, exc_info=True)
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(current_user.user_id))
return web.json_response({"ok": True, "token": token})
async def account_telegram_link_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
init_data = str(payload.get("init_data") or "")
auth_data = payload.get("auth_data")
telegram_user = None
if init_data:
telegram_user = validate_telegram_webapp_init_data(
init_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
elif auth_data is not None:
telegram_user = validate_telegram_login_widget_data(
auth_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await _link_telegram_to_user(
request,
session,
current_user_id=user_id,
telegram_user=telegram_user,
settings=settings,
)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
await session.commit()
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception as exc:
await session.rollback()
logger.error("Telegram account link failed: %s", exc, exc_info=True)
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(db_user.user_id))
return web.json_response(
{
"ok": True,
"token": token,
"user_id": int(db_user.user_id),
"telegram_id": _telegram_id_for_user(db_user),
}
)
async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
data = await _build_user_payload(request, user_id)
@@ -448,6 +655,183 @@ def _require_user_id(request: web.Request) -> int:
return user_id
async def _request_email_code(
request: web.Request,
*,
email: str,
purpose: str,
language_code: str,
target_user_id: Optional[int],
) -> web.Response:
email_service: EmailAuthService = request.app["email_auth_service"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
result = await email_service.request_code(
session,
email=email,
purpose=purpose,
language_code=language_code,
target_user_id=target_user_id,
)
if not result.ok:
await session.rollback()
status = 429 if result.error == "rate_limited" else 400
if result.error == "email_auth_not_configured":
status = 503
return web.json_response(
{
"ok": False,
"error": result.error,
"retry_after": result.retry_after,
},
status=status,
)
await session.commit()
return web.json_response({"ok": True})
except Exception as exc:
await session.rollback()
logger.error("Failed to send email verification code: %s", exc, exc_info=True)
return _json_error(502, "email_send_failed", "Failed to send email")
def _telegram_id_for_user(user: User) -> Optional[int]:
if user.telegram_id:
return int(user.telegram_id)
if user.user_id and int(user.user_id) > 0:
return int(user.user_id)
return None
def _panel_description_for_user(user: User) -> str:
lines = [
user.email or "",
user.username or "",
user.first_name or "",
user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
async def _sync_panel_identity_for_user(request: web.Request, user: User) -> None:
if not user.panel_user_uuid:
return
subscription_service: SubscriptionService = request.app.get("subscription_service")
if not subscription_service or not subscription_service.panel_service:
return
payload: Dict[str, Any] = {
"description": _panel_description_for_user(user),
}
telegram_id = _telegram_id_for_user(user)
if telegram_id:
payload["telegramId"] = telegram_id
if user.email:
payload["email"] = user.email
try:
await subscription_service.panel_service.update_user_details_on_panel(
user.panel_user_uuid,
payload,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to sync linked identities to panel for user %s: %s",
user.user_id,
exc,
)
def _apply_telegram_profile_to_user(
user: User,
telegram_user: Dict[str, Any],
settings: Settings,
) -> None:
language_code = telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = user.language_code or settings.DEFAULT_LANGUAGE
user.telegram_id = int(telegram_user["id"])
user.username = sanitize_username(telegram_user.get("username"))
user.first_name = sanitize_display_name(telegram_user.get("first_name"))
user.last_name = sanitize_display_name(telegram_user.get("last_name"))
user.language_code = language_code
async def _link_telegram_to_user(
request: web.Request,
session: AsyncSession,
*,
current_user_id: int,
telegram_user: Dict[str, Any],
settings: Settings,
) -> User:
telegram_id = int(telegram_user["id"])
current_user = await user_dal.get_user_by_id(session, current_user_id)
if not current_user:
raise ValueError("Current user not found.")
existing_telegram_user = await user_dal.get_user_by_telegram_id(session, telegram_id)
if not existing_telegram_user:
existing_telegram_user = await user_dal.get_user_by_id(session, telegram_id)
if existing_telegram_user and existing_telegram_user.user_id != current_user.user_id:
if (
current_user.email
and existing_telegram_user.email
and current_user.email != existing_telegram_user.email
):
raise UserMergeConflictError(
"Telegram account is already linked to a different email."
)
merged_user = await user_dal.merge_users(
session,
source_user_id=current_user.user_id,
target_user_id=existing_telegram_user.user_id,
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0:
language_code = telegram_user.get("language_code") or current_user.language_code or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
target_user, _ = await user_dal.create_user(
session,
{
"user_id": telegram_id,
"telegram_id": telegram_id,
"username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code,
"registration_date": current_user.registration_date or datetime.now(timezone.utc),
},
)
target_user.referral_code = None
await session.flush()
merged_user = await user_dal.merge_users(
session,
source_user_id=current_user.user_id,
target_user_id=target_user.user_id,
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
raise UserMergeConflictError("Current account is already linked to Telegram.")
_apply_telegram_profile_to_user(current_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, current_user)
return current_user
async def _ensure_user_from_telegram(
session: AsyncSession,
telegram_user: Dict[str, Any],
@@ -459,13 +843,16 @@ async def _ensure_user_from_telegram(
language_code = settings.DEFAULT_LANGUAGE
update_data = {
"telegram_id": user_id,
"username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code,
}
db_user = await user_dal.get_user_by_id(session, user_id)
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
db_user, _ = await user_dal.create_user(
session,
@@ -483,7 +870,7 @@ async def _ensure_user_from_telegram(
if getattr(db_user, key) != value
}
if changed:
db_user = await user_dal.update_user(session, user_id, changed) or db_user
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
return db_user
@@ -518,6 +905,10 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"user": {
"id": user_id,
"username": db_user.username,
"email": db_user.email,
"email_verified": bool(db_user.email_verified_at),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"first_name": db_user.first_name,
"language_code": lang,
},
@@ -527,6 +918,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"settings": {
"support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode),
"email_auth_enabled": settings.email_auth_configured,
},
}
@@ -176,6 +176,96 @@
gap: 16px;
}
.account-panel {
padding: 16px;
display: grid;
gap: 14px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.section-title {
font-size: 18px;
font-weight: 850;
line-height: 1.2;
}
.account-grid {
display: grid;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.account-row {
min-height: 46px;
padding: 10px 12px;
display: grid;
grid-template-columns: minmax(0, 0.68fr) minmax(0, 1.32fr);
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.02);
}
.account-row + .account-row {
border-top: 1px solid var(--border);
}
.link-box,
.field-stack {
display: grid;
gap: 8px;
}
.field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
gap: 8px;
align-items: stretch;
}
.input {
width: 100%;
min-height: 46px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.035);
color: var(--text-primary);
padding: 11px 12px;
font: inherit;
font-weight: 700;
outline: none;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.input:focus {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.input[aria-invalid="true"] {
border-color: color-mix(in srgb, var(--danger) 72%, var(--border));
}
.input[aria-invalid="true"]:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent);
}
.input::placeholder {
color: var(--text-muted);
}
.code-input {
font-family: var(--font-mono);
text-align: center;
letter-spacing: 0;
}
.badge-row {
display: flex;
align-items: center;
@@ -474,6 +564,22 @@
gap: 14px;
}
.code-modal-stack {
position: relative;
z-index: 1;
width: min(100%, 420px);
display: grid;
gap: 10px;
justify-items: stretch;
}
.code-modal-card {
width: 100%;
padding: 16px;
display: grid;
gap: 14px;
}
.flow-head {
display: flex;
align-items: flex-start;
@@ -493,6 +599,91 @@
font-size: 13px;
}
.code-summary {
min-height: 46px;
padding: 10px 12px;
display: grid;
grid-template-columns: minmax(0, 0.6fr) minmax(0, 1.4fr);
align-items: center;
gap: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
}
.otp-field {
position: relative;
display: block;
min-height: 56px;
}
.otp-input {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
border: 0;
background: transparent;
color: transparent;
caret-color: transparent;
opacity: 0.01;
outline: none;
}
.otp-slots {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.otp-slots span {
min-width: 0;
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.035);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 20px;
font-weight: 850;
line-height: 1;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.otp-slots span.filled {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
}
.otp-slots span.active {
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.code-modal-actions {
display: grid;
gap: 8px;
}
.code-modal-resend {
justify-self: center;
min-height: 28px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 13px;
font-weight: 750;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
text-underline-offset: 3px;
transition: color var(--transition), opacity var(--transition);
}
.stepper {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -654,6 +845,36 @@
text-align: center;
}
.auth-tabs {
min-height: 46px;
padding: 4px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
}
.auth-tab {
min-height: 36px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-weight: 850;
transition: color var(--transition), background var(--transition);
}
.auth-tab.active {
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
}
.auth-pane {
display: grid;
gap: 10px;
}
.login-brand {
display: flex;
align-items: center;
@@ -747,6 +968,10 @@
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent);
}
.code-modal-resend:hover:not(:disabled) {
color: var(--accent);
}
}
@media (max-width: 380px) {
@@ -767,6 +992,11 @@
grid-template-columns: 1fr;
}
.account-row,
.field-row {
grid-template-columns: 1fr;
}
.metric-value {
text-align: left;
}
@@ -775,6 +1005,18 @@
grid-template-columns: 1fr;
}
.code-summary {
grid-template-columns: 1fr;
}
.otp-slots {
gap: 6px;
}
.otp-slots span {
font-size: 18px;
}
.stepper {
gap: 6px;
}
@@ -830,6 +1072,30 @@
border-radius: 0;
}
.auth-code-modal {
align-items: center;
justify-items: center;
top: 0;
bottom: 0;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
}
.auth-code-modal .code-modal-stack {
width: min(100%, 420px);
}
.auth-code-modal .modal-card {
width: 100%;
height: auto;
max-height: min(90vh, 620px);
border-radius: var(--radius-lg);
transform: translateY(12px) scale(0.98);
}
.auth-code-modal.show .modal-card {
transform: translateY(0) scale(1);
}
.legal-link {
flex: 1 1 100%;
}
+88 -1
View File
@@ -74,6 +74,42 @@
</div>
</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>
<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>
@@ -134,13 +170,64 @@
</section>
</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">
<div class="panel login-card">
<div class="login-brand">
<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>
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></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>
<div id="auth-status" class="login-text login-status hidden" aria-live="polite"></div>
</div>
<div id="legal-links-login" class="legal-links hidden">
+613 -7
View File
@@ -9,13 +9,19 @@ window.__WEBAPP_DEV_MOCK__ = {
privacyPolicyUrl: 'https://example.com/privacy',
userAgreementUrl: 'https://example.com/agreement',
currency: 'RUB',
language: 'ru'
language: 'ru',
emailAuthEnabled: true,
telegramLoginBotUsername: 'preview_bot'
},
data: {
ok: true,
user: {
id: 100200300,
username: 'preview',
email: '',
email_verified: false,
telegram_id: 100200300,
telegram_linked: true,
first_name: 'Preview',
language_code: 'ru'
},
@@ -45,7 +51,8 @@ window.__WEBAPP_DEV_MOCK__ = {
],
settings: {
support_url: 'https://t.me/support',
traffic_mode: false
traffic_mode: false,
email_auth_enabled: true
}
}
};
@@ -70,6 +77,16 @@ const MOCK = (() => {
paymentStep: 'plan',
creatingPayment: 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
};
@@ -85,9 +102,10 @@ const MOCK = (() => {
traffic: 'Трафик',
connect: 'Подключиться',
copy_link: 'Скопировать ссылку',
extend_subscription: 'Продлить подписку/Добавить дни',
extend_subscription: 'Купить подписку / Добавить дни',
payment_title: 'Оплата подписки',
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
close: 'Закрыть',
close_payment: 'Закрыть оплату',
payment_steps: 'Шаги оплаты',
step_plan: 'Срок',
@@ -103,6 +121,35 @@ const MOCK = (() => {
check_payment: 'Проверить оплату',
choose_other_method: 'Выбрать другой способ',
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_verifying: 'Проверяю вход...',
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
@@ -145,6 +192,7 @@ const MOCK = (() => {
extend_subscription: 'Renew subscription/Add days',
payment_title: 'Subscription payment',
payment_caption: 'Choose a period, payment method, and create a payment.',
close: 'Close',
close_payment: 'Close payment',
payment_steps: 'Payment steps',
step_plan: 'Period',
@@ -160,6 +208,35 @@ const MOCK = (() => {
check_payment: 'Check payment',
choose_other_method: 'Choose another method',
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_verifying: 'Verifying login...',
telegram_auth_failed: 'Could not verify Telegram login. Try again.',
@@ -213,8 +290,14 @@ const MOCK = (() => {
} catch (e) { }
}
bindEmailLoginInput();
bindEmailCodeInput();
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.paymentFlowOpen) {
if (event.key !== 'Escape') return;
if (state.emailLoginCodeModalOpen) {
closeEmailLoginCodeModal();
} else if (state.paymentFlowOpen) {
closePaymentFlow();
}
});
@@ -339,6 +422,34 @@ const MOCK = (() => {
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() {
const container = document.getElementById('telegram-login-widget');
if (!container) return;
@@ -368,13 +479,250 @@ const MOCK = (() => {
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 = {}) {
const resetStatus = options.resetStatus !== false;
showLogin();
if (resetStatus) {
setAuthStatus('');
}
renderTelegramLoginWidget();
renderAuthMode();
}
async function loadData() {
@@ -390,6 +738,7 @@ const MOCK = (() => {
state.selectedMethod = null;
state.payment = null;
state.paymentStep = 'plan';
state.telegramLinkRendered = false;
render();
showApp();
}
@@ -401,6 +750,7 @@ const MOCK = (() => {
function render() {
renderSubscription(state.data.subscription);
renderAccount(state.data.user || {});
renderPaymentFlow();
}
@@ -414,6 +764,168 @@ const MOCK = (() => {
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() {
if (state.paymentFlowOpen) {
closePaymentFlow();
@@ -438,7 +950,7 @@ const MOCK = (() => {
const modal = document.getElementById('payment-modal');
if (!state.paymentFlowOpen) {
modal.classList.remove('show');
document.body.classList.remove('modal-open');
syncModalLock();
window.setTimeout(() => {
if (!state.paymentFlowOpen) modal.classList.add('hidden');
}, 180);
@@ -446,7 +958,7 @@ const MOCK = (() => {
}
modal.classList.remove('hidden');
document.body.classList.add('modal-open');
syncModalLock();
window.requestAnimationFrame(() => modal.classList.add('show'));
applyI18n(modal);
renderStepState();
@@ -455,6 +967,13 @@ const MOCK = (() => {
renderPaymentResult();
}
function syncModalLock() {
document.body.classList.toggle(
'modal-open',
Boolean(state.paymentFlowOpen || state.emailLoginCodeModalOpen)
);
}
function renderStepState() {
const order = ['plan', 'method', 'result'];
order.forEach((step, index) => {
@@ -694,11 +1213,42 @@ const MOCK = (() => {
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 = {}) {
await new Promise(resolve => window.setTimeout(resolve, 120));
if (path === '/me') {
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') {
return {
ok: true,
@@ -728,9 +1278,33 @@ const MOCK = (() => {
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() {
clearToken();
closePaymentFlow();
closeEmailLoginCodeModal();
startExternalAuth();
}
@@ -767,6 +1341,35 @@ const MOCK = (() => {
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) {
if (tg && tg.showAlert) {
tg.showAlert(message);
@@ -826,6 +1429,9 @@ const MOCK = (() => {
root.querySelectorAll('[data-aria-i18n]').forEach(node => {
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) {
+6 -3
View File
@@ -23,6 +23,7 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_logs_router")
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,
@@ -225,12 +226,14 @@ async def process_user_id_for_logs_handler(message: types.Message,
input_text = message.text.strip() if message.text else ""
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:
user_model_for_logs = await user_dal.get_user_by_id(
session, int(input_text))
except ValueError:
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:]):
user_model_for_logs = await user_dal.get_user_by_username(
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
user_display_name = user_model_for_logs.first_name or (
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(
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 (
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(
session, target_user_id, settings.LOGS_PAGE_SIZE,
+56 -4
View File
@@ -94,6 +94,7 @@ async def perform_sync(
"shortUuid"
)
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:
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
@@ -111,14 +112,25 @@ async def perform_sync(
# First, try to find by telegram ID if available
if telegram_id_from_panel:
existing_user = await user_dal.get_user_by_id(
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(
session, telegram_id_from_panel
)
if existing_user:
logging.debug(
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 existing_user:
existing_user = await user_dal.get_user_by_panel_uuid(
@@ -144,6 +156,8 @@ async def perform_sync(
try:
user_data = {
"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
"first_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}"
)
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:
logging.debug(
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(
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)
if (
@@ -206,11 +253,12 @@ async def perform_sync(
try:
if panel_uuid and existing_user:
description_text = "\n".join(
[
line for line in [
existing_user.email or "",
existing_user.username or "",
existing_user.first_name or "",
existing_user.last_name or "",
]
] if line
)
# Update description only when it differs from the current one on panel
current_panel_description = (
@@ -222,7 +270,11 @@ async def perform_sync(
and desired_description != current_panel_description
):
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:
logging.warning(
+26 -36
View File
@@ -31,6 +31,7 @@ from bot.utils.telegram_markup import (
router = Router(name="admin_user_management_router")
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]:
@@ -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)
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,
i18n_data: dict, settings: Settings,
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_name_label')} {hcode(user_name)}")
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_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)
input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None
# 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)
user_model = await _find_user_by_admin_input(session, input_text)
if not user_model:
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)
input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None
# 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)
user_model = await _find_user_by_admin_input(session, input_text)
if not user_model:
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)
input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None
# 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)
user_model = await _find_user_by_admin_input(session, input_text)
if not user_model:
await message.answer(_(
+4
View File
@@ -268,6 +268,8 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
user_display_parts.append(user_row.first_name)
if 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:
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 = []
if 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}")
if user.first_name:
user_display_parts.append(f"- {user.first_name}")
+14 -3
View File
@@ -22,13 +22,17 @@ class ProfileSyncMiddleware(BaseMiddleware):
if session and tg_user:
try:
db_user = await user_dal.get_user_by_id(session, tg_user.id)
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)
if db_user:
update_payload: Dict[str, Any] = {}
sanitized_username = sanitize_username(tg_user.username)
sanitized_first_name = sanitize_display_name(tg_user.first_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:
update_payload["username"] = sanitized_username
if db_user.first_name != sanitized_first_name:
@@ -37,7 +41,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
update_payload["last_name"] = sanitized_last_name
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(
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")
if panel_service and db_user.panel_user_uuid:
description_text = "\n".join([
db_user.email or "",
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
sanitized_first_name or "",
sanitized_last_name or "",
]).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(
db_user.panel_user_uuid,
{"description": description_text},
panel_payload,
)
except Exception as e_upd_desc:
logging.warning(
+328
View File
@@ -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)
+6 -3
View File
@@ -54,7 +54,10 @@ class PanelWebhookService:
return
async with self.async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
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
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:
async with self.async_session_factory() as session:
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':
try:
ok = await subscription_service.charge_subscription_renewal(session, sub)
@@ -89,7 +92,7 @@ class PanelWebhookService:
if days_left == 2:
async with self.async_session_factory() as session:
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(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id,
+75 -55
View File
@@ -96,6 +96,42 @@ class SubscriptionService:
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(
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
@@ -109,25 +145,45 @@ class SubscriptionService:
return None, None, None, False
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_created_or_linked_now = False
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
telegram_id=user_id
)
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(
telegram_id=telegram_id_for_panel
)
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]
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:
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
if not panel_user_obj_from_api:
if current_local_panel_uuid:
@@ -146,12 +202,9 @@ class SubscriptionService:
)
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=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 "",
]),
telegram_id=telegram_id_for_panel,
email=db_user.email,
description=self._panel_description_for_user(db_user),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
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(
username_on_panel=panel_username_on_panel_standard,
telegram_id=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 "",
]),
telegram_id=telegram_id_for_panel,
email=db_user.email,
description=self._panel_description_for_user(db_user),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
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_username_from_api = panel_user_obj_from_api.get("username")
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
if not actual_panel_uuid_from_api:
@@ -293,24 +342,15 @@ class SubscriptionService:
if (
panel_user_obj_from_api
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(
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(
current_local_panel_uuid,
{
"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 "",
]
),
},
self._panel_identity_payload_for_user(db_user),
)
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,
)
# Add user description based on Telegram profile
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 "",
]
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
@@ -525,13 +558,7 @@ class SubscriptionService:
traffic_limit_strategy="NO_RESET",
)
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 "",
]
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
@@ -695,14 +722,7 @@ class SubscriptionService:
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
)
# Add user description based on Telegram profile
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 "",
]
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload