Files
portal/app/resources/js/views/auth/TwoFactorView.vue
T

195 lines
6.5 KiB
Vue
Raw Normal View History

<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, 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 ?? 'аккаунт');
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
// прямой URL без login → отправляем на /login.
onMounted(() => {
if (!auth.requires2fa && !auth.isAuthenticated) {
router.replace('/login');
}
});
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">02:34</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>