feat(auth): A6 — реальный обратный отсчёт TOTP-окна в 2FA (Sprint 5A)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user