Files
portal/app/resources/js/views/auth/ConfirmEmailView.vue
T
Дмитрий 8ce6b3661f
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
fix(ui): DevIndexBadge и dev-код подтверждения скрыты в проде (гейт import.meta.env.DEV)
2026-06-21 12:26:54 +03:00

165 lines
5.7 KiB
Vue

<script setup lang="ts">
/**
* Экран подтверждения почты (G1/SP3a).
*
* Пользователь приходит сюда после register: вводит 6-значный код из письма,
* confirm-email логинит и ведёт на /dashboard. Email переносится через auth-store
* (pendingEmail); при перезагрузке страницы store пуст → показываем поле email.
*/
import { extractValidationErrors } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { computed, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const auth = useAuthStore();
const router = useRouter();
// Email: из store (после register) или ручной ввод (после перезагрузки).
const email = ref(auth.pendingEmail ?? '');
const code = ref('');
const errors = ref<Record<string, string[]>>({});
const formError = ref('');
const attemptsRemaining = ref<number | null>(null);
// Кулдаун повторной отправки (30 сек).
const resendCooldown = ref(0);
let cooldownTimer: ReturnType<typeof setInterval> | null = null;
function startCooldown() {
resendCooldown.value = 30;
cooldownTimer = setInterval(() => {
resendCooldown.value -= 1;
if (resendCooldown.value <= 0 && cooldownTimer) {
clearInterval(cooldownTimer);
cooldownTimer = null;
}
}, 1000);
}
onUnmounted(() => {
if (cooldownTimer) clearInterval(cooldownTimer);
});
// Код подтверждения показываем ТОЛЬКО в dev (в проде import.meta.env.DEV=false →
// null, даже если бэкенд по ошибке вернёт pendingDevCode).
const devCode = computed(() => (import.meta.env.DEV ? auth.pendingDevCode : null));
const canSubmit = computed(() => /^\d{6}$/.test(code.value) && email.value.length > 0);
async function handleConfirm() {
errors.value = {};
formError.value = '';
attemptsRemaining.value = null;
try {
await auth.confirmEmail({ email: email.value, code: code.value });
await router.push('/dashboard');
} catch (error: unknown) {
const validationErrors = extractValidationErrors(error);
if (validationErrors) {
errors.value = validationErrors;
}
const resp = (error as { response?: { data?: { message?: string; attempts_remaining?: number } } }).response;
formError.value = resp?.data?.message ?? 'Код подтверждения недействителен.';
if (typeof resp?.data?.attempts_remaining === 'number') {
attemptsRemaining.value = resp.data.attempts_remaining;
}
}
}
async function handleResend() {
if (resendCooldown.value > 0 || email.value.length === 0) return;
formError.value = '';
try {
await auth.resendCode(email.value);
startCooldown();
} catch {
formError.value = 'Не удалось отправить код. Попробуйте позже.';
}
}
</script>
<template>
<v-card variant="flat" :max-width="380" width="100%" color="transparent" class="confirm-card">
<header class="confirm-header">
<h1 class="text-h5 mb-1">Подтвердите почту</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Мы отправили 6-значный код на
<strong v-if="email">{{ email }}</strong>
<span v-else>указанный email</span>.
</p>
</header>
<v-form class="confirm-form" @submit.prevent="handleConfirm">
<v-text-field
v-if="!auth.pendingEmail"
v-model="email"
label="Email"
type="email"
autocomplete="email"
variant="outlined"
density="comfortable"
:error-messages="errors.email"
/>
<v-text-field
v-model="code"
label="Код из письма"
inputmode="numeric"
maxlength="6"
placeholder="000000"
variant="outlined"
density="comfortable"
:error-messages="errors.code"
/>
<p v-if="formError" class="text-body-2 text-error ma-0">
{{ formError }}
<span v-if="attemptsRemaining !== null"> Осталось попыток: {{ attemptsRemaining }}.</span>
</p>
<p v-if="devCode" class="text-caption text-medium-emphasis ma-0">Код (dev): {{ devCode }}</p>
<v-btn
type="submit"
color="primary"
block
size="large"
variant="flat"
:disabled="!canSubmit"
:loading="auth.loading"
>
Подтвердить
</v-btn>
<v-btn
variant="text"
size="small"
color="primary"
:disabled="resendCooldown > 0"
@click="handleResend"
>
<template v-if="resendCooldown > 0">Отправить повторно ({{ resendCooldown }})</template>
<template v-else>Отправить код повторно</template>
</v-btn>
</v-form>
</v-card>
</template>
<style scoped>
.confirm-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.confirm-header h1 {
font-variation-settings: 'opsz' 26;
letter-spacing: -0.018em;
}
.confirm-form {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>