Add hotel registration with email confirmation (nodemailer, confirm-email endpoint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
backend/migrations/005_email_confirmation.sql
Normal file
7
backend/migrations/005_email_confirmation.sql
Normal file
@@ -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;
|
||||||
@@ -17,11 +17,13 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
"pg": "^8.12.0"
|
"pg": "^8.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/pg": "^8.11.6",
|
"@types/pg": "^8.11.6",
|
||||||
"tsx": "^4.15.7",
|
"tsx": "^4.15.7",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
|
|||||||
56
backend/src/email.ts
Normal file
56
backend/src/email.ts
Normal file
@@ -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<void> {
|
||||||
|
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: `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
|
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
|
||||||
|
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||||
|
<div style="background:linear-gradient(135deg,#4f46e5,#6366f1);padding:32px 40px;text-align:center;">
|
||||||
|
<div style="display:inline-block;width:48px;height:48px;background:rgba(255,255,255,0.2);border-radius:12px;line-height:48px;font-size:24px;margin-bottom:12px;">🏨</div>
|
||||||
|
<h1 style="color:#fff;margin:0;font-size:26px;font-weight:700;letter-spacing:-0.5px;">HotelSync</h1>
|
||||||
|
<p style="color:#c7d2fe;margin:6px 0 0;font-size:14px;">Современная PMS-система</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:40px;">
|
||||||
|
<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Добро пожаловать, ${name}!</h2>
|
||||||
|
<p style="color:#475569;line-height:1.7;margin:0 0 28px;font-size:15px;">
|
||||||
|
Спасибо за регистрацию в HotelSync. Для активации аккаунта нажмите на кнопку ниже.
|
||||||
|
</p>
|
||||||
|
<div style="text-align:center;margin:0 0 28px;">
|
||||||
|
<a href="${confirmUrl}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
|
||||||
|
✓ Подтвердить email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color:#94a3b8;font-size:13px;margin:0 0 16px;line-height:1.6;background:#f8fafc;padding:12px 16px;border-radius:8px;border-left:3px solid #e2e8f0;">
|
||||||
|
⏱ Ссылка действительна <strong>24 часа</strong>.<br>
|
||||||
|
Если вы не регистрировались — просто проигнорируйте это письмо.
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
|
||||||
|
<p style="color:#94a3b8;font-size:12px;margin:0;line-height:1.6;">
|
||||||
|
Кнопка не работает? Скопируйте ссылку в браузер:<br>
|
||||||
|
<a href="${confirmUrl}" style="color:#6366f1;word-break:break-all;font-size:11px;">${confirmUrl}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import crypto from 'crypto'
|
|||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { redis } from '../redis'
|
import { redis } from '../redis'
|
||||||
import { config } from '../config'
|
import { config } from '../config'
|
||||||
|
import { sendConfirmationEmail } from '../email'
|
||||||
import type { JwtPayload } from '../types'
|
import type { JwtPayload } from '../types'
|
||||||
|
|
||||||
const auth: FastifyPluginAsync = async (fastify) => {
|
const auth: FastifyPluginAsync = async (fastify) => {
|
||||||
@@ -38,6 +39,10 @@ const auth: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(401).send({ error: 'Неверный email или пароль' })
|
return reply.code(401).send({ error: 'Неверный email или пароль' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.email_confirmed) {
|
||||||
|
return reply.code(403).send({ error: 'Email не подтверждён. Проверьте почту и перейдите по ссылке.' })
|
||||||
|
}
|
||||||
|
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -51,7 +56,6 @@ const auth: FastifyPluginAsync = async (fastify) => {
|
|||||||
expiresIn: config.jwt.accessExpiry,
|
expiresIn: config.jwt.accessExpiry,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh token — opaque, stored in Redis
|
|
||||||
const refreshToken = crypto.randomBytes(40).toString('hex')
|
const refreshToken = crypto.randomBytes(40).toString('hex')
|
||||||
await redis.set(
|
await redis.set(
|
||||||
`refresh:${refreshToken}`,
|
`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<string, string> = {
|
||||||
|
а:'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 ─────────────────────────────────────────────────
|
// ── POST /api/auth/refresh ─────────────────────────────────────────────────
|
||||||
fastify.post('/api/auth/refresh', async (request, reply) => {
|
fastify.post('/api/auth/refresh', async (request, reply) => {
|
||||||
const refreshToken = request.cookies?.refresh_token
|
const refreshToken = request.cookies?.refresh_token
|
||||||
|
|||||||
@@ -145,6 +145,16 @@ export const api = {
|
|||||||
|
|
||||||
me: () =>
|
me: () =>
|
||||||
req<User>('GET', '/api/auth/me'),
|
req<User>('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 ─────────────────────────────────────────────────────────────────
|
// ── Rooms ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Navigate, useNavigate } from 'react-router-dom'
|
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle,
|
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle,
|
||||||
Building2, ChevronRight, ChevronLeft, CheckCircle2, User,
|
Building2, ChevronRight, ChevronLeft, CheckCircle2, User,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
const DEMO_EMAILS = [
|
const DEMO_EMAILS = [
|
||||||
'manager@grand-palace.ru',
|
'manager@grand-palace.ru',
|
||||||
@@ -35,6 +36,9 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
|
|||||||
const [showPass, setShowPass] = useState(false)
|
const [showPass, setShowPass] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const confirmed = searchParams.get('confirmed') === '1'
|
||||||
|
const tokenError = searchParams.get('error')
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -90,6 +94,22 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmed && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 text-sm">
|
||||||
|
<CheckCircle2 size={15} /> Email подтверждён! Теперь вы можете войти.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tokenError === 'invalid_token' && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 text-sm">
|
||||||
|
<AlertCircle size={15} /> Ссылка недействительна или уже использована.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tokenError === 'token_expired' && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 text-sm">
|
||||||
|
<AlertCircle size={15} /> Ссылка истекла (24 ч). Пожалуйста, зарегистрируйтесь снова.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5">
|
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5">
|
||||||
{loading
|
{loading
|
||||||
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
@@ -162,9 +182,19 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
|
|||||||
setErrors(err)
|
setErrors(err)
|
||||||
if (Object.keys(err).length > 0) return
|
if (Object.keys(err).length > 0) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
await new Promise(r => setTimeout(r, 1000))
|
try {
|
||||||
setLoading(false)
|
await api.auth.register({ hotelName, address, contact, email, phone, password })
|
||||||
setDone(true)
|
setDone(true)
|
||||||
|
} catch (regErr) {
|
||||||
|
const msg = regErr instanceof Error ? regErr.message : 'Ошибка регистрации'
|
||||||
|
if (msg.includes('уже существует')) {
|
||||||
|
setErrors({ email: 'Пользователь с таким email уже существует' })
|
||||||
|
} else {
|
||||||
|
setErrors({ general: msg })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
@@ -259,6 +289,12 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{errors.general && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
||||||
|
<AlertCircle size={15} /> {errors.general}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5 mt-2">
|
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5 mt-2">
|
||||||
{loading
|
{loading
|
||||||
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
|||||||
Reference in New Issue
Block a user