Files
portal/app/resources/js/views/auth/TwoFactorView.vue
T
Дмитрий 2d7d7d1188 feat(frontend): Sprint 2 Phase B — Vue 3.5 defineModel + Vuetify 3.12 typed slots + lazy-imports + ESLint check
Sprint 2 Phase B (modernization). Закрытие audit O-stack-04/05/07 + O-perf-06:
- O-stack-04: Vue 3.5 defineModel() в 3 диалогах (NewDealDialog,
  ImpersonationDialog, ReminderDialog) — boilerplate −5 строк/файл.
  + useTemplateRef() в TwoFactorView (input v-for refs).
- O-stack-05: Vuetify 3.12 типизированные слоты VDataTable
  (DealsView + AdminTenantsView) — inline-аннотации `{ item }: { item: T }`
  на 6+7 scoped-slot bindings; vue-tsc проверяет доступ к полям статически.
- O-stack-07: ESLint flat-config verified — header-comment добавлен
  в eslint.config.js. Legacy .eslintrc.json не используется.
- O-perf-06: defineAsyncComponent() для тяжёлых диалогов в 3 местах:
  DealsView (DealDetailDrawer + NewDealDialog), DealDetailDrawer
  (ReminderDialog), RemindersView (ReminderDialog). KanbanView оставлен
  sync — async-загрузка приводила к EnvironmentTeardownError в jsdom
  (KanbanView.spec.ts), see in-file comment. Сборка показывает chunk'и
  ImpersonationDialog (7.61 kB), DealDetailDrawer (11.12 kB), NewDealDialog
  и ReminderDialog как отдельные lazy-bundles.

vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:36:02 +03:00

195 lines
6.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>