feat: email login, smtp codes
This commit is contained in:
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(_(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user