From 261d5db2d793921a14bf8ee653ea74262271093a Mon Sep 17 00:00:00 2001 From: Web-serfer Date: Wed, 15 Apr 2026 19:04:27 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=B2=D1=8B=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B8=D0=B7=20=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pb_migrations/1776259840_updated_users.js | 48 +++ .../pb_migrations/1776259856_updated_users.js | 29 ++ .../pb_migrations/1776260244_updated_users.js | 29 ++ .../src/components/layout/header/Header.astro | 123 +++++- .../components/layout/header/UserMenu.astro | 118 ++++++ .../src/pages/api/auth/forgot-password.ts | 123 ++++++ frontend/src/pages/api/auth/logout.ts | 11 + frontend/src/pages/api/auth/reset-password.ts | 101 +++++ frontend/src/pages/api/auth/sign-in.ts | 21 +- frontend/src/pages/api/auth/sign-up.ts | 6 +- frontend/src/pages/auth/forgot-password.astro | 250 ++++++++++++ frontend/src/pages/auth/reset-password.astro | 361 ++++++++++++++++++ frontend/src/pages/auth/sign-in.astro | 36 +- 13 files changed, 1244 insertions(+), 12 deletions(-) create mode 100644 backend/pb_migrations/1776259840_updated_users.js create mode 100644 backend/pb_migrations/1776259856_updated_users.js create mode 100644 backend/pb_migrations/1776260244_updated_users.js create mode 100644 frontend/src/components/layout/header/UserMenu.astro create mode 100644 frontend/src/pages/api/auth/forgot-password.ts create mode 100644 frontend/src/pages/api/auth/logout.ts create mode 100644 frontend/src/pages/api/auth/reset-password.ts create mode 100644 frontend/src/pages/auth/forgot-password.astro create mode 100644 frontend/src/pages/auth/reset-password.astro diff --git a/backend/pb_migrations/1776259840_updated_users.js b/backend/pb_migrations/1776259840_updated_users.js new file mode 100644 index 0000000..c55d5c8 --- /dev/null +++ b/backend/pb_migrations/1776259840_updated_users.js @@ -0,0 +1,48 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // add field + collection.fields.addAt(8, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1542800728", + "max": 0, + "min": 0, + "name": "field", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add field + collection.fields.addAt(9, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text596812118", + "max": 0, + "min": 0, + "name": "firstName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // remove field + collection.fields.removeById("text1542800728") + + // remove field + collection.fields.removeById("text596812118") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776259856_updated_users.js b/backend/pb_migrations/1776259856_updated_users.js new file mode 100644 index 0000000..885e536 --- /dev/null +++ b/backend/pb_migrations/1776259856_updated_users.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // add field + collection.fields.addAt(10, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text2434144904", + "max": 0, + "min": 0, + "name": "lastName", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // remove field + collection.fields.removeById("text2434144904") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776260244_updated_users.js b/backend/pb_migrations/1776260244_updated_users.js new file mode 100644 index 0000000..f65096b --- /dev/null +++ b/backend/pb_migrations/1776260244_updated_users.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // remove field + collection.fields.removeById("text1542800728") + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // add field + collection.fields.addAt(10, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1542800728", + "max": 0, + "min": 0, + "name": "field", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/frontend/src/components/layout/header/Header.astro b/frontend/src/components/layout/header/Header.astro index a5926d6..f86c75c 100644 --- a/frontend/src/components/layout/header/Header.astro +++ b/frontend/src/components/layout/header/Header.astro @@ -2,6 +2,8 @@ import Logo from "./Logo.astro"; import Navbar from "./Navbar.astro"; import MobileMenu from "./MobileMenu.astro"; +import LoginButton from "./LoginButton.astro"; +import UserMenu from "./UserMenu.astro"; import { COMPANY } from "@constants"; --- @@ -20,10 +22,11 @@ import { COMPANY } from "@constants"; - +
-
- +
+ +
+
${firstLetter}
+ +
+ `; + + // Обработчик выхода + document.getElementById('logout-btn')?.addEventListener('click', async () => { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } catch (e) {} + localStorage.removeItem('auth_token'); + localStorage.removeItem('user'); + window.location.href = '/'; + }); + } catch (e) { + showPhone(); + } + } else { + showPhone(); + } + } + + function showPhone() { + const authSection = document.getElementById('auth-section'); + const phoneEl = document.getElementById('header-phone'); + + if (phoneEl) phoneEl.style.display = 'flex'; + if (authSection) authSection.innerHTML = ''; + } + + // Стили + const authStyle = ` + .user-display { + display: flex; + align-items: center; + gap: 0.5rem; + } + .user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.9rem; + } + .logout-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: #ef4444; + border: none; + border-radius: 8px; + color: #fff; + cursor: pointer; + transition: all 0.3s ease; + } + .logout-btn:hover { + background: #dc2626; + transform: scale(1.05); + } + @media (max-width: 992px) { + .user-avatar { + width: 32px; + height: 32px; + font-size: 0.8rem; + } + .logout-btn { + width: 32px; + height: 32px; + } + .logout-btn svg { + width: 16px; + height: 16px; + } + } + `; + document.addEventListener("DOMContentLoaded", () => { + const styleEl = document.createElement('style'); + styleEl.textContent = authStyle; + document.head.appendChild(styleEl); + + initAuth(); const animatedElements = document.querySelectorAll(".animate-load"); animatedElements.forEach((el) => { diff --git a/frontend/src/components/layout/header/UserMenu.astro b/frontend/src/components/layout/header/UserMenu.astro new file mode 100644 index 0000000..8b5cfc4 --- /dev/null +++ b/frontend/src/components/layout/header/UserMenu.astro @@ -0,0 +1,118 @@ +--- +export interface Props { + userName?: string; + userEmail?: string; + class?: string; +} + +const { + userName = '', + userEmail = '', + class: className = '', +}: Props = Astro.props; + +// Получаем первую букву имени +const firstLetter = userName ? userName.charAt(0).toUpperCase() : userEmail?.charAt(0).toUpperCase() || 'U'; +--- + +
+
+ {firstLetter} +
+ + +
+ + + + \ No newline at end of file diff --git a/frontend/src/pages/api/auth/forgot-password.ts b/frontend/src/pages/api/auth/forgot-password.ts new file mode 100644 index 0000000..78208ed --- /dev/null +++ b/frontend/src/pages/api/auth/forgot-password.ts @@ -0,0 +1,123 @@ +import type { APIRoute } from 'astro'; +import PocketBase from 'pocketbase'; +import { sendEmail, getSiteUrl } from '../../../lib/email'; + +function generateResetPasswordHtml(firstName: string, resetLink: string): string { + return ` + + + + + + Сброс пароля + + + + + + +
+ + + + + + + + + + +
+

Автоюрист Сургут

+

Юридические услуги для автовладельцев

+
+

Сброс пароля

+

+ Здравствуйте, ${firstName}! +

+

+ Вы запросили сброс пароля. Нажмите кнопку ниже для создания нового пароля: +

+ + + + +
+ + Сбросить пароль + +
+

+ Ссылка действительна 1 час. Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо. +

+
+

+ © 2026 Автоюрист Сургут. Все права защищены. +

+
+
+ +`; +} + +export const POST: APIRoute = async ({ request }) => { + try { + const pb = new PocketBase(import.meta.env.POCKETBASE_URL); + const data = await request.json(); + + const { email } = data; + + console.log('Password reset request:', email); + + if (!email) { + return new Response(JSON.stringify({ + success: false, + error: 'Email обязателен' + }), { status: 400 }); + } + + // Проверяем существует ли пользователь + let user = null; + try { + user = await pb.collection('users').getFirstListItem(`email="${email}"`); + } catch (e) { + console.log('User not found, still return success'); + } + + if (!user) { + return new Response(JSON.stringify({ + success: true, + message: 'Ссылка для сброса пароля отправлена' + }), { status: 200 }); + } + + // Создаём свой токен сброса (как для верификации) + const resetToken = Buffer.from(`${user.id}:${Date.now()}`).toString('base64').replace(/=/g, ''); + const resetLink = `${getSiteUrl()}/auth/reset-password?token=${resetToken}&userId=${user.id}`; + + // Отправляем письмо + const firstName = user.firstName || 'Пользователь'; + const html = generateResetPasswordHtml(firstName, resetLink); + + const emailSent = await sendEmail({ + to: email, + subject: 'Сброс пароля — Автоюрист Сургут', + html + }); + + console.log('Reset email sent:', emailSent); + + return new Response(JSON.stringify({ + success: true, + message: 'Ссылка для сброса пароля отправлена' + }), { status: 200 }); + + } catch (error: any) { + console.error('Forgot password error:', error); + + return new Response(JSON.stringify({ + success: true, + message: 'Ссылка для сброса пароля отправлена' + }), { status: 200 }); + } +}; \ No newline at end of file diff --git a/frontend/src/pages/api/auth/logout.ts b/frontend/src/pages/api/auth/logout.ts new file mode 100644 index 0000000..f0c8f21 --- /dev/null +++ b/frontend/src/pages/api/auth/logout.ts @@ -0,0 +1,11 @@ +import type { APIRoute } from 'astro'; + +export const POST: APIRoute = async ({ cookies }) => { + // Очищаем куку авторизации + cookies.delete('pb_auth', { path: '/' }); + + return new Response(JSON.stringify({ + success: true, + message: 'Вышли из аккаунта' + }), { status: 200 }); +}; \ No newline at end of file diff --git a/frontend/src/pages/api/auth/reset-password.ts b/frontend/src/pages/api/auth/reset-password.ts new file mode 100644 index 0000000..a38f6d4 --- /dev/null +++ b/frontend/src/pages/api/auth/reset-password.ts @@ -0,0 +1,101 @@ +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, password } = data; + + console.log('Reset password request:', { userId }); + + if (!token || !userId || !password) { + return new Response(JSON.stringify({ + success: false, + error: 'Отсутствуют параметры' + }), { status: 400 }); + } + + // Валидация токена + const decoded = Buffer.from(token, 'base64').toString('utf8'); + const [tokenUserId, timestamp] = decoded.split(':'); + + if (tokenUserId !== userId) { + return new Response(JSON.stringify({ + success: false, + error: 'Неверный токен' + }), { status: 400 }); + } + + // Проверяем срок (1 час) + const tokenTime = parseInt(timestamp); + const now = Date.now(); + const maxAge = 60 * 60 * 1000; + + if (now - tokenTime > maxAge) { + return new Response(JSON.stringify({ + success: false, + error: 'Срок действия ссылки истёк' + }), { status: 400 }); + } + + // Аутентификация как superuser + 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; + } else { + 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({ + password: password, + passwordConfirm: password, + }), + }); + + if (!updateResponse.ok) { + const err = await updateResponse.json(); + console.error('Update password error:', err); + return new Response(JSON.stringify({ + success: false, + error: 'Не удалось обновить пароль' + }), { status: 400 }); + } + + console.log('Password updated for:', userId); + + return new Response(JSON.stringify({ + success: true, + message: 'Пароль успешно изменён' + }), { status: 200 }); + + } catch (error: any) { + console.error('Reset password error:', error); + + return new Response(JSON.stringify({ + success: false, + error: 'Ошибка при сбросе пароля' + }), { status: 400 }); + } +}; \ No newline at end of file diff --git a/frontend/src/pages/api/auth/sign-in.ts b/frontend/src/pages/api/auth/sign-in.ts index 8e662eb..b1cbf10 100644 --- a/frontend/src/pages/api/auth/sign-in.ts +++ b/frontend/src/pages/api/auth/sign-in.ts @@ -15,7 +15,19 @@ export const POST: APIRoute = async ({ request, cookies }) => { }), { status: 400 }); } - const authRecord = await pb.collection('users').authWithPassword(email, password); + const authData = await pb.collection('users').authWithPassword(email, password); + + console.log('Auth data token:', authData.token ? 'yes' : 'no'); + console.log('Auth record id:', authData.record?.id); + console.log('Auth record verified:', authData.record?.verified); + + // Проверяем верификацию + if (!authData.record.verified) { + return new Response(JSON.stringify({ + success: false, + error: 'Email не подтверждён' + }), { status: 401 }); + } cookies.set('pb_auth', JSON.stringify(pb.authStore.exportToCookie()), { path: '/', @@ -27,10 +39,11 @@ export const POST: APIRoute = async ({ request, cookies }) => { return new Response(JSON.stringify({ success: true, + token: authData.token, user: { - id: authRecord.id, - name: authRecord.name, - email: authRecord.email, + id: authData.record.id, + name: authData.record.name || authData.record.firstName, + email: authData.record.email, } }), { status: 200 }); diff --git a/frontend/src/pages/api/auth/sign-up.ts b/frontend/src/pages/api/auth/sign-up.ts index 01bf429..bc24b9c 100644 --- a/frontend/src/pages/api/auth/sign-up.ts +++ b/frontend/src/pages/api/auth/sign-up.ts @@ -11,10 +11,10 @@ export const POST: APIRoute = async ({ request, redirect }) => { const { firstName, lastName, email, phone, password } = data; - if (!email || !password) { + if (!firstName || !lastName || !email || !password) { return new Response(JSON.stringify({ success: false, - error: 'Email и пароль обязательны' + error: 'Все поля обязательны' }), { status: 400 }); } @@ -36,7 +36,7 @@ export const POST: APIRoute = async ({ request, redirect }) => { const verifyLink = `${getSiteUrl()}/auth/verify?token=${verifyToken}&userId=${record.id}`; // Отправляем письмо с ссылкой подтверждения - const html = generateVerifyEmailHtml(firstName || lastName || 'Пользователь', verifyLink); + const html = generateVerifyEmailHtml(firstName || 'Пользователь', verifyLink); const emailSent = await sendEmail({ to: email, diff --git a/frontend/src/pages/auth/forgot-password.astro b/frontend/src/pages/auth/forgot-password.astro new file mode 100644 index 0000000..15140f1 --- /dev/null +++ b/frontend/src/pages/auth/forgot-password.astro @@ -0,0 +1,250 @@ +--- +import Layout from '@layouts/Layout.astro'; +import { SITE_URL } from '@constants'; +--- + + +
+ + + + + \ No newline at end of file diff --git a/frontend/src/pages/auth/reset-password.astro b/frontend/src/pages/auth/reset-password.astro new file mode 100644 index 0000000..c9349e2 --- /dev/null +++ b/frontend/src/pages/auth/reset-password.astro @@ -0,0 +1,361 @@ +--- +import Layout from '@layouts/Layout.astro'; +import { SITE_URL } from '@constants'; + +const token = Astro.url.searchParams.get('token'); +const userId = Astro.url.searchParams.get('userId'); +const error = Astro.url.searchParams.get('error'); +--- + + +
+
+
+ +
+
+

Новый пароль

+

Придумайте новый пароль для аккаунта

+
+ +
+ + + +
+ +
+ + +
+ +
+ +
+ + + +
+ + +
+
+ + + + + + +
+
+
+
+ + + + \ No newline at end of file diff --git a/frontend/src/pages/auth/sign-in.astro b/frontend/src/pages/auth/sign-in.astro index eeb4222..faeff59 100644 --- a/frontend/src/pages/auth/sign-in.astro +++ b/frontend/src/pages/auth/sign-in.astro @@ -77,7 +77,7 @@ import { SITE_URL } from '@constants'; Запомнить меня - Забыли пароль? + Забыли пароль?