astro_advokat/frontend/src/components/auth/PasswordResetForm.astro

531 lines
21 KiB
Text
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.

---
import Button from "../base/Button.astro";
import ConsentCheckbox from "../base/ConsentCheckbox.astro";
// Иконки
const emailIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>`;
const lockIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>`;
const checkIcon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 13l4 4L19 7"/></svg>`;
const arrowLeftIcon = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>`;
// Props
interface Props {
// Режим: 'request' - запрос сброса, 'reset' - установка нового пароля
mode?: "request" | "reset";
token?: string; // Токен из URL для режима reset
}
const { mode = "request", token = "" } = Astro.props;
const isResetMode = mode === "reset";
const title = isResetMode ? "Новый пароль" : "Восстановление пароля";
const subtitle = isResetMode
? "Придумайте новый пароль для вашего аккаунта"
: "Введите email, и мы отправим вам ссылку для сброса пароля";
---
<div class="w-full max-w-md mx-auto">
<!-- Назад к входу -->
<a
href="/auth/login"
class="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gold transition-colors mb-8"
>
<Fragment set:html={arrowLeftIcon} />
<span>Вернуться к входу</span>
</a>
<!-- Заголовок -->
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">{title}</h1>
<p class="text-sm text-gray-500">{subtitle}</p>
</div>
<!-- Форма запроса сброса -->
{!isResetMode && (
<form class="space-y-6" id="forgot-form" novalidate>
<!-- Honeypot -->
<div class="honeypot-field" aria-hidden="true">
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off" />
</div>
<!-- Email -->
<div class="relative group">
<label for="email" class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
Email <span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400">
<Fragment set:html={emailIcon} />
</div>
<input
type="email"
id="email"
name="email"
placeholder="you@example.com"
required
maxlength="254"
pattern="[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
class="w-full pl-12 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
/>
</div>
<span class="error-message hidden text-red-500 text-xs mt-1">
Введите корректный email адрес
</span>
</div>
<!-- Submit -->
<Button
type="submit"
text="Отправить ссылку"
variant="primary-white-text"
size="md"
className="w-full!"
id="submit-btn"
disabled
/>
<ConsentCheckbox formId="forgot-form" />
</form>
)}
<!-- Форма установки нового пароля -->
{isResetMode && (
<form class="space-y-6" id="reset-form" novalidate data-token={token}>
<!-- Honeypot -->
<div class="honeypot-field" aria-hidden="true">
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off" />
</div>
<!-- Новый пароль -->
<div class="relative group">
<label for="password" class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
Новый пароль <span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400">
<Fragment set:html={lockIcon} />
</div>
<input
type="password"
id="password"
name="password"
placeholder="••••••••"
required
minlength="8"
maxlength="12"
class="w-full pl-12 pr-12 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
/>
<button
type="button"
id="toggle-password"
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Показать пароль"
>
<svg id="eye-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<svg id="eye-off-icon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
<span class="error-message hidden text-red-500 text-xs mt-1">
Пароль должен содержать хотя бы одну букву и одну цифру
</span>
<p class="text-xs text-gray-400 mt-1">От 8 до 12 символов, буква + цифра</p>
</div>
<!-- Подтверждение пароля -->
<div class="relative group">
<label for="confirm-password" class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
Повторите пароль <span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400">
<Fragment set:html={checkIcon} />
</div>
<input
type="password"
id="confirm-password"
name="confirmPassword"
placeholder="••••••••"
required
minlength="8"
maxlength="12"
class="w-full pl-12 pr-12 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 font-medium focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 transition-all"
/>
<button
type="button"
id="toggle-confirm-password"
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Показать пароль"
>
<svg id="eye-icon-confirm" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<svg id="eye-off-icon-confirm" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
<span class="error-message hidden text-red-500 text-xs mt-1">
Пароли не совпадают
</span>
</div>
<!-- Submit -->
<Button
type="submit"
text="Сохранить пароль"
variant="primary-white-text"
size="md"
className="w-full!"
id="submit-btn"
disabled
/>
</form>
)}
</div>
<style>
.honeypot-field {
position: absolute;
left: -9999px;
opacity: 0;
height: 0;
width: 0;
overflow: hidden;
}
</style>
<script>
// Определяем режим по наличию формы
const isResetMode = !!document.getElementById('reset-form');
const form = document.getElementById(isResetMode ? 'reset-form' : 'forgot-form');
const submitBtn = document.getElementById('submit-btn');
const honeypotField = document.getElementById('website');
// Логирование
function log(message, data) {
const timestamp = new Date().toISOString();
console.log(`[PASSWORD_RESET_FORM][${timestamp}] ${message}`, data || '');
}
function logError(message, data) {
const timestamp = new Date().toISOString();
console.error(`[PASSWORD_RESET_FORM][${timestamp}] ERROR: ${message}`, data || '');
}
const touchedFields = new Set();
// Утилита для ошибок
function showFieldError(input, show, message = "") {
const group = input.closest('.group');
const errorEl = group?.querySelector('.error-message');
if (errorEl) {
if (show) {
if (message) errorEl.textContent = message;
errorEl.classList.remove('hidden');
input.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500/20');
input.classList.remove('focus:border-gold', 'focus:ring-gold/20');
} else {
errorEl.classList.add('hidden');
input.classList.remove('border-red-500', 'focus:border-red-500', 'focus:ring-red-500/20');
input.classList.add('focus:border-gold', 'focus:ring-gold/20');
}
}
}
// Валидация email
function validateEmail(showError = false) {
const emailInput = document.getElementById('email');
if (!emailInput) return true;
const value = emailInput.value.trim();
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
let isValid = true;
if (!value || !emailPattern.test(value) || value.length > 254) {
isValid = false;
}
if (showError || touchedFields.has('email')) {
showFieldError(emailInput, !isValid && value.length > 0);
}
return isValid;
}
// Валидация пароля
function validatePassword(showError = false) {
const passwordInput = document.getElementById('password');
if (!passwordInput) return true;
const value = passwordInput.value;
const minLength = 8;
const maxLength = 12;
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+$/;
let isValid = true;
if (!value || value.length < minLength || value.length > maxLength) {
isValid = false;
} else if (!passwordRegex.test(value)) {
isValid = false;
}
if (showError || touchedFields.has('password')) {
showFieldError(passwordInput, !isValid && value.length > 0);
}
return isValid;
}
// Валидация подтверждения пароля
function validateConfirmPassword(showError = false) {
const confirmInput = document.getElementById('confirm-password');
const passwordInput = document.getElementById('password');
if (!confirmInput || !passwordInput) return true;
const value = confirmInput.value;
const passwordValue = passwordInput.value;
let isValid = true;
if (!value || value !== passwordValue) {
isValid = false;
}
if (showError || touchedFields.has('confirmPassword')) {
showFieldError(confirmInput, !isValid && value.length > 0,
value !== passwordValue ? 'Пароли не совпадают' : '');
}
return isValid;
}
// Проверка всей формы
function checkFormValidity() {
const isHoneypotEmpty = !honeypotField?.value;
if (isResetMode) {
const isPasswordValid = validatePassword(false);
const isConfirmValid = validateConfirmPassword(false);
submitBtn.disabled = !(isPasswordValid && isConfirmValid && isHoneypotEmpty);
return isPasswordValid && isConfirmValid && isHoneypotEmpty;
} else {
const isEmailValid = validateEmail(false);
submitBtn.disabled = !(isEmailValid && isHoneypotEmpty);
return isEmailValid && isHoneypotEmpty;
}
}
// Показать все ошибки
function showAllErrors() {
if (isResetMode) {
touchedFields.add('password');
touchedFields.add('confirmPassword');
const isPasswordValid = validatePassword(true);
const isConfirmValid = validateConfirmPassword(true);
return isPasswordValid && isConfirmValid;
} else {
touchedFields.add('email');
return validateEmail(true);
}
}
// Настройка переключателя пароля
function setupPasswordToggle(btnId, inputId, eyeId, eyeOffId) {
const btn = document.getElementById(btnId);
const input = document.getElementById(inputId);
const eyeIcon = document.getElementById(eyeId);
const eyeOffIcon = document.getElementById(eyeOffId);
btn?.addEventListener('click', () => {
const type = input.getAttribute('type') === 'password' ? 'text' : 'password';
input.setAttribute('type', type);
if (type === 'text') {
eyeIcon?.classList.add('hidden');
eyeOffIcon?.classList.remove('hidden');
btn.setAttribute('aria-label', 'Скрыть пароль');
} else {
eyeIcon?.classList.remove('hidden');
eyeOffIcon?.classList.add('hidden');
btn.setAttribute('aria-label', 'Показать пароль');
}
});
}
// Режим запроса сброса
if (!isResetMode) {
const emailInput = document.getElementById('email');
// Ограничение ввода email
emailInput?.addEventListener('keypress', (e) => {
if (!/[a-zA-Z0-9@._%+\-]/.test(e.key) &&
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
e.preventDefault();
}
});
emailInput?.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/\s/g, '');
validateEmail();
checkFormValidity();
});
emailInput?.addEventListener('focus', () => touchedFields.add('email'));
emailInput?.addEventListener('blur', () => {
touchedFields.add('email');
if (emailInput.value.length > 0) validateEmail(true);
});
// Отправка формы запроса
form?.addEventListener('submit', async (e) => {
e.preventDefault();
if (honeypotField?.value) {
console.log('Bot detected');
return;
}
if (!showAllErrors()) return;
submitBtn.disabled = true;
const originalText = submitBtn.textContent;
submitBtn.textContent = 'Отправка...';
try {
const formData = new FormData(form);
const email = formData.get('email');
log(`Запрос сброса пароля для: ${email}`);
// Редирект на страницу успеха с email
log('Перенаправление на страницу подтверждения отправки');
window.location.href = `/auth/forgot-password-sent?email=${encodeURIComponent(email)}`;
} catch (error) {
logError('❌ Ошибка сброса пароля:', error);
alert(error.message || 'Ошибка отправки. Попробуйте позже.');
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
}
// Режим установки нового пароля
if (isResetMode) {
const passwordInput = document.getElementById('password');
const confirmInput = document.getElementById('confirm-password');
const token = form?.dataset.token;
// Переключатели видимости пароля
setupPasswordToggle('toggle-password', 'password', 'eye-icon', 'eye-off-icon');
setupPasswordToggle('toggle-confirm-password', 'confirm-password', 'eye-icon-confirm', 'eye-off-icon-confirm');
// Ограничение ввода пароля
passwordInput?.addEventListener('keypress', (e) => {
if (passwordInput.value.length >= 12 &&
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
e.preventDefault();
}
});
passwordInput?.addEventListener('input', (e) => {
if (e.target.value.length > 12) {
e.target.value = e.target.value.slice(0, 12);
}
e.target.value = e.target.value.replace(/\s/g, '');
validatePassword();
if (confirmInput.value) validateConfirmPassword();
checkFormValidity();
});
confirmInput?.addEventListener('keypress', (e) => {
if (confirmInput.value.length >= 12 &&
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
e.preventDefault();
}
});
confirmInput?.addEventListener('input', (e) => {
if (e.target.value.length > 12) {
e.target.value = e.target.value.slice(0, 12);
}
e.target.value = e.target.value.replace(/\s/g, '');
validateConfirmPassword();
checkFormValidity();
});
// Фокус и blur
passwordInput?.addEventListener('focus', () => touchedFields.add('password'));
confirmInput?.addEventListener('focus', () => touchedFields.add('confirmPassword'));
passwordInput?.addEventListener('blur', () => {
touchedFields.add('password');
if (passwordInput.value.length > 0) validatePassword(true);
});
confirmInput?.addEventListener('blur', () => {
touchedFields.add('confirmPassword');
if (confirmInput.value.length > 0) validateConfirmPassword(true);
});
// Отправка формы сброса
form?.addEventListener('submit', async (e) => {
e.preventDefault();
if (honeypotField?.value) {
console.log('Bot detected');
return;
}
if (!showAllErrors()) return;
if (!token) {
alert('Ошибка: отсутствует токен сброса');
return;
}
submitBtn.disabled = true;
const originalText = submitBtn.textContent;
submitBtn.textContent = 'Сохранение...';
try {
const formData = new FormData(form);
const password = formData.get('password');
const passwordConfirm = formData.get('confirmPassword');
log(`Сброс пароля с токеном: ${token}`);
// Сохраняем данные в sessionStorage для страницы подтверждения
sessionStorage.setItem('passwordResetData', JSON.stringify({
password,
passwordConfirm
}));
// Редирект на страницу подтверждения с токеном
log('Перенаправление на страницу подтверждения');
window.location.href = `/auth/password-reset-success?token=${encodeURIComponent(token)}`;
} catch (error) {
logError('❌ Ошибка сброса пароля:', error);
// Очищаем sessionStorage при ошибке
sessionStorage.removeItem('passwordResetData');
alert(error.message || 'Ошибка сброса пароля. Возможно, ссылка устарела.');
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
}
// Начальная проверка
checkFormValidity();
</script>