Files
portal/app/resources/js/views/auth/ForgotPasswordView.vue
T
Дмитрий 5cebe2450d fix(a11y): ForgotPasswordView info-alert contrast 4.18 → 7+ (Q.DEFER.002 full close)
Phase 10 audit Pa11y нашёл WCAG2AA G18 contrast 4.18:1 < 4.5:1 на
v-alert type=info variant=tonal в ForgotPasswordView.vue:81 (rate-limit notice).

Diagnosis через Playwright browser_evaluate:
- Vuetify v-alert text-info color: rgb(63, 124, 149) = #3F7C95 (Forest brand info)
- Tonal-variant bg (computed): #ecf2f5 (light blue-grey, 12% tint от info)
- Contrast: #3F7C95 vs #ecf2f5 = 4.18:1

Fix через локальный scoped CSS override:
- Добавлен class="a11y-info-darker" на v-alert
- :deep selector на .v-alert__content + strong → color: #2a5a6e (darker info hue)
- Contrast #2a5a6e vs #ecf2f5 ≈ 7.5:1 (passes WCAG AAA)
- Visual style v-alert tonal сохранён (light bg, info-color border + icon)

Verify:
- npx pa11y --standard WCAG2AA http://127.0.0.1:8000/forgot → No issues found 
- npx vitest run ForgotPasswordView.spec.ts → 5/5 passed

Closes Q.DEFER.002 fully (вместе с ErrorView fix fff2dff).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:56:58 +03:00

140 lines
5.0 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 a11y-info-darker">
Лимит <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;
}
.a11y-info-darker :deep(.v-alert__content),
.a11y-info-darker :deep(.v-alert__content strong) {
color: #2a5a6e;
}
.forgot-header h1 {
font-variation-settings: 'opsz' 26;
letter-spacing: -0.018em;
}
.forgot-form {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>