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) {
|
||||
|
||||
Reference in New Issue
Block a user