feat: email login, smtp codes

This commit is contained in:
3252a8
2026-04-23 21:20:32 +03:00
parent b9cb1fec06
commit 807a8933b9
20 changed files with 2321 additions and 137 deletions
@@ -176,6 +176,96 @@
gap: 16px;
}
.account-panel {
padding: 16px;
display: grid;
gap: 14px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.section-title {
font-size: 18px;
font-weight: 850;
line-height: 1.2;
}
.account-grid {
display: grid;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.account-row {
min-height: 46px;
padding: 10px 12px;
display: grid;
grid-template-columns: minmax(0, 0.68fr) minmax(0, 1.32fr);
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.02);
}
.account-row + .account-row {
border-top: 1px solid var(--border);
}
.link-box,
.field-stack {
display: grid;
gap: 8px;
}
.field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
gap: 8px;
align-items: stretch;
}
.input {
width: 100%;
min-height: 46px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.035);
color: var(--text-primary);
padding: 11px 12px;
font: inherit;
font-weight: 700;
outline: none;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.input:focus {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.input[aria-invalid="true"] {
border-color: color-mix(in srgb, var(--danger) 72%, var(--border));
}
.input[aria-invalid="true"]:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent);
}
.input::placeholder {
color: var(--text-muted);
}
.code-input {
font-family: var(--font-mono);
text-align: center;
letter-spacing: 0;
}
.badge-row {
display: flex;
align-items: center;
@@ -474,6 +564,22 @@
gap: 14px;
}
.code-modal-stack {
position: relative;
z-index: 1;
width: min(100%, 420px);
display: grid;
gap: 10px;
justify-items: stretch;
}
.code-modal-card {
width: 100%;
padding: 16px;
display: grid;
gap: 14px;
}
.flow-head {
display: flex;
align-items: flex-start;
@@ -493,6 +599,91 @@
font-size: 13px;
}
.code-summary {
min-height: 46px;
padding: 10px 12px;
display: grid;
grid-template-columns: minmax(0, 0.6fr) minmax(0, 1.4fr);
align-items: center;
gap: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
}
.otp-field {
position: relative;
display: block;
min-height: 56px;
}
.otp-input {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
border: 0;
background: transparent;
color: transparent;
caret-color: transparent;
opacity: 0.01;
outline: none;
}
.otp-slots {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.otp-slots span {
min-width: 0;
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.035);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 20px;
font-weight: 850;
line-height: 1;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.otp-slots span.filled {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
}
.otp-slots span.active {
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.code-modal-actions {
display: grid;
gap: 8px;
}
.code-modal-resend {
justify-self: center;
min-height: 28px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 13px;
font-weight: 750;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
text-underline-offset: 3px;
transition: color var(--transition), opacity var(--transition);
}
.stepper {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -654,6 +845,36 @@
text-align: center;
}
.auth-tabs {
min-height: 46px;
padding: 4px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
}
.auth-tab {
min-height: 36px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-weight: 850;
transition: color var(--transition), background var(--transition);
}
.auth-tab.active {
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
}
.auth-pane {
display: grid;
gap: 10px;
}
.login-brand {
display: flex;
align-items: center;
@@ -747,6 +968,10 @@
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent);
}
.code-modal-resend:hover:not(:disabled) {
color: var(--accent);
}
}
@media (max-width: 380px) {
@@ -767,6 +992,11 @@
grid-template-columns: 1fr;
}
.account-row,
.field-row {
grid-template-columns: 1fr;
}
.metric-value {
text-align: left;
}
@@ -775,6 +1005,18 @@
grid-template-columns: 1fr;
}
.code-summary {
grid-template-columns: 1fr;
}
.otp-slots {
gap: 6px;
}
.otp-slots span {
font-size: 18px;
}
.stepper {
gap: 6px;
}
@@ -830,6 +1072,30 @@
border-radius: 0;
}
.auth-code-modal {
align-items: center;
justify-items: center;
top: 0;
bottom: 0;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
}
.auth-code-modal .code-modal-stack {
width: min(100%, 420px);
}
.auth-code-modal .modal-card {
width: 100%;
height: auto;
max-height: min(90vh, 620px);
border-radius: var(--radius-lg);
transform: translateY(12px) scale(0.98);
}
.auth-code-modal.show .modal-card {
transform: translateY(0) scale(1);
}
.legal-link {
flex: 1 1 100%;
}
+88 -1
View File
@@ -74,6 +74,42 @@
</div>
</section>
<section class="panel account-panel">
<div class="panel-head">
<div>
<div class="section-title" data-i18n="account_title">Аккаунт</div>
<div class="flow-caption" data-i18n="account_caption">Способы входа</div>
</div>
</div>
<div class="account-grid">
<div class="account-row">
<div class="metric-label" data-i18n="email_label">Email</div>
<div id="account-email" class="metric-value">...</div>
</div>
<div class="account-row">
<div class="metric-label" data-i18n="telegram_label">Telegram</div>
<div id="account-telegram" class="metric-value">...</div>
</div>
</div>
<div id="email-link-box" class="link-box hidden">
<div class="field-row">
<input id="email-link-input" class="input" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
<button id="email-link-send-btn" class="btn" type="button" onclick="requestEmailLinkCode()" data-i18n="send_code">Отправить код</button>
</div>
<div id="email-link-code-row" class="field-row hidden">
<input id="email-link-code-input" class="input code-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
<button id="email-link-verify-btn" class="btn primary" type="button" onclick="verifyEmailLinkCode()" data-i18n="confirm">Подтвердить</button>
</div>
</div>
<div id="telegram-link-box" class="link-box hidden">
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
<div id="telegram-link-status" class="login-text login-status hidden" aria-live="polite"></div>
</div>
</section>
<a id="support-link" class="btn support-link hidden" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
<div id="legal-links-app" class="legal-links hidden">
<a class="legal-link" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
@@ -134,13 +170,64 @@
</section>
</div>
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()"></button>
<div class="code-modal-stack">
<section class="panel modal-card code-modal-card">
<div class="flow-head">
<div>
<div id="email-code-title" class="flow-title" data-i18n="email_code_title">Подтвердите вход</div>
<div id="email-code-caption" class="flow-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
</div>
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()">×</button>
</div>
<div class="code-summary">
<div class="metric-label" data-i18n="email_label">Email</div>
<div id="email-code-address" class="metric-value">...</div>
</div>
<label class="otp-field" for="email-login-code-input">
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
<span class="otp-slots" aria-hidden="true">
<span id="email-code-slot-0"></span>
<span id="email-code-slot-1"></span>
<span id="email-code-slot-2"></span>
<span id="email-code-slot-3"></span>
<span id="email-code-slot-4"></span>
<span id="email-code-slot-5"></span>
</span>
</label>
<div id="email-code-status" class="login-text login-status hidden" aria-live="polite"></div>
<div class="code-modal-actions">
<button id="email-login-verify-btn" class="btn primary full" type="button" onclick="verifyEmailLoginCode()" data-i18n="login">Войти</button>
</div>
</section>
<button id="email-login-resend-btn" class="code-modal-resend" type="button" onclick="resendEmailLoginCode()" data-i18n="resend_code">Отправить еще раз</button>
</div>
</div>
<section id="login" class="login">
<div class="panel login-card">
<div class="login-brand">
<img id="login-brand-logo" class="brand-logo brand-logo--lg hidden" data-brand-logo alt="" aria-hidden="true">
<div id="login-brand-title" class="login-brand-title" data-brand-title>Моя подписка</div>
</div>
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
<div class="auth-tabs" role="tablist">
<button id="email-auth-tab" class="auth-tab active" type="button" onclick="setAuthMode('email')" data-i18n="email_login_tab">Email</button>
<button id="telegram-auth-tab" class="auth-tab" type="button" onclick="setAuthMode('telegram')" data-i18n="telegram_login_tab">Telegram</button>
</div>
<div id="email-login-pane" class="auth-pane">
<div class="field-stack">
<input id="email-login-input" class="input" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
<button id="email-login-send-btn" class="btn primary full" type="button" onclick="requestEmailLoginCode()" data-i18n="send_code">Отправить код</button>
</div>
</div>
<div id="telegram-login-pane" class="auth-pane hidden">
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
</div>
<div id="auth-status" class="login-text login-status hidden" aria-live="polite"></div>
</div>
<div id="legal-links-login" class="legal-links hidden">
+613 -7
View File
@@ -9,13 +9,19 @@ window.__WEBAPP_DEV_MOCK__ = {
privacyPolicyUrl: 'https://example.com/privacy',
userAgreementUrl: 'https://example.com/agreement',
currency: 'RUB',
language: 'ru'
language: 'ru',
emailAuthEnabled: true,
telegramLoginBotUsername: 'preview_bot'
},
data: {
ok: true,
user: {
id: 100200300,
username: 'preview',
email: '',
email_verified: false,
telegram_id: 100200300,
telegram_linked: true,
first_name: 'Preview',
language_code: 'ru'
},
@@ -45,7 +51,8 @@ window.__WEBAPP_DEV_MOCK__ = {
],
settings: {
support_url: 'https://t.me/support',
traffic_mode: false
traffic_mode: false,
email_auth_enabled: true
}
}
};
@@ -70,6 +77,16 @@ const MOCK = (() => {
paymentStep: 'plan',
creatingPayment: false,
authInProgress: false,
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
emailLoginPending: false,
emailLoginEmail: '',
emailLoginCodeModalOpen: false,
emailLoginVerifying: false,
emailLoginResending: false,
emailLinkPending: false,
emailLinkEmail: '',
telegramLinkRendered: false,
telegramLinkInProgress: false,
toastTimer: null
};
@@ -85,9 +102,10 @@ const MOCK = (() => {
traffic: 'Трафик',
connect: 'Подключиться',
copy_link: 'Скопировать ссылку',
extend_subscription: 'Продлить подписку/Добавить дни',
extend_subscription: 'Купить подписку / Добавить дни',
payment_title: 'Оплата подписки',
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
close: 'Закрыть',
close_payment: 'Закрыть оплату',
payment_steps: 'Шаги оплаты',
step_plan: 'Срок',
@@ -103,6 +121,35 @@ const MOCK = (() => {
check_payment: 'Проверить оплату',
choose_other_method: 'Выбрать другой способ',
support: 'Поддержка',
account_title: 'Аккаунт',
account_caption: 'Способы входа',
email_label: 'Email',
telegram_label: 'Telegram',
linked: 'Привязан',
not_linked: 'Не привязан',
email_login_tab: 'Email',
telegram_login_tab: 'Telegram',
email_placeholder: 'mail@example.com',
code_placeholder: '000000',
send_code: 'Отправить код',
resend_code: 'Отправить еще раз',
confirm: 'Подтвердить',
login: 'Войти',
email_code_title: 'Подтвердите вход',
email_code_caption: 'Введите 6-значный код из письма.',
email_code_aria: 'Код подтверждения',
email_auth_disabled: 'Вход по email пока не настроен.',
email_required: 'Введите email',
email_invalid: 'Введите корректный email',
email_code_sending: 'Отправляю код...',
email_code_sent: 'Код отправлен на почту',
email_code_send_failed: 'Не удалось отправить код',
email_code_invalid: 'Неверный код',
email_code_expired: 'Код устарел',
email_rate_limited: 'Повторная отправка доступна через {seconds} сек.',
email_linked: 'Email привязан',
telegram_linked: 'Telegram привязан',
account_merge_conflict: 'Этот аккаунт уже связан с другими данными.',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Проверяю вход...',
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
@@ -145,6 +192,7 @@ const MOCK = (() => {
extend_subscription: 'Renew subscription/Add days',
payment_title: 'Subscription payment',
payment_caption: 'Choose a period, payment method, and create a payment.',
close: 'Close',
close_payment: 'Close payment',
payment_steps: 'Payment steps',
step_plan: 'Period',
@@ -160,6 +208,35 @@ const MOCK = (() => {
check_payment: 'Check payment',
choose_other_method: 'Choose another method',
support: 'Support',
account_title: 'Account',
account_caption: 'Sign-in methods',
email_label: 'Email',
telegram_label: 'Telegram',
linked: 'Linked',
not_linked: 'Not linked',
email_login_tab: 'Email',
telegram_login_tab: 'Telegram',
email_placeholder: 'mail@example.com',
code_placeholder: '000000',
send_code: 'Send code',
resend_code: 'Send again',
confirm: 'Confirm',
login: 'Log in',
email_code_title: 'Confirm login',
email_code_caption: 'Enter the 6-digit code from the email.',
email_code_aria: 'Verification code',
email_auth_disabled: 'Email sign-in is not configured yet.',
email_required: 'Enter your email',
email_invalid: 'Enter a valid email address',
email_code_sending: 'Sending code...',
email_code_sent: 'Code sent to email',
email_code_send_failed: 'Could not send code',
email_code_invalid: 'Invalid code',
email_code_expired: 'Code expired',
email_rate_limited: 'Try again in {seconds} sec.',
email_linked: 'Email linked',
telegram_linked: 'Telegram linked',
account_merge_conflict: 'This account is already linked to different data.',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Verifying login...',
telegram_auth_failed: 'Could not verify Telegram login. Try again.',
@@ -213,8 +290,14 @@ const MOCK = (() => {
} catch (e) { }
}
bindEmailLoginInput();
bindEmailCodeInput();
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.paymentFlowOpen) {
if (event.key !== 'Escape') return;
if (state.emailLoginCodeModalOpen) {
closeEmailLoginCodeModal();
} else if (state.paymentFlowOpen) {
closePaymentFlow();
}
});
@@ -339,6 +422,34 @@ const MOCK = (() => {
await finalizeTelegramAuth(user, 'auth_data');
}
function setAuthMode(mode) {
state.authMode = mode === 'telegram' ? 'telegram' : 'email';
renderAuthMode();
}
function renderAuthMode() {
const emailTab = document.getElementById('email-auth-tab');
const telegramTab = document.getElementById('telegram-auth-tab');
const emailPane = document.getElementById('email-login-pane');
const telegramPane = document.getElementById('telegram-login-pane');
const emailEnabled = CFG.emailAuthEnabled !== false;
if (!emailEnabled && state.authMode === 'email') {
state.authMode = 'telegram';
}
emailTab.classList.toggle('active', state.authMode === 'email');
telegramTab.classList.toggle('active', state.authMode === 'telegram');
emailPane.classList.toggle('hidden', state.authMode !== 'email');
telegramPane.classList.toggle('hidden', state.authMode !== 'telegram');
emailTab.disabled = !emailEnabled;
if (state.authMode === 'telegram') {
renderTelegramLoginWidget();
} else if (!emailEnabled) {
setAuthStatus(t('email_auth_disabled'), true);
}
}
function renderTelegramLoginWidget() {
const container = document.getElementById('telegram-login-widget');
if (!container) return;
@@ -368,13 +479,250 @@ const MOCK = (() => {
container.appendChild(script);
}
function bindEmailLoginInput() {
const input = document.getElementById('email-login-input');
if (!input) return;
input.addEventListener('keydown', event => {
if (event.key !== 'Enter') return;
event.preventDefault();
requestEmailLoginCode();
});
input.addEventListener('input', () => {
if (input.getAttribute('aria-invalid') !== 'true') return;
input.removeAttribute('aria-invalid');
setAuthStatus('');
});
}
function bindEmailCodeInput() {
const input = document.getElementById('email-login-code-input');
if (!input) return;
input.addEventListener('input', () => {
input.value = sanitizeCode(input.value);
updateEmailLoginCodeSlots();
});
input.addEventListener('focus', updateEmailLoginCodeSlots);
input.addEventListener('blur', updateEmailLoginCodeSlots);
input.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
if (sanitizeCode(input.value).length === 6 && !state.emailLoginVerifying) {
verifyEmailLoginCode();
}
}
});
const field = input.closest('.otp-field');
if (field) {
field.addEventListener('click', () => input.focus());
}
}
function sanitizeCode(value) {
return String(value || '').replace(/\D/g, '').slice(0, 6);
}
function isValidEmail(value) {
const email = normalizeEmail(value);
return Boolean(email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
}
function readValidEmailLoginInput() {
const input = document.getElementById('email-login-input');
const email = normalizeEmail(input && input.value);
if (!email || !isValidEmail(email)) {
if (input) {
input.setAttribute('aria-invalid', 'true');
input.focus();
}
setAuthStatus(t(email ? 'email_invalid' : 'email_required'), true);
return '';
}
if (input) {
input.value = email;
input.removeAttribute('aria-invalid');
}
return email;
}
function openEmailLoginCodeModal(email, message, isError = false) {
state.emailLoginEmail = email;
state.emailLoginCodeModalOpen = true;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
renderEmailLoginCodeModal();
setEmailCodeStatus(message || t('email_code_sent'), isError);
window.setTimeout(() => {
const codeInput = document.getElementById('email-login-code-input');
if (state.emailLoginCodeModalOpen && codeInput) codeInput.focus();
}, 80);
}
function closeEmailLoginCodeModal() {
state.emailLoginCodeModalOpen = false;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
setEmailCodeStatus('');
renderEmailLoginCodeModal();
}
function renderEmailLoginCodeModal() {
const modal = document.getElementById('email-code-modal');
if (!modal) return;
if (!state.emailLoginCodeModalOpen) {
modal.classList.remove('show');
syncModalLock();
window.setTimeout(() => {
if (!state.emailLoginCodeModalOpen) modal.classList.add('hidden');
}, 180);
return;
}
const address = document.getElementById('email-code-address');
if (address) address.textContent = state.emailLoginEmail || '...';
modal.classList.remove('hidden');
syncModalLock();
applyI18n(modal);
updateEmailLoginCodeSlots();
window.requestAnimationFrame(() => modal.classList.add('show'));
}
function updateEmailLoginCodeSlots() {
const input = document.getElementById('email-login-code-input');
const value = sanitizeCode(input && input.value);
if (input && input.value !== value) input.value = value;
for (let index = 0; index < 6; index += 1) {
const slot = document.getElementById('email-code-slot-' + index);
if (!slot) continue;
const character = value[index] || '';
slot.textContent = character;
slot.classList.toggle('filled', Boolean(character));
slot.classList.toggle(
'active',
state.emailLoginCodeModalOpen
&& document.activeElement === input
&& index === Math.min(value.length, 5)
);
}
const verifyButton = document.getElementById('email-login-verify-btn');
if (verifyButton) {
verifyButton.disabled = value.length !== 6 || state.emailLoginVerifying || state.emailLoginPending;
}
const resendButton = document.getElementById('email-login-resend-btn');
if (resendButton) {
resendButton.disabled = state.emailLoginPending || state.emailLoginResending;
}
}
async function requestEmailLoginCode() {
if (state.emailLoginPending) return;
if (CFG.emailAuthEnabled === false) {
setAuthStatus(t('email_auth_disabled'), true);
return;
}
const email = readValidEmailLoginInput();
if (!email) {
return;
}
state.emailLoginPending = true;
setButtonBusy('email-login-send-btn', true);
openEmailLoginCodeModal(email, t('email_code_sending'));
setAuthStatus('');
try {
const data = await publicApi('/auth/email/request', {
email,
language: getLanguage()
});
if (!data.ok) throw data;
setEmailCodeStatus(t('email_code_sent'));
} catch (e) {
const message = emailErrorMessage(e, 'email_code_send_failed');
if (e && e.error === 'invalid_email') {
closeEmailLoginCodeModal();
const input = document.getElementById('email-login-input');
if (input) input.setAttribute('aria-invalid', 'true');
setAuthStatus(message, true);
} else {
setEmailCodeStatus(message, true);
}
} finally {
state.emailLoginPending = false;
setButtonBusy('email-login-send-btn', false);
updateEmailLoginCodeSlots();
}
}
async function resendEmailLoginCode() {
if (state.emailLoginPending || state.emailLoginResending) return;
const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value);
if (!email || !isValidEmail(email)) {
setEmailCodeStatus(t(email ? 'email_invalid' : 'email_required'), true);
return;
}
state.emailLoginResending = true;
updateEmailLoginCodeSlots();
setEmailCodeStatus(t('email_code_sending'));
try {
const data = await publicApi('/auth/email/request', {
email,
language: getLanguage()
});
if (!data.ok) throw data;
state.emailLoginEmail = email;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
setEmailCodeStatus(t('email_code_sent'));
} catch (e) {
setEmailCodeStatus(emailErrorMessage(e, 'email_code_send_failed'), true);
} finally {
state.emailLoginResending = false;
updateEmailLoginCodeSlots();
}
}
async function verifyEmailLoginCode() {
const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value);
const input = document.getElementById('email-login-code-input');
const code = sanitizeCode(input && input.value);
if (!email || code.length !== 6) {
setEmailCodeStatus(t('email_code_invalid'), true);
updateEmailLoginCodeSlots();
return;
}
state.emailLoginVerifying = true;
updateEmailLoginCodeSlots();
setEmailCodeStatus(t('telegram_auth_verifying'));
try {
const data = await publicApi('/auth/email/verify', {email, code});
if (!data.ok || !data.token) throw data;
setToken(data.token);
closeEmailLoginCodeModal();
await loadData();
} catch (e) {
clearToken();
setEmailCodeStatus(emailErrorMessage(e, 'email_code_invalid'), true);
} finally {
state.emailLoginVerifying = false;
updateEmailLoginCodeSlots();
}
}
function startExternalAuth(options = {}) {
const resetStatus = options.resetStatus !== false;
showLogin();
if (resetStatus) {
setAuthStatus('');
}
renderTelegramLoginWidget();
renderAuthMode();
}
async function loadData() {
@@ -390,6 +738,7 @@ const MOCK = (() => {
state.selectedMethod = null;
state.payment = null;
state.paymentStep = 'plan';
state.telegramLinkRendered = false;
render();
showApp();
}
@@ -401,6 +750,7 @@ const MOCK = (() => {
function render() {
renderSubscription(state.data.subscription);
renderAccount(state.data.user || {});
renderPaymentFlow();
}
@@ -414,6 +764,168 @@ const MOCK = (() => {
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
}
function renderAccount(user) {
const emailLinked = Boolean(user.email && user.email_verified);
const telegramLinked = Boolean(user.telegram_linked);
document.getElementById('account-email').textContent = emailLinked ? user.email : t('not_linked');
document.getElementById('account-telegram').textContent = telegramLinked
? (user.telegram_id ? String(user.telegram_id) : t('linked'))
: t('not_linked');
const emailBox = document.getElementById('email-link-box');
const telegramBox = document.getElementById('telegram-link-box');
emailBox.classList.toggle('hidden', emailLinked || !state.data.settings.email_auth_enabled);
telegramBox.classList.toggle('hidden', telegramLinked);
if (!telegramLinked) {
renderTelegramLinkWidget();
} else {
state.telegramLinkRendered = false;
}
}
async function requestEmailLinkCode() {
const input = document.getElementById('email-link-input');
const email = normalizeEmail(input.value);
if (!email) {
showToast(t('email_code_send_failed'));
return;
}
state.emailLinkPending = true;
setButtonBusy('email-link-send-btn', true);
try {
const data = await api('/account/email/request', {
method: 'POST',
body: JSON.stringify({email})
});
if (!data.ok) throw data;
if (data.already_linked) {
showToast(t('email_linked'));
await loadData();
return;
}
state.emailLinkEmail = email;
document.getElementById('email-link-code-row').classList.remove('hidden');
showToast(t('email_code_sent'));
} catch (e) {
showToast(emailErrorMessage(e, 'email_code_send_failed'));
} finally {
state.emailLinkPending = false;
setButtonBusy('email-link-send-btn', false);
}
}
async function verifyEmailLinkCode() {
const email = state.emailLinkEmail || normalizeEmail(document.getElementById('email-link-input').value);
const code = document.getElementById('email-link-code-input').value;
setButtonBusy('email-link-verify-btn', true);
try {
const data = await api('/account/email/verify', {
method: 'POST',
body: JSON.stringify({email, code})
});
if (!data.ok) throw data;
if (data.token) setToken(data.token);
showToast(t('email_linked'));
state.emailLinkEmail = '';
await loadData();
} catch (e) {
showToast(emailErrorMessage(e, 'email_code_invalid'));
} finally {
setButtonBusy('email-link-verify-btn', false);
}
}
function renderTelegramLinkWidget() {
const container = document.getElementById('telegram-link-widget');
if (!container || state.telegramLinkRendered) return;
container.innerHTML = '';
const botUsername = String(CFG.telegramLoginBotUsername || '').trim();
if (!botUsername) {
setTelegramLinkStatus(t('telegram_auth_unavailable'), true);
return;
}
window.onTelegramLinkAuth = async function(user) {
await linkTelegramAccount(user);
};
const script = document.createElement('script');
script.async = true;
script.src = TELEGRAM_LOGIN_WIDGET_URL;
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-userpic', 'false');
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-onauth', 'onTelegramLinkAuth(user)');
script.onerror = () => setTelegramLinkStatus(t('telegram_auth_unavailable'), true);
container.appendChild(script);
state.telegramLinkRendered = true;
}
async function linkTelegramAccount(user) {
if (!user || state.telegramLinkInProgress) return;
state.telegramLinkInProgress = true;
setTelegramLinkStatus(t('telegram_auth_verifying'));
try {
const data = await api('/account/telegram/link', {
method: 'POST',
body: JSON.stringify({auth_data: user})
});
if (!data.ok) throw data;
await finishTelegramLink(data, user.id);
} catch (e) {
if (await refreshLinkedTelegramAfterError()) {
setTelegramLinkStatus('');
try {
showToast(t('telegram_linked'));
} catch (toastError) {
console.warn('Telegram link success toast failed', toastError);
}
return;
}
setTelegramLinkStatus(emailErrorMessage(e, 'telegram_auth_failed'), true);
} finally {
state.telegramLinkInProgress = false;
}
}
async function finishTelegramLink(data, fallbackTelegramId) {
if (data.token) setToken(data.token);
markTelegramLinked(data.telegram_id || fallbackTelegramId);
setTelegramLinkStatus('');
state.telegramLinkRendered = false;
try {
showToast(t('telegram_linked'));
} catch (toastError) {
console.warn('Telegram link success toast failed', toastError);
}
try {
await loadData();
} catch (refreshError) {
console.warn('Telegram account linked, but data refresh failed', refreshError);
}
}
function markTelegramLinked(telegramId) {
if (!state.data || !state.data.user) return;
state.data.user.telegram_linked = true;
if (telegramId) {
state.data.user.telegram_id = Number(telegramId);
}
renderAccount(state.data.user);
}
async function refreshLinkedTelegramAfterError() {
try {
await loadData();
return Boolean(state.data && state.data.user && state.data.user.telegram_linked);
} catch (refreshError) {
console.warn('Telegram link status refresh failed', refreshError);
return false;
}
}
function togglePaymentFlow() {
if (state.paymentFlowOpen) {
closePaymentFlow();
@@ -438,7 +950,7 @@ const MOCK = (() => {
const modal = document.getElementById('payment-modal');
if (!state.paymentFlowOpen) {
modal.classList.remove('show');
document.body.classList.remove('modal-open');
syncModalLock();
window.setTimeout(() => {
if (!state.paymentFlowOpen) modal.classList.add('hidden');
}, 180);
@@ -446,7 +958,7 @@ const MOCK = (() => {
}
modal.classList.remove('hidden');
document.body.classList.add('modal-open');
syncModalLock();
window.requestAnimationFrame(() => modal.classList.add('show'));
applyI18n(modal);
renderStepState();
@@ -455,6 +967,13 @@ const MOCK = (() => {
renderPaymentResult();
}
function syncModalLock() {
document.body.classList.toggle(
'modal-open',
Boolean(state.paymentFlowOpen || state.emailLoginCodeModalOpen)
);
}
function renderStepState() {
const order = ['plan', 'method', 'result'];
order.forEach((step, index) => {
@@ -694,11 +1213,42 @@ const MOCK = (() => {
return response.json();
}
async function publicApi(path, payload = {}) {
if (MOCK) {
return mockApi(path, {
method: 'POST',
body: JSON.stringify(payload)
});
}
const response = await fetch(CFG.apiBase + path, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
return response.json();
}
async function mockApi(path, options = {}) {
await new Promise(resolve => window.setTimeout(resolve, 120));
if (path === '/me') {
return JSON.parse(JSON.stringify(MOCK.data));
}
if (path === '/auth/email/request' || path === '/account/email/request') {
return {ok: true};
}
if (path === '/auth/email/verify') {
return {ok: true, token: 'local-preview'};
}
if (path === '/account/email/verify') {
MOCK.data.user.email = 'preview@example.com';
MOCK.data.user.email_verified = true;
return {ok: true, token: 'local-preview'};
}
if (path === '/account/telegram/link') {
MOCK.data.user.telegram_linked = true;
MOCK.data.user.telegram_id = 100200300;
return {ok: true, token: 'local-preview'};
}
if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') {
return {
ok: true,
@@ -728,9 +1278,33 @@ const MOCK = (() => {
localStorage.removeItem('rw_webapp_token');
}
function normalizeEmail(value) {
return String(value || '').trim().toLowerCase();
}
function emailErrorMessage(error, fallbackKey) {
const code = error && error.error;
if (code === 'rate_limited') {
return t('email_rate_limited', {seconds: error.retry_after || 60});
}
if (code === 'invalid_email') return t('email_invalid');
if (code === 'expired_code') return t('email_code_expired');
if (code === 'invalid_code' || code === 'too_many_attempts') return t('email_code_invalid');
if (code === 'email_auth_not_configured') return t('email_auth_disabled');
if (code === 'account_merge_conflict') return t('account_merge_conflict');
if (code === 'invalid_auth') return t('telegram_auth_failed');
return t(fallbackKey);
}
function setButtonBusy(id, busy) {
const button = document.getElementById(id);
if (button) button.disabled = Boolean(busy);
}
function logout() {
clearToken();
closePaymentFlow();
closeEmailLoginCodeModal();
startExternalAuth();
}
@@ -767,6 +1341,35 @@ const MOCK = (() => {
status.classList.toggle('error', Boolean(isError));
}
function setEmailCodeStatus(message, isError = false) {
const status = document.getElementById('email-code-status');
if (!status) return;
if (!message) {
status.textContent = '';
status.classList.add('hidden');
status.classList.remove('error');
return;
}
status.textContent = message;
status.classList.remove('hidden');
status.classList.toggle('error', Boolean(isError));
}
function setTelegramLinkStatus(message, isError = false) {
const status = document.getElementById('telegram-link-status');
if (!status) return;
if (!message) {
status.textContent = '';
status.classList.add('hidden');
status.classList.remove('error');
return;
}
status.textContent = message;
status.classList.remove('hidden');
status.classList.toggle('error', Boolean(isError));
}
function showToast(message) {
if (tg && tg.showAlert) {
tg.showAlert(message);
@@ -826,6 +1429,9 @@ const MOCK = (() => {
root.querySelectorAll('[data-aria-i18n]').forEach(node => {
node.setAttribute('aria-label', t(node.dataset.ariaI18n));
});
root.querySelectorAll('[data-placeholder-i18n]').forEach(node => {
node.setAttribute('placeholder', t(node.dataset.placeholderI18n));
});
}
function applyLegalLinks(root = document) {