c39d555e6f
- 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>
126 lines
4.3 KiB
Vue
126 lines
4.3 KiB
Vue
<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>
|