feat: add magic login link to email with login codes
This commit is contained in:
@@ -71,6 +71,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/token",
|
||||
"/api/auth/email/request",
|
||||
"/api/auth/email/verify",
|
||||
"/api/auth/email/magic",
|
||||
"/api/auth/logout",
|
||||
}
|
||||
|
||||
@@ -93,6 +94,12 @@ class WebAppEmailCodePayload(WebAppEmailPayload):
|
||||
code: str = ""
|
||||
|
||||
|
||||
class WebAppEmailMagicPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
token: constr(min_length=8, max_length=512)
|
||||
|
||||
|
||||
class WebAppPaymentCreatePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -166,6 +173,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
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_post("/api/auth/email/magic", email_auth_magic_route)
|
||||
app.router.add_post("/api/auth/logout", logout_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
@@ -909,6 +917,113 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def email_auth_magic_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
magic_payload, validation_error = _validate_model_payload(WebAppEmailMagicPayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
token_value = str(magic_payload.token).strip()
|
||||
referral_param = str(payload.get("referral_code") or payload.get("start_param") or "")
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
created_user = False
|
||||
new_user_referrer_id: Optional[int] = None
|
||||
verified_email: Optional[str] = None
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
magic_result = await email_service.verify_magic_token(
|
||||
session,
|
||||
token=token_value,
|
||||
purpose="login",
|
||||
target_user_id=None,
|
||||
)
|
||||
if not magic_result.ok:
|
||||
await session.commit()
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": magic_result.error or "invalid_token",
|
||||
"message": "Invalid login link",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
verified_email = magic_result.email or ""
|
||||
db_user = await user_dal.get_user_by_email(session, verified_email)
|
||||
if not db_user:
|
||||
referred_by_id = await _resolve_referrer_id(
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
email=verified_email,
|
||||
language_code=_normalize_language(settings.DEFAULT_LANGUAGE),
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
referred_by_id=referred_by_id,
|
||||
)
|
||||
created_user = True
|
||||
new_user_referrer_id = referred_by_id
|
||||
elif not db_user.email_verified_at:
|
||||
db_user.email_verified_at = datetime.now(timezone.utc)
|
||||
|
||||
referral_applied = await _apply_referral_to_existing_user(
|
||||
request,
|
||||
session,
|
||||
db_user,
|
||||
referral_param,
|
||||
)
|
||||
if created_user or referral_applied:
|
||||
await _apply_referral_welcome_bonus_if_needed(
|
||||
request,
|
||||
session,
|
||||
db_user,
|
||||
referral_param,
|
||||
)
|
||||
|
||||
if db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "banned", "Access denied")
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email magic-link auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
if created_user and verified_email:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
bot: Bot = request.app["bot"]
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
request.app.get("i18n"),
|
||||
)
|
||||
await notification_service.notify_new_email_user_registration(
|
||||
user_id=int(db_user.user_id),
|
||||
email=verified_email,
|
||||
referred_by_id=new_user_referrer_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send new email user notification")
|
||||
|
||||
session_token = create_webapp_session_token(settings, int(db_user.user_id))
|
||||
return _build_webapp_auth_response(
|
||||
settings,
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": int(db_user.user_id),
|
||||
"telegram_id": _telegram_id_for_user(db_user),
|
||||
},
|
||||
token=session_token,
|
||||
)
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
@@ -511,6 +511,14 @@ const MOCK = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const magicToken = readMagicLoginToken();
|
||||
if (magicToken) {
|
||||
const authenticated = await finalizeMagicLogin(magicToken);
|
||||
if (authenticated) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit logout should survive refresh, even if the old cookie session is still valid.
|
||||
if (isManuallyLoggedOut()) {
|
||||
await startExternalAuth();
|
||||
@@ -549,6 +557,54 @@ const MOCK = (() => {
|
||||
await startExternalAuth();
|
||||
}
|
||||
|
||||
function readMagicLoginToken() {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const token = (query.get('login_token') || '').trim();
|
||||
return token || null;
|
||||
}
|
||||
|
||||
function clearMagicLoginQuery() {
|
||||
const url = new URL(window.location.href);
|
||||
['login_token', 'login_purpose'].forEach(key => url.searchParams.delete(key));
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeMagicLogin(token) {
|
||||
if (state.authInProgress) return false;
|
||||
state.authInProgress = true;
|
||||
setAuthStatus(t('telegram_auth_verifying'));
|
||||
try {
|
||||
const payload = {token};
|
||||
if (state.referralParam) payload.referral_code = state.referralParam;
|
||||
const data = await publicApi('/auth/email/magic', payload);
|
||||
if (data && data.ok && data.token) {
|
||||
setToken(data.token, data.csrf_token);
|
||||
clearManualLogoutFlag();
|
||||
clearMagicLoginQuery();
|
||||
try {
|
||||
setAuthStatus('');
|
||||
await loadData();
|
||||
return true;
|
||||
} catch (e) {
|
||||
clearToken();
|
||||
setAuthStatus(t('telegram_auth_failed'), true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
clearMagicLoginQuery();
|
||||
setAuthStatus(emailErrorMessage(data, 'email_code_invalid'), true);
|
||||
return false;
|
||||
} catch (e) {
|
||||
clearMagicLoginQuery();
|
||||
setAuthStatus(t('telegram_auth_failed'), true);
|
||||
return false;
|
||||
} finally {
|
||||
state.authInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
function readTelegramLoginWidgetAuthData() {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
|
||||
@@ -2183,6 +2239,9 @@ const MOCK = (() => {
|
||||
if (path === '/auth/email/verify') {
|
||||
return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'};
|
||||
}
|
||||
if (path === '/auth/email/magic') {
|
||||
return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'};
|
||||
}
|
||||
if (path === '/account/email/verify') {
|
||||
MOCK.data.user.email = 'preview@example.com';
|
||||
MOCK.data.user.email_verified = true;
|
||||
|
||||
@@ -46,6 +46,15 @@ class EmailCodeVerifyResult:
|
||||
retry_after: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailMagicVerifyResult:
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
target_user_id: Optional[int] = None
|
||||
|
||||
|
||||
def normalize_email(value: str) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
@@ -98,6 +107,31 @@ class EmailAuthService:
|
||||
payload = f"{purpose}:{email}:{code}".encode("utf-8")
|
||||
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
||||
|
||||
def _hash_magic_token(self, token: str) -> str:
|
||||
secret = hmac.new(
|
||||
self.settings.BOT_TOKEN.encode("utf-8"),
|
||||
b"remnawave-tg-shop-email-magic",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _build_magic_link(self, *, token: str, purpose: str) -> Optional[str]:
|
||||
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
parsed = urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return None
|
||||
params = {"login_token": token}
|
||||
if purpose and purpose != "login":
|
||||
params["login_purpose"] = purpose
|
||||
existing_query = parsed.query
|
||||
new_query = urlencode(params)
|
||||
merged_query = f"{existing_query}&{new_query}" if existing_query else new_query
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment))
|
||||
|
||||
async def request_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -159,9 +193,12 @@ class EmailAuthService:
|
||||
)
|
||||
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
magic_token = secrets.token_urlsafe(32)
|
||||
magic_link = self._build_magic_link(token=magic_token, purpose=purpose)
|
||||
code_model = EmailVerificationCode(
|
||||
email=normalized_email,
|
||||
code_hash=self._hash_code(normalized_email, purpose, code),
|
||||
magic_token_hash=self._hash_magic_token(magic_token) if magic_link else None,
|
||||
purpose=purpose,
|
||||
target_user_id=target_user_id,
|
||||
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
|
||||
@@ -174,6 +211,7 @@ class EmailAuthService:
|
||||
email=normalized_email,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
return EmailCodeRequestResult(ok=True)
|
||||
|
||||
@@ -304,18 +342,74 @@ class EmailAuthService:
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def verify_magic_token(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
token: str,
|
||||
purpose: str,
|
||||
target_user_id: Optional[int] = None,
|
||||
) -> EmailMagicVerifyResult:
|
||||
if not token:
|
||||
return EmailMagicVerifyResult(ok=False, error="invalid_token")
|
||||
|
||||
token_hash = self._hash_magic_token(token)
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.magic_token_hash == token_hash,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
EmailVerificationCode.target_user_id == target_user_id,
|
||||
EmailVerificationCode.status == "active",
|
||||
EmailVerificationCode.consumed_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
return EmailMagicVerifyResult(ok=False, error="invalid_token")
|
||||
|
||||
expires_at = record.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < now:
|
||||
return EmailMagicVerifyResult(ok=False, error="expired_token")
|
||||
|
||||
record.consumed_at = now
|
||||
throttle_identifier = _email_throttle_identifier(
|
||||
record.email,
|
||||
purpose,
|
||||
target_user_id,
|
||||
)
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
await session.flush()
|
||||
return EmailMagicVerifyResult(
|
||||
ok=True,
|
||||
email=record.email,
|
||||
purpose=record.purpose,
|
||||
target_user_id=record.target_user_id,
|
||||
)
|
||||
|
||||
async def _send_code_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
language_code: str,
|
||||
magic_link: Optional[str] = None,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_code_email_sync,
|
||||
email=email,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
|
||||
async def send_custom_email(
|
||||
@@ -353,11 +447,13 @@ class EmailAuthService:
|
||||
email: str,
|
||||
code: str,
|
||||
language_code: str,
|
||||
magic_link: Optional[str] = None,
|
||||
) -> None:
|
||||
content = render_login_code(
|
||||
self.settings,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
|
||||
@@ -207,6 +207,7 @@ def render_login_code(
|
||||
*,
|
||||
code: str,
|
||||
language_code: Optional[str],
|
||||
magic_link: Optional[str] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
@@ -214,6 +215,7 @@ def render_login_code(
|
||||
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_magic_link = (magic_link or "").strip()
|
||||
|
||||
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
|
||||
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
|
||||
@@ -222,19 +224,47 @@ def render_login_code(
|
||||
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
|
||||
security = _t_text(i18n, lang, "email_login_code_security")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
text = _t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)
|
||||
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
|
||||
if safe_magic_link:
|
||||
text_lines.append(
|
||||
_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)
|
||||
)
|
||||
|
||||
body_html = f"""
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">
|
||||
<tr>
|
||||
<td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">
|
||||
<div style="font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">{html.escape(code)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>
|
||||
<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>
|
||||
"""
|
||||
code_block = (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">'
|
||||
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">'
|
||||
f'<div style="font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">'
|
||||
f'{html.escape(code)}'
|
||||
f'</div></td></tr></table>'
|
||||
)
|
||||
|
||||
magic_block = ""
|
||||
if safe_magic_link:
|
||||
cta_label = _t_text(i18n, lang, "email_login_code_magic_cta")
|
||||
divider_label = _t_text(i18n, lang, "email_login_code_magic_or")
|
||||
magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro")
|
||||
magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint")
|
||||
divider_html = (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">'
|
||||
f'<tr>'
|
||||
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </td>'
|
||||
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
|
||||
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </td>'
|
||||
f'</tr></table>'
|
||||
)
|
||||
magic_block = (
|
||||
divider_html
|
||||
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>'
|
||||
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
|
||||
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>'
|
||||
)
|
||||
|
||||
body_html = (
|
||||
code_block
|
||||
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>'
|
||||
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>'
|
||||
+ magic_block
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
@@ -244,7 +274,7 @@ def render_login_code(
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text=text, html=rendered)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_account_merged(
|
||||
|
||||
@@ -282,6 +282,27 @@ def _migration_0008_add_email_verification_code_status(connection: Connection) -
|
||||
)
|
||||
|
||||
|
||||
def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
|
||||
|
||||
if "magic_token_hash" not in columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR"
|
||||
)
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash
|
||||
ON email_verification_codes (magic_token_hash)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0009_add_composite_indexes(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
@@ -355,6 +376,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Add composite indexes for subscription and payment lookups",
|
||||
upgrade=_migration_0009_add_composite_indexes,
|
||||
),
|
||||
Migration(
|
||||
id="0010_add_email_magic_token_hash",
|
||||
description="Store hashed magic-link tokens for email login deeplinks",
|
||||
upgrade=_migration_0010_add_email_magic_token_hash,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ class EmailVerificationCode(Base):
|
||||
code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
email = Column(String, nullable=False, index=True)
|
||||
code_hash = Column(String, nullable=False)
|
||||
magic_token_hash = Column(String, nullable=True, index=True)
|
||||
purpose = Column(String, nullable=False, index=True)
|
||||
target_user_id = Column(
|
||||
BigInteger,
|
||||
|
||||
@@ -529,7 +529,12 @@
|
||||
"email_login_code_intro": "Use this 6-digit code to finish signing in to your subscription dashboard.",
|
||||
"email_login_code_expiry_html": "The code is valid for <strong style=\"color:#e6e9ef;\">{minutes} min</strong>.",
|
||||
"email_login_code_security": "If you didn't request this code, you can ignore this email — your account stays safe.",
|
||||
"email_login_code_magic_cta": "Sign in with one tap",
|
||||
"email_login_code_magic_intro": "Don't want to type the code? Sign in with this link.",
|
||||
"email_login_code_magic_hint": "The link is single-use and expires together with the code.",
|
||||
"email_login_code_magic_or": "or",
|
||||
"email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.",
|
||||
"email_login_code_text_magic": "Or sign in with this single-use link: {url}",
|
||||
|
||||
"email_account_merged_subject": "Accounts merged",
|
||||
"email_account_merged_preheader": "Your accounts were combined into one profile.",
|
||||
|
||||
@@ -529,7 +529,12 @@
|
||||
"email_login_code_intro": "Введите этот 6-значный код, чтобы завершить вход в личный кабинет подписки.",
|
||||
"email_login_code_expiry_html": "Код действует <strong style=\"color:#e6e9ef;\">{minutes} мин</strong>.",
|
||||
"email_login_code_security": "Если вы не запрашивали код, просто проигнорируйте это письмо — ваш аккаунт в безопасности.",
|
||||
"email_login_code_magic_cta": "Войти одним нажатием",
|
||||
"email_login_code_magic_intro": "Не хотите вводить код? Войдите по ссылке.",
|
||||
"email_login_code_magic_hint": "Ссылка одноразовая и действует столько же, сколько код.",
|
||||
"email_login_code_magic_or": "или",
|
||||
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
|
||||
"email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}",
|
||||
|
||||
"email_account_merged_subject": "Аккаунты объединены",
|
||||
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
|
||||
|
||||
Reference in New Issue
Block a user