Newer
Older
tgclient-mcp / frontend / src / pages / LoginWizard.vue
<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('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 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 = «сейчас идёт запрос» — на мёртвых сетях тик пропускается;
    // вне активных шагов (phone/done) поллинг не нужен
    if (polling.value || step.value === 'phone' || step.value === 'done') 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) => {
  console.log('[wiz] watch open ->', isOpen, 'loginId:', loginId.value, 'step:', step.value)
  if (isOpen) {
    reset()
  } else {
    stopPolling()
    stopCountdown()
    // закрыли без успеха (X/ESC/клик мимо) — не оставляем живую попытку логина:
    // Telegram auth.cancelCode + очистка строки (как кнопка «Отменить логин»)
    if (loginId.value && !['done', 'phone'].includes(step.value)) {
      api.cancelLogin(loginId.value).catch(() => {})
    }
  }
})

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
  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' },
    })
    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)
    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 || '' })
      reset()
      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') })
      reset()
      return
    }
  } catch (e) {
    if (e.status === 404 || e.status === 410) {
      // логин отменили/истёк извне — НЕ успех: честный тост об отмене
      stopPolling()
      toast.danger({ title: t('wizard.expired') })
      reset()
      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') {
    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
    }
  }
  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" :loading="cancelling" @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" :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>