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