diff --git a/app/resources/js/views/auth/TwoFactorView.vue b/app/resources/js/views/auth/TwoFactorView.vue index cc863608..2fa2d985 100644 --- a/app/resources/js/views/auth/TwoFactorView.vue +++ b/app/resources/js/views/auth/TwoFactorView.vue @@ -11,7 +11,7 @@ */ import { extractValidationErrors } from '../../api/client'; import { useAuthStore } from '../../stores/auth'; -import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'; +import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import { useRouter } from 'vue-router'; const code = ref(['', '', '', '', '', '']); @@ -27,12 +27,31 @@ const router = useRouter(); const userEmail = computed(() => auth.user?.email ?? 'аккаунт'); +/** + * TOTP-окно: код в приложении-аутентификаторе меняется каждые 30 секунд. + * Показываем честный обратный отсчёт до смены кода (заменяет хардкод «02:34»). + * Значение 30..1 секунд, формат «00:NN». + */ +function totpWindowLeft(): number { + return 30 - (Math.floor(Date.now() / 1000) % 30); +} +const totpSecondsLeft = ref(totpWindowLeft()); +const totpCountdown = computed(() => `00:${String(totpSecondsLeft.value).padStart(2, '0')}`); +let totpTimer: ReturnType | undefined; + // Если попали на /2fa без pending state (requires2fa=false и не залогинен) — // прямой URL без login → отправляем на /login. onMounted(() => { if (!auth.requires2fa && !auth.isAuthenticated) { router.replace('/login'); } + totpTimer = setInterval(() => { + totpSecondsLeft.value = totpWindowLeft(); + }, 1000); +}); + +onUnmounted(() => { + if (totpTimer) clearInterval(totpTimer); }); function onInput(index: number, event: Event) { @@ -126,7 +145,12 @@ async function handleSubmit() { Использовать резервный код - 02:34 + {{ totpCountdown }} { const links = wrapper.findAll('a').map((a) => a.text()); expect(links.some((t) => t.includes('резервный код'))).toBe(true); }); + + it('A6: показывает реальный обратный отсчёт TOTP-окна (30 с)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(10_000)); // epoch 10 c → 30 - (10 % 30) = 20 + try { + const wrapper = await mountTwoFactor(); + const el = wrapper.find('[data-testid="totp-countdown"]'); + expect(el.exists()).toBe(true); + expect(el.text()).toBe('00:20'); + + // Устанавливаем время так, чтобы после срабатывания интервала (+1000ms) + // Date.now() оказался на 15000 ms: 15000 - 1000 = 14000. + // epoch 15 c → 30 - (15 % 30) = 15 + vi.setSystemTime(new Date(14_000)); + vi.advanceTimersByTime(1000); // interval fires, Date.now() → 15000 + await wrapper.vm.$nextTick(); + expect(el.text()).toBe('00:15'); + } finally { + vi.useRealTimers(); + } + }); });