diff --git a/backend/migrations/005_email_confirmation.sql b/backend/migrations/005_email_confirmation.sql new file mode 100644 index 0000000..a92b38b --- /dev/null +++ b/backend/migrations/005_email_confirmation.sql @@ -0,0 +1,7 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS email_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS confirmation_token VARCHAR(255), + ADD COLUMN IF NOT EXISTS confirmation_sent_at TIMESTAMPTZ; + +-- Existing users (seed data) are already confirmed +UPDATE users SET email_confirmed = TRUE WHERE confirmation_token IS NULL; diff --git a/backend/package.json b/backend/package.json index 3faad1c..5eeb487 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,11 +17,13 @@ "bcryptjs": "^2.4.3", "fastify": "^4.28.1", "ioredis": "^5.3.2", + "nodemailer": "^8.0.2", "pg": "^8.12.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^20.14.0", + "@types/nodemailer": "^7.0.11", "@types/pg": "^8.11.6", "tsx": "^4.15.7", "typescript": "^5.4.5" diff --git a/backend/src/email.ts b/backend/src/email.ts new file mode 100644 index 0000000..824462d --- /dev/null +++ b/backend/src/email.ts @@ -0,0 +1,56 @@ +import nodemailer from 'nodemailer' + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST ?? 'smtp.timeweb.ru', + port: parseInt(process.env.SMTP_PORT ?? '465', 10), + secure: true, + auth: { + user: process.env.SMTP_USER ?? 'noreply@hotelsync.ru', + pass: process.env.SMTP_PASS ?? '08K28150zBwii32c85', + }, +}) + +export async function sendConfirmationEmail(to: string, name: string, token: string): Promise { + const apiUrl = process.env.API_URL ?? 'https://api.hotelsync.ru' + const confirmUrl = `${apiUrl}/api/auth/confirm-email?token=${token}` + const fromAddr = process.env.SMTP_USER ?? 'noreply@hotelsync.ru' + + await transporter.sendMail({ + from: `"HotelSync" <${fromAddr}>`, + to, + subject: 'Подтвердите email — HotelSync', + html: ` + + + +
+
+
🏨
+

HotelSync

+

Современная PMS-система

+
+
+

Добро пожаловать, ${name}!

+

+ Спасибо за регистрацию в HotelSync. Для активации аккаунта нажмите на кнопку ниже. +

+
+ + ✓ Подтвердить email + +
+

+ ⏱ Ссылка действительна 24 часа.
+ Если вы не регистрировались — просто проигнорируйте это письмо. +

+
+

+ Кнопка не работает? Скопируйте ссылку в браузер:
+ ${confirmUrl} +

+
+
+ +`, + }) +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 7d2eef0..a40f082 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -4,6 +4,7 @@ import crypto from 'crypto' import { db } from '../db' import { redis } from '../redis' import { config } from '../config' +import { sendConfirmationEmail } from '../email' import type { JwtPayload } from '../types' const auth: FastifyPluginAsync = async (fastify) => { @@ -38,6 +39,10 @@ const auth: FastifyPluginAsync = async (fastify) => { return reply.code(401).send({ error: 'Неверный email или пароль' }) } + if (!user.email_confirmed) { + return reply.code(403).send({ error: 'Email не подтверждён. Проверьте почту и перейдите по ссылке.' }) + } + const payload: JwtPayload = { sub: user.id, email: user.email, @@ -51,7 +56,6 @@ const auth: FastifyPluginAsync = async (fastify) => { expiresIn: config.jwt.accessExpiry, }) - // Refresh token — opaque, stored in Redis const refreshToken = crypto.randomBytes(40).toString('hex') await redis.set( `refresh:${refreshToken}`, @@ -82,6 +86,145 @@ const auth: FastifyPluginAsync = async (fastify) => { }, ) + // ── POST /api/auth/register ──────────────────────────────────────────────── + fastify.post<{ + Body: { + hotelName: string + address?: string + contact: string + email: string + phone?: string + password: string + } + }>( + '/api/auth/register', + { + schema: { + body: { + type: 'object', + required: ['hotelName', 'contact', 'email', 'password'], + properties: { + hotelName: { type: 'string', minLength: 2 }, + address: { type: 'string' }, + contact: { type: 'string', minLength: 2 }, + email: { type: 'string' }, + phone: { type: 'string' }, + password: { type: 'string', minLength: 8 }, + }, + }, + }, + }, + async (request, reply) => { + const { hotelName, address, contact, email, phone, password } = request.body + + const { rows: existing } = await db.query( + 'SELECT id FROM users WHERE email = $1', + [email.toLowerCase().trim()], + ) + if (existing.length > 0) { + return reply.code(409).send({ error: 'Пользователь с таким email уже существует' }) + } + + // Generate slug from hotel name (transliterate RU→EN) + const ru: Record = { + а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y', + к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f', + х:'h',ц:'ts',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya', + } + const baseSlug = hotelName + .toLowerCase() + .split('') + .map(c => ru[c] ?? c) + .join('') + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .substring(0, 50) || 'hotel' + + let slug = baseSlug + let suffix = 2 + for (;;) { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + if (rows.length === 0) break + slug = `${baseSlug}-${suffix++}` + } + + const passwordHash = await bcrypt.hash(password, 12) + const confirmToken = crypto.randomBytes(32).toString('hex') + + const client = await db.connect() + try { + await client.query('BEGIN') + const { rows: [hotel] } = await client.query( + `INSERT INTO hotels (name, slug, address, timezone, currency, plan, is_active) + VALUES ($1, $2, $3, 'Europe/Moscow', 'RUB', 'starter', true) + RETURNING id`, + [hotelName, slug, address ?? null], + ) + await client.query( + `INSERT INTO users (name, email, password_hash, role, hotel_id, phone, email_confirmed, confirmation_token, confirmation_sent_at) + VALUES ($1, $2, $3, 'manager', $4, $5, false, $6, NOW())`, + [contact, email.toLowerCase().trim(), passwordHash, hotel.id, phone ?? null, confirmToken], + ) + await client.query('COMMIT') + } catch (err) { + await client.query('ROLLBACK') + throw err + } finally { + client.release() + } + + try { + await sendConfirmationEmail(email, contact, confirmToken) + } catch (emailErr) { + console.error('[email] Confirmation email failed:', emailErr) + // Don't fail the registration — user can request resend later + } + + return reply.code(201).send({ + ok: true, + message: `Письмо с подтверждением отправлено на ${email}`, + }) + }, + ) + + // ── GET /api/auth/confirm-email ──────────────────────────────────────────── + fastify.get<{ Querystring: { token?: string } }>( + '/api/auth/confirm-email', + async (request, reply) => { + const { token } = request.query + const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru' + + if (!token) { + return reply.redirect(`${appUrl}/login?error=invalid_token`) + } + + const { rows } = await db.query( + `SELECT id, confirmation_sent_at FROM users + WHERE confirmation_token = $1 AND email_confirmed = false`, + [token], + ) + + if (rows.length === 0) { + return reply.redirect(`${appUrl}/login?error=invalid_token`) + } + + const sentAt = new Date(rows[0].confirmation_sent_at as string) + const hoursElapsed = (Date.now() - sentAt.getTime()) / 1000 / 3600 + if (hoursElapsed > 24) { + return reply.redirect(`${appUrl}/login?error=token_expired`) + } + + await db.query( + `UPDATE users SET email_confirmed = true, confirmation_token = NULL WHERE id = $1`, + [rows[0].id], + ) + + return reply.redirect(`${appUrl}/login?confirmed=1`) + }, + ) + // ── POST /api/auth/refresh ───────────────────────────────────────────────── fastify.post('/api/auth/refresh', async (request, reply) => { const refreshToken = request.cookies?.refresh_token diff --git a/src/lib/api.ts b/src/lib/api.ts index e7ab260..abeb100 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -145,6 +145,16 @@ export const api = { me: () => req('GET', '/api/auth/me'), + + register: (data: { + hotelName: string + address?: string + contact: string + email: string + phone?: string + password: string + }) => + req<{ ok: boolean; message: string }>('POST', '/api/auth/register', data), }, // ── Rooms ───────────────────────────────────────────────────────────────── diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index dc6a19e..1e08a31 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Navigate, useNavigate } from 'react-router-dom' +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom' import { Hotel, Eye, EyeOff, Sun, Moon, AlertCircle, Building2, ChevronRight, ChevronLeft, CheckCircle2, User, @@ -7,6 +7,7 @@ import { import { useAuth } from '../contexts/AuthContext' import { useTheme } from '../contexts/ThemeContext' import { cn } from '../lib/utils' +import { api } from '../lib/api' const DEMO_EMAILS = [ 'manager@grand-palace.ru', @@ -35,6 +36,9 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) { const [showPass, setShowPass] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [searchParams] = useSearchParams() + const confirmed = searchParams.get('confirmed') === '1' + const tokenError = searchParams.get('error') const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -90,6 +94,22 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) { )} + {confirmed && ( +
+ Email подтверждён! Теперь вы можете войти. +
+ )} + {tokenError === 'invalid_token' && ( +
+ Ссылка недействительна или уже использована. +
+ )} + {tokenError === 'token_expired' && ( +
+ Ссылка истекла (24 ч). Пожалуйста, зарегистрируйтесь снова. +
+ )} +