Newer
Older
tgclient-mcp / frontend / src / pages / LoginWizard.vue
<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>