Files
portal/app/resources/js/components/settings/security/TwoFactorCard.vue
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

220 lines
8.9 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">
/**
* TwoFactorCard — управление 2FA: включить (3-step wizard) / отключить.
* Sprint 4 Phase B/2 — split SecurityTab (audit O-refactor-04 хвост).
*
* 2FA flow по ТЗ §1.6/Прил. Г.4.2 (TOTP 6 цифр, recovery 8 шт.):
* - Включить → POST /api/2fa/init → QR + secret → user сканирует →
* ввод 6-значного кода → POST /api/2fa/confirm → показ 8 recovery-codes один раз.
* - Отключить → modal с password → POST /api/2fa/disable.
*
* Регенерация recovery-codes вынесена в RecoveryCodesCard.vue.
*/
import * as authApi from '../../../api/auth';
import { useAuthStore } from '../../../stores/auth';
import { computed, ref } from 'vue';
const auth = useAuthStore();
const has2fa = computed(() => auth.user?.totp_enabled ?? false);
// === Setup wizard (3 шага: init → confirm → show codes) ===
const setupOpen = ref(false);
const setupStep = ref<'init' | 'confirm' | 'codes'>('init');
const setupSecret = ref('');
const setupQrUrl = ref('');
const setupCode = ref('');
const setupRecoveryCodes = ref<string[]>([]);
const setupError = ref('');
const setupBusy = ref(false);
async function openSetup(): Promise<void> {
setupOpen.value = true;
setupStep.value = 'init';
setupError.value = '';
setupCode.value = '';
setupRecoveryCodes.value = [];
setupBusy.value = true;
try {
const r = await authApi.twoFactorInit();
setupSecret.value = r.secret;
setupQrUrl.value = r.qr_url;
setupStep.value = 'confirm';
} catch (error: unknown) {
setupError.value = error instanceof Error ? error.message : 'Ошибка инициализации.';
} finally {
setupBusy.value = false;
}
}
async function confirmSetup(): Promise<void> {
setupError.value = '';
setupBusy.value = true;
try {
const r = await authApi.twoFactorConfirm(setupCode.value);
setupRecoveryCodes.value = r.recovery_codes;
setupStep.value = 'codes';
if (auth.user) auth.user = { ...auth.user, totp_enabled: true };
} catch {
setupError.value = 'Неверный код. Проверьте время на устройстве.';
} finally {
setupBusy.value = false;
}
}
function closeSetup(): void {
setupOpen.value = false;
setupSecret.value = '';
setupQrUrl.value = '';
setupCode.value = '';
}
// === Disable dialog ===
const disableOpen = ref(false);
const disablePassword = ref('');
const disableError = ref('');
async function confirmDisable(): Promise<void> {
disableError.value = '';
try {
await authApi.twoFactorDisable(disablePassword.value);
if (auth.user) auth.user = { ...auth.user, totp_enabled: false };
disableOpen.value = false;
disablePassword.value = '';
} catch {
disableError.value = 'Неверный пароль.';
}
}
</script>
<template>
<v-card variant="outlined" class="pa-4 mb-4" data-testid="2fa-card" data-tour="settings-2fa">
<div class="d-flex justify-space-between align-center mb-3">
<h3 class="text-subtitle-2 ma-0">Двухфакторная авторизация (2FA)</h3>
<v-chip v-if="has2fa" color="success" size="small" variant="tonal">включена</v-chip>
<v-chip v-else color="warning" size="small" variant="tonal">выключена</v-chip>
</div>
<p class="text-body-2 text-medium-emphasis mb-3">
<template v-if="has2fa">
Используется TOTP (Google Authenticator / Yandex Key). 8 одноразовых резервных кодов сохранены в
безопасном месте.
</template>
<template v-else>
Защитите аккаунт двухфакторной авторизацией. Поддерживаются Google Authenticator, Yandex Key, 1Password
и другие TOTP-приложения.
</template>
</p>
<div class="d-flex ga-2 flex-wrap">
<v-btn
v-if="!has2fa"
color="primary"
variant="flat"
size="small"
prepend-icon="mdi-shield-key"
data-testid="enable-2fa-btn"
data-tour="settings-2fa-btn"
@click="openSetup"
>
Включить 2FA
</v-btn>
<v-btn
v-else
variant="outlined"
size="small"
color="error"
prepend-icon="mdi-shield-off"
data-testid="disable-2fa-btn"
@click="disableOpen = true"
>
Отключить 2FA
</v-btn>
</div>
<!-- 2FA SETUP WIZARD -->
<v-dialog v-model="setupOpen" :max-width="480" data-testid="setup-dialog">
<v-card data-tour="twofactor-dialog">
<v-card-title>Включение 2FA</v-card-title>
<v-card-text>
<template v-if="setupStep === 'init'">
<v-progress-circular indeterminate />
</template>
<template v-else-if="setupStep === 'confirm'">
<p class="mb-3">
Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Yandex Key) и
введите 6-значный код, который оно покажет.
</p>
<p class="text-caption text-medium-emphasis mb-3">
Если QR не сканируется, введите secret вручную:
<strong class="font-mono">{{ setupSecret }}</strong>
</p>
<v-text-field
v-model="setupCode"
label="6-значный код"
inputmode="numeric"
maxlength="6"
:error-messages="setupError ? [setupError] : []"
density="comfortable"
/>
</template>
<template v-else-if="setupStep === 'codes'">
<v-alert type="warning" variant="tonal" class="mb-3">
Сохраните 8 резервных кодов сейчас они показываются один раз!
</v-alert>
<div class="codes-grid">
<div v-for="(c, i) in setupRecoveryCodes" :key="i" class="code-item font-mono">
{{ c }}
</div>
</div>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn v-if="setupStep === 'confirm'" :loading="setupBusy" color="primary" @click="confirmSetup">
Подтвердить
</v-btn>
<v-btn v-if="setupStep === 'codes'" color="primary" @click="closeSetup">Готово</v-btn>
<v-btn v-if="setupStep !== 'codes'" variant="text" @click="closeSetup">Отмена</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- DISABLE DIALOG -->
<v-dialog v-model="disableOpen" :max-width="420" data-testid="disable-dialog">
<v-card>
<v-card-title>Отключить 2FA</v-card-title>
<v-card-text>
<p class="mb-3">Введите пароль, чтобы подтвердить отключение двухфакторной авторизации.</p>
<v-text-field
v-model="disablePassword"
label="Пароль"
type="password"
autocomplete="current-password"
:error-messages="disableError ? [disableError] : []"
density="comfortable"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="error" @click="confirmDisable">Отключить</v-btn>
<v-btn variant="text" @click="disableOpen = false">Отмена</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<style scoped>
.codes-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.code-item {
background: #f6f3ec;
padding: 8px 12px;
border-radius: 6px;
text-align: center;
font-size: 14px;
letter-spacing: 0.06em;
}
</style>