Создана система регистрации пользователя
This commit is contained in:
parent
13754eecc3
commit
229826acc3
10 changed files with 1332 additions and 40 deletions
157
frontend/src/lib/email.ts
Normal file
157
frontend/src/lib/email.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
|
||||
const SMTP_HOST = import.meta.env.SMTP_HOST || 'localhost';
|
||||
const SMTP_PORT = import.meta.env.SMTP_PORT || 1025;
|
||||
const FROM_EMAIL = import.meta.env.FROM_EMAIL || 'noreply@avtourist-surgut.ru';
|
||||
const FROM_NAME = import.meta.env.FROM_NAME || 'Автоюрист Сургут';
|
||||
const SITE_URL = import.meta.env.SITE_URL || 'http://localhost:4321';
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: parseInt(String(SMTP_PORT)),
|
||||
secure: false,
|
||||
ignoreTLS: true,
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
||||
try {
|
||||
console.log('Sending email to:', options.to);
|
||||
console.log('SMTP config:', { host: SMTP_HOST, port: SMTP_PORT });
|
||||
|
||||
const info = await getTransporter().sendMail({
|
||||
from: `${FROM_NAME} <${FROM_EMAIL}>`,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
});
|
||||
|
||||
console.log('Email sent:', info.messageId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Email send error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSiteUrl(): string {
|
||||
return SITE_URL;
|
||||
}
|
||||
|
||||
export function generateVerifyEmailHtml(firstName: string, verifyLink: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Подтверждение регистрации</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f7fa; padding: 40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.08);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, #1e3050 0%, #2d4a6f 100%); padding: 30px 40px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Автоюрист Сургут</h1>
|
||||
<p style="color: rgba(255,255,255,0.8); margin: 10px 0 0 0; font-size: 16px;">Юридические услуги для автовладельцев</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="color: #1e3050; margin: 0 0 20px 0; font-size: 24px; font-weight: 700;">Добро пожаловать, ${firstName}!</h2>
|
||||
|
||||
<p style="color: #64748b; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
||||
Благодарим за регистрацию на сайте <strong>avtourist-surgut.ru</strong>. Мы рады, что вы выбрали нас для решения юридических вопросов, связанных с автомобилем.
|
||||
</p>
|
||||
|
||||
<p style="color: #64748b; font-size: 16px; line-height: 1.6; margin: 0 0 30px 0;">
|
||||
Для завершения регистрации и активации вашего аккаунта, пожалуйста, подтвердите ваш email, нажав на кнопку ниже:
|
||||
</p>
|
||||
|
||||
<!-- Button -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="${verifyLink}" style="display: inline-block; background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%); color: #ffffff; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 700;">
|
||||
Подтвердить регистрацию
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Features -->
|
||||
<tr>
|
||||
<td style="background-color: #f8fafc; padding: 30px 40px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 10px;">
|
||||
<div style="width: 48px; height: 48px; background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%); border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p style="color: #1e3050; font-size: 13px; font-weight: 600; margin: 0;">Бесплатная консультация</p>
|
||||
</td>
|
||||
<td align="center" style="padding: 0 10px;">
|
||||
<div style="width: 48px; height: 48px; background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%); border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<p style="color: #1e3050; font-size: 13px; font-weight: 600; margin: 0;">Оплата за результат</p>
|
||||
</td>
|
||||
<td align="center" style="padding: 0 10px;">
|
||||
<div style="width: 48px; height: 48px; background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%); border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<p style="color: #1e3050; font-size: 13px; font-weight: 600; margin: 0;">Работаем 24/7</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1e3050; padding: 24px 40px; text-align: center;">
|
||||
<p style="color: rgba(255,255,255,0.6); font-size: 14px; margin: 0;">
|
||||
© 2026 Автоюрист Сургут. Все права защищены.
|
||||
</p>
|
||||
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 10px 0 0 0;">
|
||||
Это письмо отправлено автоматически, пожалуйста, не отвечайте на него.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
110
frontend/src/pages/api/auth/confirm.ts
Normal file
110
frontend/src/pages/api/auth/confirm.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const pb = new PocketBase(import.meta.env.POCKETBASE_URL);
|
||||
const data = await request.json();
|
||||
|
||||
const { token, userId } = data;
|
||||
|
||||
console.log('Confirm request:', { userId, token });
|
||||
|
||||
if (!token || !userId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Отсутствуют параметры'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// Декодируем токен
|
||||
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
||||
const parts = decoded.split(':');
|
||||
|
||||
if (parts.length < 3) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Неверный формат токена'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
const [tokenUserId, email, timestamp] = parts;
|
||||
|
||||
// Проверяем что userId совпадает
|
||||
if (tokenUserId !== userId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Неверный токен'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// Проверяем срок токена (24 часа)
|
||||
const tokenTime = parseInt(timestamp);
|
||||
const now = Date.now();
|
||||
const maxAge = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (now - tokenTime > maxAge) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Срок действия ссылки истёк'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// Обновляем пользователя через HTTP с аутентификацией супер-админа
|
||||
const authResponse = await fetch(`${import.meta.env.POCKETBASE_URL}/api/collections/_superusers/auth-with-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identity: import.meta.env.PB_ADMIN_EMAIL,
|
||||
password: import.meta.env.PB_ADMIN_PASSWORD,
|
||||
}),
|
||||
});
|
||||
|
||||
let authToken = '';
|
||||
if (authResponse.ok) {
|
||||
const authData = await authResponse.json();
|
||||
authToken = authData.token;
|
||||
console.log('Superuser authenticated');
|
||||
} else {
|
||||
console.error('Auth failed:', authResponse.status);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Ошибка аутентификации'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// Обновляем
|
||||
const updateResponse = await fetch(`${import.meta.env.POCKETBASE_URL}/api/collections/users/records/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ verified: true }),
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
const err = await updateResponse.json();
|
||||
console.error('Update error:', err);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Не удалось обновить пользователя'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
console.log('User verified:', userId);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Email подтверждён'
|
||||
}), { status: 200 });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Confirm error:', error);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Ошибка при подтверждении'
|
||||
}), { status: 400 });
|
||||
}
|
||||
};
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { sendEmail, generateVerifyEmailHtml, getSiteUrl } from '../../../lib/email';
|
||||
|
||||
export const POST: APIRoute = async ({ request, redirect }) => {
|
||||
try {
|
||||
const pb = new PocketBase(import.meta.env.POCKETBASE_URL);
|
||||
const data = await request.json();
|
||||
|
||||
const { name, email, phone, password } = data;
|
||||
console.log('Registration attempt:', { email: data.email, firstName: data.firstName, lastName: data.lastName });
|
||||
|
||||
const { firstName, lastName, email, phone, password } = data;
|
||||
|
||||
if (!email || !password) {
|
||||
return new Response(JSON.stringify({
|
||||
|
|
@ -15,31 +18,59 @@ export const POST: APIRoute = async ({ request, redirect }) => {
|
|||
}), { status: 400 });
|
||||
}
|
||||
|
||||
// Создаём пользователя
|
||||
const record = await pb.collection('users').create({
|
||||
name,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
});
|
||||
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
console.log('User created:', record.id);
|
||||
|
||||
// Создаём токен подтверждения
|
||||
const verifyToken = Buffer.from(`${record.id}:${email}:${Date.now()}`).toString('base64').replace(/=/g, '');
|
||||
const verifyLink = `${getSiteUrl()}/auth/verify?token=${verifyToken}&userId=${record.id}`;
|
||||
|
||||
// Отправляем письмо с ссылкой подтверждения
|
||||
const html = generateVerifyEmailHtml(firstName || lastName || 'Пользователь', verifyLink);
|
||||
|
||||
const emailSent = await sendEmail({
|
||||
to: email,
|
||||
subject: 'Подтверждение регистрации — Автоюрист Сургут',
|
||||
html
|
||||
});
|
||||
|
||||
console.log('Verify email sent:', emailSent);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
record: {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
email: record.email,
|
||||
}
|
||||
message: 'На ваш email отправлена ссылка для подтверждения регистрации',
|
||||
email
|
||||
}), { status: 201 });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Sign up error:', error);
|
||||
|
||||
let errorMessage = 'Ошибка при регистрации';
|
||||
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data;
|
||||
if (data.email) {
|
||||
errorMessage = `Email: ${data.email.message || 'уже используется'}`;
|
||||
} else if (data.password) {
|
||||
errorMessage = `Пароль: ${data.password.message || 'некорректный'}`;
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || 'Ошибка при регистрации'
|
||||
error: errorMessage
|
||||
}), { status: 400 });
|
||||
}
|
||||
};
|
||||
|
|
@ -43,7 +43,10 @@ import { SITE_URL } from '@constants';
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<label for="password">
|
||||
Пароль
|
||||
<span class="hint">От 8 до 12 символов</span>
|
||||
</label>
|
||||
<div class="password-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
|
|
@ -52,6 +55,8 @@ import { SITE_URL } from '@constants';
|
|||
placeholder="••••••••"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
minlength="8"
|
||||
maxlength="12"
|
||||
/>
|
||||
<button type="button" class="toggle-password" data-target="password">
|
||||
<svg class="eye-open" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
|
@ -149,6 +154,15 @@ import { SITE_URL } from '@constants';
|
|||
color: #1e3050;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-group label .hint {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
|
|
@ -361,6 +375,10 @@ import { SITE_URL } from '@constants';
|
|||
return regex.test(value);
|
||||
}
|
||||
|
||||
function validatePassword(value: string): boolean {
|
||||
return value.length >= 8 && value.length <= 12;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.toggle-password').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const targetId = button.getAttribute('data-target');
|
||||
|
|
@ -384,7 +402,12 @@ import { SITE_URL } from '@constants';
|
|||
});
|
||||
|
||||
passwordInput?.addEventListener('input', () => {
|
||||
if (passwordInput.value) {
|
||||
if (passwordInput.value.length > 12) {
|
||||
passwordInput.value = passwordInput.value.slice(0, 12);
|
||||
}
|
||||
if (passwordInput.value && !validatePassword(passwordInput.value)) {
|
||||
showError(passwordInput, 'Пароль должен быть 8-12 символов');
|
||||
} else {
|
||||
clearError(passwordInput);
|
||||
}
|
||||
});
|
||||
|
|
@ -414,6 +437,9 @@ import { SITE_URL } from '@constants';
|
|||
if (!password || password.trim() === '') {
|
||||
showError(passwordInput, 'Введите пароль');
|
||||
hasErrors = true;
|
||||
} else if (!validatePassword(password)) {
|
||||
showError(passwordInput, 'Пароль должен быть от 8 до 12 символов');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ import { SITE_URL } from '@constants';
|
|||
<div class="modal-content">
|
||||
<h2 class="modal-title">Политика обработки персональных данных</h2>
|
||||
<div class="privacy-text">
|
||||
<p>Настоящая политика обработки персональных данных (далее — Политика) определяет порядок обработки персональных данных пользователей сайта avtourist.ru.</p>
|
||||
<p>Настоящая политика обработки персональных данных (далее — Политика) определяет порядок обработки персональных данных пользователей сайта avtourist-surgut.ru.</p>
|
||||
|
||||
<h3>1. Общие положения</h3>
|
||||
<p>1.1. Обработка персональных данных осуществляется на основе принципов законности, справедливости и конфиденциальности.</p>
|
||||
|
|
@ -533,6 +533,73 @@ import { SITE_URL } from '@constants';
|
|||
box-shadow: 0 4px 12px rgba(206, 159, 64, 0.4);
|
||||
}
|
||||
|
||||
/* Success message */
|
||||
.success-message {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.success-icon svg {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.success-message h3 {
|
||||
color: #1e3050;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.success-message p {
|
||||
color: #64748b;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.success-message .hint {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.success-message + .auth-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.success-message .btn-submit {
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.success-message .btn-submit:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(206, 159, 64, 0.4);
|
||||
}
|
||||
|
||||
/* Кнопка отправки */
|
||||
.btn-submit {
|
||||
background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%);
|
||||
|
|
@ -552,6 +619,12 @@ import { SITE_URL } from '@constants';
|
|||
box-shadow: 0 8px 20px rgba(206, 159, 64, 0.4);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-submit:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
|
@ -623,13 +696,11 @@ import { SITE_URL } from '@constants';
|
|||
}
|
||||
|
||||
function validateFirstName(value: string): boolean {
|
||||
const regex = /^[а-яА-ЯёЁ\-]+$/;
|
||||
return regex.test(value);
|
||||
return /^[а-яА-ЯёЁ\-]+$/.test(value);
|
||||
}
|
||||
|
||||
function validateLastName(value: string): boolean {
|
||||
const regex = /^[а-яА-ЯёЁ\-]+$/;
|
||||
return regex.test(value);
|
||||
return /^[а-яА-ЯёЁ\-]+$/.test(value);
|
||||
}
|
||||
|
||||
function validateEmail(value: string): boolean {
|
||||
|
|
@ -650,6 +721,7 @@ import { SITE_URL } from '@constants';
|
|||
document.querySelectorAll('.toggle-password').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const targetId = button.getAttribute('data-target');
|
||||
if (!targetId) return;
|
||||
const input = document.getElementById(targetId) as HTMLInputElement;
|
||||
if (input) {
|
||||
const isPassword = input.type === 'password';
|
||||
|
|
@ -660,9 +732,10 @@ import { SITE_URL } from '@constants';
|
|||
});
|
||||
|
||||
firstNameInput?.addEventListener('input', () => {
|
||||
const value = firstNameInput.value.replace(/[^а-яА-ЯёЁ\-]/g, '');
|
||||
const value = firstNameInput.value.replace(/[^\w\u0400-\u04FF\-]/gi, '');
|
||||
firstNameInput.value = value;
|
||||
if (firstNameInput.value && !validateFirstName(firstNameInput.value)) {
|
||||
const trimmed = value ? value.trim() : '';
|
||||
if (trimmed.length > 0 && !validateFirstName(trimmed)) {
|
||||
showError(firstNameInput, 'Используйте только русские буквы');
|
||||
} else {
|
||||
clearError(firstNameInput);
|
||||
|
|
@ -670,9 +743,10 @@ import { SITE_URL } from '@constants';
|
|||
});
|
||||
|
||||
lastNameInput?.addEventListener('input', () => {
|
||||
const value = lastNameInput.value.replace(/[^а-яА-ЯёЁ\-]/g, '');
|
||||
const value = lastNameInput.value.replace(/[^\w\u0400-\u04FF\-]/gi, '');
|
||||
lastNameInput.value = value;
|
||||
if (lastNameInput.value && !validateLastName(lastNameInput.value)) {
|
||||
const trimmed = value ? value.trim() : '';
|
||||
if (trimmed.length > 0 && !validateLastName(trimmed)) {
|
||||
showError(lastNameInput, 'Используйте только русские буквы');
|
||||
} else {
|
||||
clearError(lastNameInput);
|
||||
|
|
@ -751,21 +825,27 @@ import { SITE_URL } from '@constants';
|
|||
return;
|
||||
}
|
||||
|
||||
const name = formData.get('firstName') as string;
|
||||
const firstName = formData.get('firstName') as string;
|
||||
const lastName = formData.get('lastName') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const email = (formData.get('email') as string) || '';
|
||||
const phone = formData.get('phone') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
if (!validateFirstName(firstName)) {
|
||||
if (!firstName || typeof firstName !== 'string' || !firstName.trim()) {
|
||||
showError(firstNameInput, 'Введите имя');
|
||||
hasErrors = true;
|
||||
} else if (!validateFirstName(firstName)) {
|
||||
showError(firstNameInput, 'Введите имя (только русские буквы)');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (!validateLastName(lastName)) {
|
||||
if (!lastName || typeof lastName !== 'string' || !lastName.trim()) {
|
||||
showError(lastNameInput, 'Введите фамилию');
|
||||
hasErrors = true;
|
||||
} else if (!validateLastName(lastName)) {
|
||||
showError(lastNameInput, 'Введите фамилию (только русские буквы)');
|
||||
hasErrors = true;
|
||||
}
|
||||
|
|
@ -794,7 +874,53 @@ import { SITE_URL } from '@constants';
|
|||
return;
|
||||
}
|
||||
|
||||
console.log('Регистрация:', { firstName, lastName, email, phone, password });
|
||||
// Отправка данных на сервер
|
||||
const submitBtn = form.querySelector('.btn-submit') as HTMLButtonElement;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Регистрация...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/sign-up', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ firstName, lastName, email, phone, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Показываем success message и скрываем footer
|
||||
form.innerHTML = `
|
||||
<div class="success-message" style="text-align: center; padding: 1rem 0;">
|
||||
<div class="success-icon" style="width: 80px; height: 80px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 1.5rem;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 style="color: #1e3050; font-size: 1.5rem; font-weight: 700; margin: 0 0 1rem;">Регистрация успешна!</h3>
|
||||
<p style="color: #64748b; font-size: 1rem; line-height: 1.6; margin: 0 0 0.75rem;">На ваш email <strong>${email || ''}</strong> отправлена ссылка для подтверждения регистрации.</p>
|
||||
<p style="color: #94a3b8; font-size: 0.875rem;">Проверьте почту и перейдите по ссылке для активации аккаунта.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Скрываем footer с ссылкой на вход
|
||||
const authFooter = document.querySelector('.auth-footer') as HTMLElement;
|
||||
if (authFooter) {
|
||||
authFooter.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
showError(emailInput, result.error || 'Ошибка регистрации');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Зарегистрироваться';
|
||||
}
|
||||
} catch (err) {
|
||||
showError(emailInput, 'Ошибка соединения');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Зарегистрироваться';
|
||||
}
|
||||
});
|
||||
|
||||
// Модальное окно политики
|
||||
|
|
|
|||
208
frontend/src/pages/auth/verify.astro
Normal file
208
frontend/src/pages/auth/verify.astro
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
---
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import { SITE_URL } from '@constants';
|
||||
|
||||
const success = Astro.url.searchParams.get('success');
|
||||
const error = Astro.url.searchParams.get('error');
|
||||
const token = Astro.url.searchParams.get('token');
|
||||
const userId = Astro.url.searchParams.get('userId');
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Подтверждение email"
|
||||
description="Подтверждение email адреса"
|
||||
canonicalLink={`${SITE_URL}/auth/verify`}
|
||||
>
|
||||
<div class="verify-page">
|
||||
<div class="verify-container">
|
||||
<div class="verify-card" id="verify-card">
|
||||
<!-- Loading state -->
|
||||
<div id="loading">
|
||||
<div class="verify-icon">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<h1>Подтверждение email...</h1>
|
||||
</div>
|
||||
|
||||
<!-- Success state -->
|
||||
<div id="success" class="hidden">
|
||||
<div class="verify-icon success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Email подтверждён!</h1>
|
||||
<p>Ваш аккаунт успешно активирован. Теперь вы можете войти в личный кабинет.</p>
|
||||
<a href="/auth/sign-in" class="btn-primary">Войти в аккаунт</a>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div id="error" class="hidden">
|
||||
<div class="verify-icon error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Ошибка подтверждения</h1>
|
||||
<p id="error-message">Ссылка недействительна или истёк срок действия.</p>
|
||||
<a href="/auth/sign-up" class="btn-primary">На страницу регистрации</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ token, userId, success, error }}>
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
function showSection(id) {
|
||||
document.getElementById('loading')?.classList.add('hidden');
|
||||
document.getElementById('success')?.classList.add('hidden');
|
||||
document.getElementById('error')?.classList.add('hidden');
|
||||
document.getElementById(id)?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function verifyEmail() {
|
||||
const token = urlParams.get('token');
|
||||
const userId = urlParams.get('userId');
|
||||
|
||||
if (!token || !userId) {
|
||||
document.getElementById('error-message').textContent = 'Отсутствуют параметры подтверждения';
|
||||
showSection('error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/confirm', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, userId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showSection('success');
|
||||
} else {
|
||||
document.getElementById('error-message').textContent = data.error || 'Ошибка подтверждения';
|
||||
showSection('error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Verification error:', e);
|
||||
document.getElementById('error-message').textContent = 'Ошибка подтверждения';
|
||||
showSection('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (success === 'true') {
|
||||
showSection('success');
|
||||
} else if (error) {
|
||||
document.getElementById('error-message').textContent = error;
|
||||
showSection('error');
|
||||
} else if (token && userId) {
|
||||
verifyEmail();
|
||||
} else {
|
||||
document.getElementById('error-message').textContent = 'Отсутствует токен подтверждения';
|
||||
showSection('error');
|
||||
}
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.verify-page {
|
||||
min-height: calc(100vh - 160px);
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6rem 2rem 2rem;
|
||||
}
|
||||
|
||||
.verify-container {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.verify-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 2rem 1.5rem;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.verify-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.verify-icon.success {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
}
|
||||
|
||||
.verify-icon.error {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
}
|
||||
|
||||
.verify-icon svg {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.verify-card h1 {
|
||||
color: #1e3050;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.verify-card p {
|
||||
color: #64748b;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(206, 159, 64, 0.4);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue