<script setup>
// Логин-визард Telegram-аккаунта: две вкладки входа — SMS-код (POST /accounts/logins →
// poll → код → 2FA → done) и QR (POST /accounts/logins/qr → poll qr-state;
// код не шлётся вовсе — когда ТГ молчит по SMS/PHONE_CODE_FLOOD).
// Промах кода не закрывает визард: бэкенд возвращает attempts_left.
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import QRCode from 'qrcode'
import {
GnButton, GnInput, GnLoader, GnModal, GnTabs,
} from 'gnexus-ui-kit/vue'
import { useToast } from 'gnexus-ui-kit/vue'
import { api } from '../api.js'
import { t } from '../i18n/index.js'
const toast = useToast()
const open = defineModel('open', { type: Boolean, default: false })
const emit = defineEmits(['done'])
const step = ref('phone') // phone (вкладки входа) | code | password | done
const entryTab = ref('phone') // вкладка входа: phone (SMS) | qr
const phone = ref('')
const label = ref('')
const loginId = ref('')
const code = ref('')
const password = ref('')
const phoneMasked = ref('')
const attemptsLeft = ref(null)
const submitting = ref(false)
const cancelling = ref(false)
const polling = ref(false)
const qrImage = ref('') // dataURL QR-кода
// обратный отсчёт до истечения логина (GET /logins/{id} держит expires_in_sec)
const countdown = ref(0)
let countdownTimer = null
let pollTimer = null
function startPolling() {
stopPolling()
pollTimer = setInterval(async () => {
// polling = «сейчас идёт запрос» — на мёртвых сетях тик пропускается;
// вне активных шагов и вкладок поллинг не нужен
if (polling.value || step.value === 'done') return
try {
if (step.value === 'phone') {
// вкладка QR: поллится QR-статус, код/пароль-вкладка статуса не требует
if (entryTab.value === 'qr' && loginId.value) await refreshQrState()
return
}
await refreshState()
} catch { /* сеть мигнула — продолжаем */ }
}, 2000)
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
const untilText = computed(() => {
const secs = countdown.value
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m}:${String(s).padStart(2, '0')}`
})
function startCountdown(seconds) {
countdown.value = seconds || 0
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
countdown.value = Math.max(0, countdown.value - 1)
if (countdown.value <= 0) stopCountdown()
}, 1000)
}
function stopCountdown() {
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}
watch(open, (isOpen) => {
if (isOpen) {
reset()
} else {
stopPolling()
stopCountdown()
// закрыли без успеха (X/ESC/клик мимо) — не оставляем живую попытку логина:
// Telegram auth.cancelCode + очистка строки (как кнопка «Отменить логин»)
if (loginId.value && step.value !== 'done') {
api.cancelLogin(loginId.value).catch(() => {})
}
}
})
// переключение вкладок входа: открыли QR — логин стартует сразу;
// ушли обратно к телефону — QR-попытку отменяем (брошенные не копим)
watch(entryTab, (tab) => {
if (step.value !== 'phone') return
if (tab === 'qr') {
if (!loginId.value && !submitting.value) startQr()
return
}
if (loginId.value) {
api.cancelLogin(loginId.value).catch(() => {})
stopQrAttempt()
}
})
// остановить QR-попытку и вернуться к вкладке телефона
function stopQrAttempt() {
stopPolling()
loginId.value = ''
qrImage.value = ''
submitting.value = false
entryTab.value = 'phone'
}
function reset() {
step.value = 'phone'
entryTab.value = 'phone'
phone.value = ''
label.value = ''
loginId.value = ''
code.value = ''
password.value = ''
attemptsLeft.value = null
phoneMasked.value = ''
qrImage.value = ''
submitting.value = false
polling.value = false
cancelling.value = false
stopPolling()
stopCountdown()
}
async function start() {
submitting.value = true
try {
const data = await api.startLogin(phone.value.trim(), label.value.trim())
loginId.value = data.login_id
phoneMasked.value = data.phone_masked || phone.value
if (data.reused) {
toast.info({ title: t('wizard.reused') })
}
step.value = 'code'
await refreshState()
startPolling()
} catch (e) {
toast.danger({ title: t('common.failed'), text: e.message })
} finally {
submitting.value = false
}
}
async function startQr() {
submitting.value = true
try {
const data = await api.startQrLogin(label.value.trim())
loginId.value = data.login_id
qrImage.value = await QRCode.toDataURL(data.qr_url, {
width: 232, margin: 1, color: { dark: '#16161e', light: '#ffffff' },
})
startPolling()
} catch (e) {
toast.danger({ title: t('common.failed'), text: e.message })
stopQrAttempt()
} finally {
submitting.value = false
}
}
async function refreshState() {
polling.value = true
try {
const state = await api.loginState(loginId.value)
if (state.step === 'done') {
finish()
return
}
if (state.step === 'error') {
stopPolling()
toast.danger({ title: t('wizard.expired'), text: state.last_error || '' })
reset()
return
}
startCountdown(state.expires_in_sec)
phoneMasked.value = state.phone_masked || phoneMasked.value
if (state.step === 'awaiting_code' || state.step === 'awaiting_password') {
step.value = state.step === 'awaiting_password' ? 'password' : 'code'
if (state.step === 'awaiting_password') {
stopPolling()
}
}
} catch (e) {
if (e.status === 404 || e.status === 410) {
// логин отменили/истёк извне (другая вкладка, sweeper, рестарт)
stopPolling()
toast.danger({ title: t('wizard.expired') })
reset()
return
}
} finally {
polling.value = false
}
}
// poll вкладки QR: done (скан принят) → success; error → тост + вкладка телефона;
// 2FA → password
async function refreshQrState() {
polling.value = true
try {
const state = await api.qrLoginState(loginId.value)
if (state.step === 'done') {
finish()
return
}
if (state.step === 'error') {
stopPolling()
toast.danger({ title: t('wizard.qrFailed'), text: state.last_error || '' })
stopQrAttempt()
return
}
startCountdown(state.expires_in_sec)
if (state.step === 'awaiting_scan' && state.qr_url) {
const fresh = await QRCode.toDataURL(state.qr_url, {
width: 232, margin: 1, color: { dark: '#16161e', light: '#ffffff' },
})
if (fresh !== qrImage.value) qrImage.value = fresh
} else if (state.step === 'awaiting_password') {
phoneMasked.value = state.phone_masked || phoneMasked.value
step.value = 'password'
startPolling() // уже с login-state
return
} else if (state.step === 'awaiting_code') {
step.value = 'code'
startPolling()
return
} else if (state.step === 'awaiting_scan') {
// qr_url потерян (waiter умер) — ждать нечего
stopPolling()
toast.danger({ title: t('wizard.qrFailed') })
stopQrAttempt()
return
}
} catch (e) {
if (e.status === 404 || e.status === 410) {
// логин отменили/истёк извне — НЕ успех: честный тост об отмене
stopPolling()
toast.danger({ title: t('wizard.expired') })
stopQrAttempt()
return
}
} finally {
polling.value = false
}
}
async function submitCode() {
submitting.value = true
try {
const data = await api.submitLoginCode(loginId.value, code.value.trim())
if (data.step === 'done') {
finish()
return
}
if (data.step === 'awaiting_password') {
step.value = 'password'
stopPolling()
return
}
attemptsLeft.value = data.attempts_left ?? null
toast.danger({
title: t('wizard.wrongCode'),
text: data.attempts_left != null ? t('wizard.attemptsLeft', { n: data.attempts_left }) : '',
})
} catch (e) {
// 410 — логин отменён/expired: вернёмся к вкладкам входа
if (e.status === 410 || e.status === 404) {
toast.danger({ title: t('wizard.expired') })
reset()
return
}
toast.danger({ title: t('wizard.wrongCode'), text: e.message })
} finally {
submitting.value = false
}
}
async function submitPassword() {
submitting.value = true
try {
const data = await api.submitLoginPassword(loginId.value, password.value)
if (data.step === 'done') {
finish()
return
}
attemptsLeft.value = data.attempts_left ?? null
toast.danger({
title: t('wizard.wrongPassword'),
text: data.attempts_left != null ? t('wizard.attemptsLeft', { n: data.attempts_left }) : '',
})
} catch (e) {
if (e.status === 410 || e.status === 404) {
toast.danger({ title: t('wizard.expired') })
reset()
return
}
toast.danger({ title: t('wizard.wrongPassword'), text: e.message })
} finally {
submitting.value = false
}
}
function finish() {
step.value = 'done'
stopPolling()
stopCountdown()
toast.success({ title: t('wizard.success') })
emit('done')
// короткая пауза на визуалку success, затем закрыть
setTimeout(() => { open.value = false }, 800)
}
async function cancel() {
// «Отменить логин» на шаге кода/пароля: бэкенд шлёт Telegram auth.cancelCode
// (инвалидация кода) и чистит сессию; возвращаемся к вкладкам входа.
if (loginId.value) {
cancelling.value = true
try {
await api.cancelLogin(loginId.value)
loginId.value = '' // не даём watch'ам повторно отменять
} catch (e) {
if (e.status === 404 || e.status === 410) {
// уже отменёна/истекла — просто отходим
loginId.value = ''
} else {
toast.danger({ title: t('wizard.cancelFailed'), text: e.message })
cancelling.value = false
return // попытка ещё жива, можно повторить
}
} finally {
cancelling.value = false
}
}
stopPolling()
stopCountdown()
reset()
}
onBeforeUnmount(() => {
stopPolling()
stopCountdown()
})
</script>
<template>
<GnModal v-model:open="open" :title="t('wizard.title')">
<template v-if="step === 'phone'">
<GnTabs
v-model="entryTab"
:items="[
{ id: 'phone', label: t('wizard.tabPhone'), icon: 'ph-phone' },
{ id: 'qr', label: t('wizard.tabQr'), icon: 'ph-qr-code' },
]"
aria-label="login method"
>
<template #phone>
<p class="modal-hint">{{ t('wizard.subtitle') }}</p>
<GnInput
v-model="phone"
:label="t('wizard.phone')"
icon="ph-phone"
placeholder="+79991234567"
@keydown.enter="start"
/>
<GnInput
v-model="label"
:label="t('wizard.label')"
:placeholder="t('wizard.labelPlaceholder')"
@keydown.enter="start"
/>
<div class="actions-row">
<GnButton variant="secondary" @click="open = false">{{ t('common.cancel') }}</GnButton>
<GnButton
variant="primary"
icon="ph-paper-plane-tilt"
:loading="submitting"
:disabled="!phone.trim()"
@click="start"
>
{{ t('wizard.sendCode') }}
</GnButton>
</div>
</template>
<template #qr>
<div class="qr-row">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="Telegram login QR" />
<GnLoader v-else circle :label="t('common.loading')" />
</div>
<p class="modal-hint qr-hint">
{{ t('wizard.qrHint') }}
</p>
<div class="actions-row">
<GnButton variant="secondary" :loading="submitting" @click="stopQrAttempt">
{{ t('wizard.back') }}
</GnButton>
</div>
</template>
</GnTabs>
</template>
<template v-else-if="step === 'code'">
<p class="modal-hint">
{{ t('wizard.codeHint', { phone: phoneMasked, until: untilText }) }}
</p>
<GnInput
v-model="code"
:label="t('wizard.code')"
icon="ph-shield-check"
@keydown.enter="submitCode"
/>
<div class="actions-row">
<GnButton variant="secondary" :loading="cancelling" @click="cancel">
{{ t('wizard.cancel') }}
</GnButton>
<GnButton
variant="primary"
icon="ph-shield-check"
:loading="submitting"
:disabled="!code.trim()"
@click="submitCode"
>
{{ t('wizard.submitCode') }}
</GnButton>
</div>
</template>
<template v-else-if="step === 'password'">
<p class="modal-hint">{{ t('wizard.passwordHint') }}</p>
<GnInput
v-model="password"
type="password"
:label="t('wizard.password')"
icon="ph-lock-key"
@keydown.enter="submitPassword"
/>
<div class="actions-row">
<GnButton variant="secondary" :loading="cancelling" @click="cancel">
{{ t('wizard.cancel') }}
</GnButton>
<GnButton
variant="primary"
icon="ph-lock-key"
:loading="submitting"
:disabled="!password"
@click="submitPassword"
>
{{ t('wizard.submitPassword') }}
</GnButton>
</div>
</template>
<template v-else>
<div class="done-row">
<GnLoader circle class="done-loader" />
<p class="modal-hint">{{ t('wizard.done') }}</p>
</div>
</template>
</GnModal>
</template>
<style scoped>
.modal-hint {
margin: 0 0 14px;
opacity: 0.75;
font-size: 14px;
}
.actions-row {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 18px;
}
.done-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 0;
}
.done-loader {
justify-content: center;
}
.qr-row {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 0 10px;
}
.qr-img {
width: 232px;
height: 232px;
border-radius: 10px;
background: #fff;
}
.qr-hint {
margin: 0;
text-align: center;
}
</style>