Создана система регистрации пользователя

This commit is contained in:
Web-serfer 2026-04-15 18:08:26 +05:00
parent 13754eecc3
commit 229826acc3
10 changed files with 1332 additions and 40 deletions

View 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 });
}
};

View file

@ -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 });
}
};

View file

@ -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) {

View file

@ -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 = 'Зарегистрироваться';
}
});
// Модальное окно политики

View 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>