cb05657f30
Phase 1B audit found 48 files failing `prettier --check`. Auto-apply
via `npx prettier --write resources/js/**/*.{ts,vue,css}` produced
style-only changes:
- consistent quote style
- trailing comma normalization
- spaces around : in v-card style="position: relative" attrs
- explicit ; insertion
No semantic changes. No code-behavior changes. Production-code only;
test files batched separately into `test(frontend):` commit.
Verification:
- npx vitest run → 79/79 files, 614/614 + 3 skipped (no regression).
- npx vue-tsc --noEmit → 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
8.8 KiB
Vue
219 lines
8.8 KiB
Vue
<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>
|