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

157 lines
5.4 KiB
Vue
Raw Normal View History

<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,
);
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"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
required
:error-messages="errors.password"
@click:append-inner="showPassword = !showPassword"
/>
<v-text-field
v-model="passwordConfirmation"
label="Повторите пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
variant="outlined"
density="comfortable"
required
/>
<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;
}
</style>