2026-05-08 17:09:56 +03:00
|
|
|
|
<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 цифр заполняет все.
|
|
|
|
|
|
*/
|
2026-05-08 20:14:33 +03:00
|
|
|
|
import { extractValidationErrors } from '../../api/client';
|
|
|
|
|
|
import { useAuthStore } from '../../stores/auth';
|
2026-05-09 19:36:02 +03:00
|
|
|
|
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
2026-05-08 20:14:33 +03:00
|
|
|
|
import { useRouter } from 'vue-router';
|
2026-05-08 17:09:56 +03:00
|
|
|
|
|
|
|
|
|
|
const code = ref(['', '', '', '', '', '']);
|
2026-05-09 19:36:02 +03:00
|
|
|
|
// Vue 3.5 useTemplateRef() — Sprint 2 Phase B / O-stack-04. Заменяет
|
|
|
|
|
|
// `const inputs = ref<HTMLInputElement[]>([])`; имя ref'а в template = 'inputs'.
|
|
|
|
|
|
const inputs = useTemplateRef<HTMLInputElement[]>('inputs');
|
2026-05-08 17:09:56 +03:00
|
|
|
|
const codeFull = computed(() => code.value.join(''));
|
|
|
|
|
|
const canSubmit = computed(() => codeFull.value.length === 6 && /^\d{6}$/.test(codeFull.value));
|
2026-05-08 20:14:33 +03:00
|
|
|
|
const errors = ref<Record<string, string[]>>({});
|
2026-05-08 17:09:56 +03:00
|
|
|
|
|
2026-05-08 20:14:33 +03:00
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
|
|
|
|
|
|
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
|
|
|
|
|
|
|
|
|
|
|
|
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
|
|
|
|
|
|
// прямой URL без login → отправляем на /login.
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (!auth.requires2fa && !auth.isAuthenticated) {
|
|
|
|
|
|
router.replace('/login');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-05-08 17:09:56 +03:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-05-09 19:36:02 +03:00
|
|
|
|
nextTick(() => inputs.value?.[index + 1]?.focus());
|
2026-05-08 17:09:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onKeydown(index: number, event: KeyboardEvent) {
|
|
|
|
|
|
if (event.key === 'Backspace' && !code.value[index] && index > 0) {
|
2026-05-09 19:36:02 +03:00
|
|
|
|
nextTick(() => inputs.value?.[index - 1]?.focus());
|
2026-05-08 17:09:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
|
}
|
2026-05-09 19:36:02 +03:00
|
|
|
|
nextTick(() => inputs.value?.[5]?.focus());
|
2026-05-08 17:09:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 20:14:33 +03:00
|
|
|
|
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 = ['', '', '', '', '', ''];
|
2026-05-09 19:36:02 +03:00
|
|
|
|
nextTick(() => inputs.value?.[0]?.focus());
|
2026-05-08 20:14:33 +03:00
|
|
|
|
}
|
2026-05-08 17:09:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-05-08 20:14:33 +03:00
|
|
|
|
<div v-if="errors.code?.length" class="text-error text-caption mb-1">
|
|
|
|
|
|
{{ errors.code[0] }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-08 20:49:47 +03:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-08 17:09:56 +03:00
|
|
|
|
<div class="d-flex justify-space-between align-center mb-2">
|
2026-05-09 03:43:58 +03:00
|
|
|
|
<RouterLink to="/recovery-use" class="text-body-2 text-primary">
|
|
|
|
|
|
Использовать резервный код
|
|
|
|
|
|
</RouterLink>
|
2026-05-08 17:09:56 +03:00
|
|
|
|
<span class="text-caption text-medium-emphasis font-mono">02:34</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-08 20:14:33 +03:00
|
|
|
|
<v-btn
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
color="primary"
|
|
|
|
|
|
block
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
variant="flat"
|
|
|
|
|
|
:disabled="!canSubmit"
|
|
|
|
|
|
:loading="auth.loading"
|
|
|
|
|
|
>
|
2026-05-08 17:09:56 +03:00
|
|
|
|
Подтвердить
|
|
|
|
|
|
</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>
|