Files
portal/app/resources/js/views/auth/UseRecoveryCodeView.vue
T
Дмитрий c39d555e6f phase2(recovery-code): POST /api/auth/2fa/recovery-use + UseRecoveryCodeView
- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:43:58 +03:00

126 lines
4.3 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 (UseRecoveryCodeView).
*
* Источник: ТЗ §1.6 / §22.4.2 — 8 одноразовых кодов, bcrypt-хеш в user_recovery_codes,
* после использования помечаются `used_at`.
*
* Открывается из TwoFactorView ссылкой «Использовать резервный код».
*
* Submit → useAuthStore.useRecoveryCode() → POST /api/auth/2fa/recovery-use:
* - На 200 → redirect /dashboard. Бэкенд вернёт `recovery_codes_remaining`,
* запоминаем для toast-warning'а если осталось ≤2.
* - На 422 → form-error «недействителен или использован».
* - На 429 → lockout-alert.
*/
import { extractValidationErrors } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const auth = useAuthStore();
const code = ref('');
const errors = ref<Record<string, string[]>>({});
const canSubmit = computed(() => code.value.trim().length >= 8);
onMounted(() => {
// Без pending-2FA состояния тут делать нечего — отправляем на /login.
if (!auth.requires2fa && !auth.isAuthenticated) {
router.push('/login');
}
});
async function handleSubmit() {
errors.value = {};
try {
const response = await auth.useRecoveryCode(code.value);
// Сохраним remaining count в session/sessionStorage для UI-warning'а
// (отдельный коммит — в SettingsView/SecurityTab).
if (typeof window !== 'undefined' && window.sessionStorage) {
window.sessionStorage.setItem('recovery_codes_remaining', String(response.recovery_codes_remaining));
}
await router.push('/dashboard');
} catch (error: unknown) {
const validationErrors = extractValidationErrors(error);
if (validationErrors) {
errors.value = validationErrors;
} else if (auth.lockoutSeconds === null) {
errors.value = { code: ['Произошла ошибка. Попробуйте позже.'] };
}
code.value = '';
}
}
</script>
<template>
<v-card variant="flat" :max-width="380" width="100%" color="transparent" class="recovery-card">
<header class="recovery-header">
<h1 class="text-h5 mb-1">Резервный код</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Введите один из 8 резервных кодов. После использования код будет аннулирован.
</p>
</header>
<v-alert
v-if="auth.lockoutSeconds !== null"
type="error"
variant="tonal"
density="compact"
data-testid="lockout-alert"
>
Слишком много попыток. Попробуйте через {{ Math.ceil(auth.lockoutSeconds / 60) }} мин.
</v-alert>
<v-form class="recovery-form" @submit.prevent="handleSubmit">
<v-text-field
v-model="code"
label="Код (XXXX-XXXX)"
placeholder="ABCD-1234"
autocomplete="one-time-code"
variant="outlined"
density="comfortable"
required
:error-messages="errors.code"
/>
<v-btn
type="submit"
color="primary"
block
size="large"
variant="flat"
:disabled="!canSubmit"
:loading="auth.loading"
>
Войти
</v-btn>
<v-btn :to="{ name: '2fa' }" variant="outlined" block size="large" prepend-icon="mdi-arrow-left">
Назад к коду из приложения
</v-btn>
</v-form>
</v-card>
</template>
<style scoped>
.recovery-card {
display: flex;
flex-direction: column;
gap: 20px;
}
.recovery-header h1 {
font-variation-settings: 'opsz' 26;
letter-spacing: -0.018em;
}
.recovery-form {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>