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

186 lines
6.5 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">
/**
* Экран установки нового пароля по email-ссылке.
*
* Источник логики: ТЗ §1.7 / Прил. Г.4.3 — token действителен 60 мин,
* пароль ≥10 символов (system_settings.password_min_length).
*
* Deep-link формат: /reset/{token}?email=user@example.ru
*
* Submit → useAuthStore.resetPassword() → POST /api/auth/reset-password:
* - На 200 → success-state + редирект на /login через 3 сек.
* - На 422 → form-error «ссылка недействительна или истекла».
* - На 429 → lockout-alert.
*/
import { extractValidationErrors } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const token = computed(() => String(route.params.token ?? ''));
const email = ref(typeof route.query.email === 'string' ? route.query.email : '');
const password = ref('');
const passwordConfirmation = ref('');
const showPassword = ref(false);
const errors = ref<Record<string, string[]>>({});
const submitted = ref(false);
const canSubmit = computed(
() =>
token.value.length > 0 &&
email.value.length > 0 &&
password.value.length >= 10 &&
password.value === passwordConfirmation.value,
);
/**
* Ошибка поля подтверждения: client-side проверка совпадения +
* проброс backend-ошибки `password_confirmation` если придёт с 422.
*/
const confirmationError = computed<string[]>(() => {
if (passwordConfirmation.value.length > 0 && password.value !== passwordConfirmation.value) {
return ['Пароли не совпадают'];
}
return errors.value.password_confirmation ?? [];
});
async function handleSubmit() {
errors.value = {};
try {
await auth.resetPassword({
token: token.value,
email: email.value,
password: password.value,
password_confirmation: passwordConfirmation.value,
});
submitted.value = true;
// Редиректим через короткую задержку, чтобы user успел прочитать success.
setTimeout(() => router.push('/login'), 3000);
} catch (error: unknown) {
const validationErrors = extractValidationErrors(error);
if (validationErrors) {
errors.value = validationErrors;
} else if (auth.lockoutSeconds === null) {
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
}
}
}
</script>
<template>
<v-card variant="flat" :max-width="380" width="100%" color="transparent" class="reset-card">
<header class="reset-header">
<h1 class="text-h5 mb-1">Новый пароль</h1>
<p class="text-body-2 text-medium-emphasis ma-0">Установите новый пароль для вашего аккаунта.</p>
</header>
<v-alert v-if="submitted" type="success" variant="tonal" density="comfortable" data-testid="reset-success">
Пароль успешно изменён. Сейчас вы будете перенаправлены на страницу входа.
</v-alert>
<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 v-if="!submitted" class="reset-form" @submit.prevent="handleSubmit">
<v-text-field
v-model="email"
label="Email"
type="email"
autocomplete="email"
variant="outlined"
density="comfortable"
required
:error-messages="errors.email"
/>
<v-text-field
v-model="password"
label="Новый пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
placeholder="Минимум 10 символов"
variant="outlined"
density="comfortable"
required
:error-messages="errors.password"
>
<template #append-inner>
<v-icon
class="password-toggle"
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
role="button"
tabindex="0"
@click="showPassword = !showPassword"
@keydown.enter.prevent="showPassword = !showPassword"
@keydown.space.prevent="showPassword = !showPassword"
/>
</template>
</v-text-field>
<v-text-field
v-model="passwordConfirmation"
label="Повторите пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
variant="outlined"
density="comfortable"
required
:error-messages="confirmationError"
/>
<v-btn
type="submit"
color="primary"
block
size="large"
variant="flat"
:disabled="!canSubmit"
:loading="auth.loading"
>
Сохранить пароль
</v-btn>
<v-btn :to="{ name: 'login' }" variant="text" block size="small" prepend-icon="mdi-arrow-left">
Вернуться ко входу
</v-btn>
</v-form>
</v-card>
</template>
<style scoped>
.reset-card {
display: flex;
flex-direction: column;
gap: 20px;
}
.reset-header h1 {
font-variation-settings: 'opsz' 26;
letter-spacing: -0.018em;
}
.reset-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.password-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
border-radius: 2px;
}
</style>