220 lines
7.5 KiB
Vue
220 lines
7.5 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Экран 2FA — ввод 6-значного кода из приложения-аутентификатора.
|
||
*
|
||
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html секция #form-2fa.
|
||
* Источник логики: ТЗ v8.5 §1.6 / Прил. Г.4.2 — TOTP 6 цифр, 30-сек окно;
|
||
* после 5 неудачных попыток — блок аккаунта на 15 минут.
|
||
*
|
||
* UX: 6 input-cell'ов, авто-переход на следующую при вводе цифры,
|
||
* Backspace удаляет и переходит назад, paste 6 цифр заполняет все.
|
||
*/
|
||
import { extractValidationErrors } from '../../api/client';
|
||
import { useAuthStore } from '../../stores/auth';
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
|
||
const code = ref(['', '', '', '', '', '']);
|
||
// Vue 3.5 useTemplateRef() — Sprint 2 Phase B / O-stack-04. Заменяет
|
||
// `const inputs = ref<HTMLInputElement[]>([])`; имя ref'а в template = 'inputs'.
|
||
const inputs = useTemplateRef<HTMLInputElement[]>('inputs');
|
||
const codeFull = computed(() => code.value.join(''));
|
||
const canSubmit = computed(() => codeFull.value.length === 6 && /^\d{6}$/.test(codeFull.value));
|
||
const errors = ref<Record<string, string[]>>({});
|
||
|
||
const auth = useAuthStore();
|
||
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');
|
||
return;
|
||
}
|
||
totpTimer = setInterval(() => {
|
||
totpSecondsLeft.value = totpWindowLeft();
|
||
}, 1000);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (totpTimer) clearInterval(totpTimer);
|
||
});
|
||
|
||
function onInput(index: number, event: Event) {
|
||
const target = event.target as HTMLInputElement;
|
||
const v = target.value.replace(/\D/g, '').slice(-1);
|
||
code.value[index] = v;
|
||
if (v && index < 5) {
|
||
nextTick(() => inputs.value?.[index + 1]?.focus());
|
||
}
|
||
}
|
||
|
||
function onKeydown(index: number, event: KeyboardEvent) {
|
||
if (event.key === 'Backspace' && !code.value[index] && index > 0) {
|
||
nextTick(() => inputs.value?.[index - 1]?.focus());
|
||
}
|
||
}
|
||
|
||
function onPaste(event: ClipboardEvent) {
|
||
const pasted = event.clipboardData?.getData('text').replace(/\D/g, '').slice(0, 6) ?? '';
|
||
if (pasted.length === 6) {
|
||
event.preventDefault();
|
||
for (let i = 0; i < 6; i++) {
|
||
code.value[i] = pasted[i];
|
||
}
|
||
nextTick(() => inputs.value?.[5]?.focus());
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
errors.value = {};
|
||
try {
|
||
await auth.verifyTwoFactor(codeFull.value);
|
||
await router.push('/dashboard');
|
||
} catch (error: unknown) {
|
||
const validationErrors = extractValidationErrors(error);
|
||
if (validationErrors) {
|
||
errors.value = validationErrors;
|
||
} else {
|
||
errors.value = { code: ['Произошла ошибка. Попробуйте ещё раз.'] };
|
||
}
|
||
// Очищаем код и фокусим первую cell для повторного ввода.
|
||
code.value = ['', '', '', '', '', ''];
|
||
nextTick(() => inputs.value?.[0]?.focus());
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<v-card variant="flat" :max-width="380" width="100%" color="transparent" class="twofactor-card">
|
||
<header class="twofactor-header">
|
||
<h1 class="text-h5 mb-1">Двухфакторная проверка</h1>
|
||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||
Откройте приложение-аутентификатор и введите 6-значный код для
|
||
<strong>{{ userEmail }}</strong>
|
||
</p>
|
||
</header>
|
||
|
||
<v-form class="twofactor-form" @submit.prevent="handleSubmit">
|
||
<div class="code-row" @paste="onPaste">
|
||
<input
|
||
v-for="(_, i) in code"
|
||
:key="i"
|
||
ref="inputs"
|
||
v-model="code[i]"
|
||
type="text"
|
||
inputmode="numeric"
|
||
maxlength="1"
|
||
class="code-cell"
|
||
:aria-label="`Цифра ${i + 1}`"
|
||
@input="onInput(i, $event)"
|
||
@keydown="onKeydown(i, $event)"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="errors.code?.length" class="text-error text-caption mb-1">
|
||
{{ errors.code[0] }}
|
||
</div>
|
||
|
||
<v-alert
|
||
v-if="auth.lockoutSeconds !== null"
|
||
type="error"
|
||
variant="tonal"
|
||
density="compact"
|
||
class="mb-2"
|
||
data-testid="lockout-alert"
|
||
>
|
||
Слишком много попыток. Попробуйте через {{ Math.ceil(auth.lockoutSeconds / 60) }} мин.
|
||
</v-alert>
|
||
|
||
<div class="d-flex justify-space-between align-center mb-2">
|
||
<RouterLink to="/recovery-use" class="text-body-2 text-primary">
|
||
Использовать резервный код
|
||
</RouterLink>
|
||
<span
|
||
class="text-caption text-medium-emphasis font-mono"
|
||
:title="`До смены кода в приложении: ${totpCountdown}`"
|
||
data-testid="totp-countdown"
|
||
>{{ totpCountdown }}</span
|
||
>
|
||
</div>
|
||
|
||
<v-btn
|
||
type="submit"
|
||
color="primary"
|
||
block
|
||
size="large"
|
||
variant="flat"
|
||
:disabled="!canSubmit"
|
||
:loading="auth.loading"
|
||
>
|
||
Подтвердить
|
||
</v-btn>
|
||
</v-form>
|
||
</v-card>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.twofactor-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.twofactor-header h1 {
|
||
font-variation-settings: 'opsz' 26;
|
||
letter-spacing: -0.018em;
|
||
}
|
||
|
||
.twofactor-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.code-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.code-cell {
|
||
width: 48px;
|
||
height: 56px;
|
||
text-align: center;
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-size: 22px;
|
||
font-weight: 500;
|
||
border: 1px solid #d9d5cd;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
color: #081319;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.code-cell:focus {
|
||
outline: none;
|
||
border-color: #0f6e56;
|
||
box-shadow: 0 0 0 2px rgba(15, 110, 86, 0.2);
|
||
}
|
||
|
||
.font-mono {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-feature-settings: 'tnum';
|
||
}
|
||
</style>
|