Files
portal/app/resources/js/views/auth/RegisterView.vue
T
Дмитрий dd9b52b587 fix/onboarding: косяк 07 §6 — мелочи (имя «Новый», индикатор пароля, 401 /me)
1) Регистрация больше не подставляет фейковое «Новый клиент» — first/last_name
   пустые, приветствие падает на «коллега», аватар/имя из email; настоящее имя
   клиент укажет в профиле.
2) Индикатор силы пароля: частые/словарные пароли (password123, qwerty, 123456…)
   теперь «Слабый» независимо от длины/цифр (vitest RED→GREEN).
3) На гостевых страницах при холодном старте не дёргаем /api/auth/me — убран
   401-шум в консоли у неавторизованного посетителя; восстановление сессии на
   защищённых роутах сохранено.
Проверено глазами на 8000 (имя «коллега», 0 ошибок в консоли логина) + vitest.
Капча под нагрузкой/headless (§6.4) — заметка для приёмки, отдельно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:48:35 +03:00

230 lines
8.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">
/**
* Экран регистрации (RegisterView).
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html секция #form-register.
* Источник логики: ТЗ v8.5 §1.5/§4.1 — два обязательных click-wrap'а
* (оферта + согласие на ПДн). 3-й «маркетинговый» click-wrap из handoff
* НЕ реализован (handoff противоречит ТЗ — расхождение #2 из реестра v1.13).
*
* G1/SP3a: подключён к новому backend-потоку — register создаёт тенант в
* pending_email_confirm и шлёт 6-значный код; после успеха ведём на /confirm-email.
*/
import SmartCaptchaWidget from '../../components/auth/SmartCaptchaWidget.vue';
import { extractValidationErrors } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
const email = ref('');
const password = ref('');
const showPassword = ref(false);
const acceptOffer = ref(false);
const acceptPdn = ref(false);
const captchaToken = ref('');
const captchaRef = ref<InstanceType<typeof SmartCaptchaWidget> | null>(null);
const errors = ref<Record<string, string[]>>({});
const auth = useAuthStore();
const router = useRouter();
// §6: частые/словарные пароли (password123, qwerty, 123456…) — всегда «Слабый»,
// даже если формально длинные и с цифрой. База — самые ходовые + клавиатурные ряды.
const COMMON_WEAK =
/^(password|passw0rd|qwerty|qwertyuiop|qazwsx|123456|1234567|12345678|123456789|1234567890|111111|000000|123123|iloveyou|admin|welcome|abc123|letmein|monkey|dragon|master|login|sunshine|football|password1|пароль|йцукен|qwerty123)\d*$/i;
// Простая оценка силы пароля 0..4 для индикатора. На backend будет zxcvbn.
const passwordStrength = computed(() => {
const v = password.value;
if (!v) return 0;
if (COMMON_WEAK.test(v)) return 1; // частый словарный пароль — слабый независимо от длины/цифр
let score = 0;
if (v.length >= 8) score++;
if (/[A-ZА-Я]/.test(v) && /[a-zа-я]/.test(v)) score++;
if (/\d/.test(v)) score++;
if (/[^A-Za-zА-Яа-я0-9]/.test(v)) score++;
return score;
});
const strengthLabel = computed(() => {
const map = ['—', 'Слабый', 'Средний', 'Хороший', 'Надёжный'];
return map[passwordStrength.value];
});
const strengthColor = computed(() => {
const map = ['', 'error', 'warning', 'info', 'success'];
return map[passwordStrength.value];
});
const canSubmit = computed(
() =>
email.value.length > 0 &&
password.value.length >= 8 &&
acceptOffer.value &&
acceptPdn.value &&
captchaToken.value.length > 0,
);
async function handleSubmit() {
errors.value = {};
try {
await auth.register({
email: email.value,
password: password.value,
accept_offer: acceptOffer.value,
accept_pdn: acceptPdn.value,
// Токен от SmartCaptchaWidget: реальный Yandex-токен (prod) или
// 'dev-captcha-stub' (dev/local без sitekey, fallback-ветка виджета).
captcha_token: captchaToken.value,
});
await router.push('/confirm-email');
} catch (error: unknown) {
// Одноразовый Yandex-токен использован/истёк — сбрасываем виджет под новую попытку.
captchaRef.value?.reset();
const validationErrors = extractValidationErrors(error);
if (validationErrors) {
errors.value = validationErrors;
} else {
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
}
}
}
</script>
<template>
<v-card variant="flat" :max-width="380" width="100%" color="transparent" class="register-card">
<header class="register-header">
<h1 class="text-h5 mb-1">Создать аккаунт</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Уже есть?
<RouterLink to="/login" class="text-primary"> Войдите </RouterLink>
</p>
</header>
<v-form class="register-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-text-field
v-model="password"
label="Пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
placeholder="Минимум 8 символов"
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>
<div v-if="password" class="strength-block mb-2">
<v-progress-linear
:model-value="(passwordStrength / 4) * 100"
:color="strengthColor"
height="4"
rounded
/>
<span class="text-caption text-medium-emphasis"> {{ strengthLabel }} </span>
</div>
<div class="checks">
<v-checkbox v-model="acceptOffer" density="compact" hide-details color="primary">
<template #label>
<span class="text-body-2">
Принимаю
<a href="/legal/offer" class="text-primary" target="_blank" rel="noopener">оферту</a>
</span>
</template>
</v-checkbox>
<v-checkbox v-model="acceptPdn" density="compact" hide-details color="primary">
<template #label>
<span class="text-body-2">
Согласен с
<a href="/legal/privacy" class="text-primary" target="_blank" rel="noopener">
политикой обработки персональных данных
</a>
</span>
</template>
</v-checkbox>
<SmartCaptchaWidget
ref="captchaRef"
v-model="captchaToken"
:error-messages="errors.captcha_token"
/>
</div>
<v-btn
type="submit"
color="primary"
block
size="large"
variant="flat"
:disabled="!canSubmit"
:loading="auth.loading"
>
Создать аккаунт
</v-btn>
</v-form>
</v-card>
</template>
<style scoped>
.register-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.register-header h1 {
font-variation-settings: 'opsz' 26;
letter-spacing: -0.018em;
}
.register-form {
display: flex;
flex-direction: column;
gap: 4px;
}
.strength-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.checks {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.password-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
border-radius: 2px;
}
</style>