Files
portal/app/resources/js/views/auth/TwoFactorView.vue
T
Дмитрий 8e687f99c1 feat: сквозной путь клиента от рекламного клика до Вебвизора
Путь человека рвался пополам: лендинг и кабинет считали разные счётчики,
а вход и регистрация не писались вообще. Почему люди бросают регистрацию,
узнать было нельзя.

Счётчик лендинга 110476275 становится счётчиком полного пути и грузится
на всех страницах кабинета. Прежний счётчик кабинета 110494416 не тронут,
его история цела, на страницах кабинета работают оба.

Что сделано:
- загрузчик Метрики поднимает счётчики по области: public или app
- белый список раскладок: новая раскладка Метрику НЕ получает по умолчанию
- формы входа и регистрации закрыты от записи классом ym-hide-content
- метка utm не теряется при заходе сразу в кабинет минуя лендинг

Найдено при исполнении и закрыто:
- почта выводится в заголовке ДВУХ экранов, то есть вне формы: класс на форме
  её не накрывал, утекла бы в записи открытым текстом. Замаскирована точечно
- Метрика, единожды запустившись, пишет дальше сама и роутером не выключается.
  Админ входит через общий /login и идёт в админку к чужим телефонам.
  Корни админки и портала продаж закрыты ym-hide-content
- ключ metrika в config/services.php был объявлен ДВАЖДЫ: раздвоил его мой
  же merge 42e907c8 от 14.07. PHP молча берёт последний, правка первого блока
  не дала бы ничего и не выругалась. Дубль вычищен, прочие конфиги проверены

В кабинете Метрики включена галочка «включая поддомены»: счётчик принимал
данные только с liderra.ru и молча выбрасывал бы всё из кабинета.

Заодно погашен долг по статанализу: 63 ошибки держали коммит. Все до одной
в тестах, в боевом коде ноль. Природа ложная — анализатор не понимает
устройство Pest и ругается на обычный вызов внутри теста. Их гасят списком
игнора, а список пересобирали 18.07, тогда как тесты добавлялись 19-20.07,
в том числе мои по рекламной аудитории. Список пересобран, стало 0 ошибок.
Долг накопился в том числе потому, что вчера я обошёл эту проверку.

Тесты: 27 новых, все проверены вырезанием защиты. Полный набор 202 файла зелёный.

План: docs/superpowers/plans/2026-07-20-skvoznoy-put-do-vebvizora.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:47:19 +03:00

225 lines
8.2 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, 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-значный код для
<!-- ym-hide-content точечно на почте: она вне формы, и класс на форме
её не накрывает. Поясняющий текст вокруг остаётся читаемым в записи. -->
<strong class="ym-hide-content">{{ userEmail }}</strong>
</p>
</header>
<!-- ym-hide-content: Вебвизор пишет действия, но не содержимое полей.
Закрываем контейнер целиком, а не каждое поле: класс наследуется
детьми, и новое поле окажется закрыто само собой, без правки. -->
<v-form class="twofactor-form ym-hide-content" @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>