9c488122a1
- AuthController::resetPassword через Password::reset() (callback пишет password_hash)
- ResetPasswordRequest: token + email + password (min 10 по ТЗ §22.4.1) + confirmed
- Rate-limit auth:reset:{sha256(token)[0..16]}|{ip} (5/15мин)
- ResetPasswordView для deep-link /reset/:token?email=...; pre-fill email из query; success → redirect /login через 3 сек
- Vue Router /reset/:token (guestOnly); web.php /reset SPA-path
- DB FIX: config/database.php pgsql.timezone=UTC — без него PG TIMESTAMPTZ +03 терялся при Carbon::parse и tokenExpired ошибочно срабатывал
- Pest +6 ResetPasswordTest (85/85 за 11.50с, 291 assertions)
- Vitest +7 (160/160 за 11.02с)
- Регресс: lint+type+format OK; build 784ms; story:build 21/28 за 30.74с; Pint+Stan passed
- CLAUDE.md v1.37→v1.38, реестр v1.46→v1.47
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.4 KiB
Vue
157 lines
5.4 KiB
Vue
<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>
|