Files
portal/app/resources/js/views/auth/UseRecoveryCodeView.vue
T

126 lines
4.3 KiB
Vue
Raw Normal View History

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