feat(auth): A6 — реальный обратный отсчёт TOTP-окна в 2FA (Sprint 5A)

This commit is contained in:
Дмитрий
2026-05-16 22:08:55 +03:00
parent 7e7ccfeedf
commit eaac2cf329
2 changed files with 48 additions and 3 deletions
+26 -2
View File
@@ -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<typeof setInterval> | 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() {
<RouterLink to="/recovery-use" class="text-body-2 text-primary">
Использовать резервный код
</RouterLink>
<span class="text-caption text-medium-emphasis font-mono">02:34</span>
<span
class="text-caption text-medium-emphasis font-mono"
:title="`До смены кода в приложении: ${totpCountdown}`"
data-testid="totp-countdown"
>{{ totpCountdown }}</span
>
</div>
<v-btn
+22 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
@@ -51,4 +51,25 @@ describe('TwoFactorView.vue', () => {
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();
}
});
});