<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,
} 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({ name: 'open', type: Boolean, default: false })
const emit = defineEmits(['done'])
const step = ref('phone') // phone | qr | code | password | done
const phone = ref('')
const label = ref('')
const loginId = ref('')
const code = ref('')
const password = ref('')
const phoneMasked = ref('')
const expiresAt = ref(null)
const attemptsLeft = ref(null)
const submitting = 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 () => {
if (!polling.value) return
try {
// QR-шаг опрашивает QR-статус, остальные — код/пароль-статус
if (step.value === 'qr') await refreshQrState()
else 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()
}
})
function reset() {
step.value = 'phone'
phone.value = ''
label.value = ''
loginId.value = ''
code.value = ''
password.value = ''
attemptsLeft.value = null
phoneMasked.value = ''
expiresAt.value = null
qrImage.value = ''
submitting.value = false
polling.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' },
})
step.value = 'qr'
startPolling()
} catch (e) {
toast.danger({ title: t('common.failed'), text: e.message })
} finally {
submitting.value = false
}
}
async function refreshState() {
polling.value = true
try {
const state = await api.loginState(loginId.value)
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' && step.value === 'password') {
stopPolling()
}
}
} finally {
polling.value = false
}
}
// poll QR-шага: скан принят (bэкенд финализирует сам) → done; 2FA → password;
// токен умер — бэкенд сам обновил qr_url, перерисовываем QR
async function refreshQrState() {
polling.value = true
try {
const state = await api.qrLoginState(loginId.value)
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
}
} catch (e) {
if (e.status === 404 || e.status === 410) {
// скан принят и финализирован (строка удалена) либо сгорела
stopPolling()
toast.success({ title: t('wizard.success') })
step.value = 'done'
emit('done')
setTimeout(() => { open.value = false }, 800)
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: вернёмся в phone-step
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 (инвалидация
// кода) и чистит сессию; 404/410 — попытка уже отменилась сама, не страшно.
if (loginId.value && step.value !== 'phone') {
try {
await api.cancelLogin(loginId.value)
} catch (e) {
if (e.status === 404 || e.status === 410) {
// уже отменёна/истекла — просто закрываем
} else {
toast.danger({ title: t('wizard.cancelFailed'), text: e.message })
return // модалку не закрываем: попытка ещё жива, можно повторить
}
}
}
open.value = false
}
onBeforeUnmount(() => {
stopPolling()
stopCountdown()
})
</script>
<template>
<GnModal v-model:open="open" :title="t('wizard.title')">
<template v-if="step === '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="cancel">{{ t('common.cancel') }}</GnButton>
<GnButton
variant="secondary"
icon="ph-qr-code"
:loading="submitting"
@click="startQr"
>
{{ t('wizard.viaQr') }}
</GnButton>
<GnButton
variant="primary"
icon="ph-paper-plane-tilt"
:loading="submitting"
:disabled="!phone.trim()"
@click="start"
>
{{ t('wizard.sendCode') }}
</GnButton>
</div>
</template>
<template v-else-if="step === '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" @click="cancel">{{ t('wizard.cancel') }}</GnButton>
</div>
</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" @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" @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>