2d7d7d1188
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>
195 lines
6.5 KiB
Vue
195 lines
6.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, 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>
|