Files
portal/app/resources/js/components/settings/security/TwoFactorCard.vue
T

219 lines
8.8 KiB
Vue
Raw Normal View History

<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">
<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"
@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>
<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>