From eaac2cf3290b7ff7b736b44fccc4c5a298ee79f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 16 May 2026 22:08:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth):=20A6=20=E2=80=94=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=82=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=20TOTP-=D0=BE=D0=BA=D0=BD=D0=B0=20=D0=B2=202FA=20(Sprint=205A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/resources/js/views/auth/TwoFactorView.vue | 28 +++++++++++++++++-- app/tests/Frontend/TwoFactorView.spec.ts | 23 ++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) 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(); + } + }); });