170382878b
- AuthController::forgotPassword использует Password::sendResetLink (anti-enumeration: всегда 200)
- AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets — указывает на нашу таблицу из schema v8.7
- Rate-limit 5/15мин по auth:forgot:{email}|{ip} — hit ставится ДО sendResetLink (защита перебора через unknown email)
- Frontend: authApi.forgotPassword, auth-store.requestPasswordReset, ForgotPasswordView success-state
- Pest +6 в ForgotPasswordTest (79/79 за 10.55с, 273 assertions)
- Vitest +4 (153/153 за 11.11с)
- TODO: POST /api/auth/reset-password + UI-форма new_password (deep-link)
- Регресс: lint+type+format OK; build 862ms; story:build 21/28 за 32с; Pint+Stan passed
- CLAUDE.md v1.36→v1.37, реестр v1.45→v1.46
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
4.9 KiB
Vue
135 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Экран сброса пароля (ForgotPasswordView).
|
|
*
|
|
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html секция #form-forgot.
|
|
* Источник логики: ТЗ v8.5 §1.7 / Прил. Г.4.3 — лимит 5 попыток в 15 минут;
|
|
* ссылка на сброс действительна 60 минут; токены генерируются 32-байт hex.
|
|
*
|
|
* Submit → useAuthStore.requestPasswordReset() → POST /api/auth/forgot:
|
|
* 1. Backend: anti-enumeration unified-ответ (всегда 200, независимо от существования email).
|
|
* 2. На 422 — show validation error в форме.
|
|
* 3. На 429 — auth.lockoutSeconds → lockout-alert.
|
|
* 4. На 200 — success-state с инструкцией проверить почту.
|
|
*/
|
|
import { extractValidationErrors } from '../../api/client';
|
|
import { useAuthStore } from '../../stores/auth';
|
|
import { computed, ref } from 'vue';
|
|
|
|
const email = ref('');
|
|
const errors = ref<Record<string, string[]>>({});
|
|
const submitted = ref(false);
|
|
|
|
const auth = useAuthStore();
|
|
|
|
const canSubmit = computed(() => email.value.length > 0 && /.+@.+/.test(email.value));
|
|
|
|
async function handleSubmit() {
|
|
errors.value = {};
|
|
try {
|
|
await auth.requestPasswordReset(email.value);
|
|
submitted.value = true;
|
|
} 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="forgot-card">
|
|
<header class="forgot-header">
|
|
<h1 class="text-h5 mb-1">Сброс пароля</h1>
|
|
<p class="text-body-2 text-medium-emphasis ma-0">
|
|
Введите email, на который зарегистрирован аккаунт. Отправим ссылку для сброса.
|
|
</p>
|
|
</header>
|
|
|
|
<!-- Success-state: показываем после успешного submit. -->
|
|
<v-alert v-if="submitted" type="success" variant="tonal" density="comfortable" data-testid="forgot-success">
|
|
Если такой email зарегистрирован — мы отправили ссылку для сброса пароля. Проверьте почту в течение
|
|
нескольких минут (письмо может попасть в спам).
|
|
</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="forgot-form" @submit.prevent="handleSubmit">
|
|
<v-text-field
|
|
v-model="email"
|
|
label="Email"
|
|
type="email"
|
|
autocomplete="email"
|
|
placeholder="manager@yourcompany.ru"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
required
|
|
:error-messages="errors.email"
|
|
/>
|
|
|
|
<v-alert type="info" variant="tonal" density="compact" class="mb-2">
|
|
Лимит — <strong>5 попыток в 15 минут</strong>. Если не пришло письмо — проверьте спам или попробуйте
|
|
через 15 минут.
|
|
</v-alert>
|
|
|
|
<v-btn
|
|
type="submit"
|
|
color="primary"
|
|
block
|
|
size="large"
|
|
variant="flat"
|
|
:disabled="!canSubmit"
|
|
:loading="auth.loading"
|
|
>
|
|
Отправить ссылку
|
|
</v-btn>
|
|
|
|
<v-btn :to="{ name: 'login' }" variant="outlined" block size="large" prepend-icon="mdi-arrow-left">
|
|
Назад ко входу
|
|
</v-btn>
|
|
</v-form>
|
|
|
|
<v-btn
|
|
v-else
|
|
:to="{ name: 'login' }"
|
|
variant="outlined"
|
|
block
|
|
size="large"
|
|
prepend-icon="mdi-arrow-left"
|
|
class="mt-2"
|
|
>
|
|
Назад ко входу
|
|
</v-btn>
|
|
</v-card>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.forgot-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.forgot-header h1 {
|
|
font-variation-settings: 'opsz' 26;
|
|
letter-spacing: -0.018em;
|
|
}
|
|
|
|
.forgot-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
</style>
|