<script setup>
// Логин-визард Telegram-аккаунта: POST /accounts/logins → poll статуса
// (обратный отсчёт по expires_in_sec) → код → при step awaiting_password —
// 2FA → done. Промах кода не закрывает визард: бэкенд возвращает attempts_left.
import { computed, onBeforeUnmount, ref, watch } from 'vue'
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 | 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)
// обратный отсчёт до истечения логина (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 {
await refreshState()
} catch { /* сеть мигнула — продолжаем */ }
}, 3000)
}
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
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 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
}
}
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() {
if (loginId.value && step.value !== 'phone') {
try {
await api.cancelLogin(loginId.value)
} catch { /* строка уже истекла — не страшно */ }
}
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="primary"
icon="ph-paper-plane-tilt"
:loading="submitting"
:disabled="!phone.trim()"
@click="start"
>
{{ t('wizard.sendCode') }}
</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;
}
</style>