Files
hotelsync/backend/src/routes/auth.ts
HotelSync 696cf4d38c fix: refresh token re-reads role from DB; remove staff stats cards
- /api/auth/refresh now fetches current role from DB instead of using
  stale Redis payload — role changes now take effect on next page refresh
  without requiring re-login. Also deactivates token if user.active=false.
- Removed role-count stat cards from staff page (user request)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 20:17:09 +03:00

511 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyPluginAsync } from 'fastify'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { db } from '../db'
import { redis } from '../redis'
import { config } from '../config'
import { sendConfirmationEmail, sendPasswordResetEmail } from '../email'
import type { JwtPayload } from '../types'
const auth: FastifyPluginAsync = async (fastify) => {
// ── POST /api/auth/login ───────────────────────────────────────────────────
fastify.post<{ Body: { email: string; password: string } }>(
'/api/auth/login',
{
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string' },
password: { type: 'string' },
},
},
},
},
async (request, reply) => {
const { email, password } = request.body
const { rows } = await db.query(
`SELECT u.*, h.slug AS hotel_slug
FROM users u
LEFT JOIN hotels h ON h.id = u.hotel_id
WHERE u.email = $1`,
[email.toLowerCase().trim()],
)
const user = rows[0]
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
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,
name: user.name,
role: user.role,
hotelId: user.hotel_id ?? null,
hotelSlug: user.hotel_slug ?? null,
}
const accessToken = fastify.jwt.sign(payload, {
expiresIn: config.jwt.accessExpiry,
})
const refreshToken = crypto.randomBytes(40).toString('hex')
await redis.set(
`refresh:${refreshToken}`,
JSON.stringify(payload),
'EX',
config.jwt.refreshExpiry,
)
reply.setCookie('refresh_token', refreshToken, {
httpOnly: true,
secure: config.nodeEnv === 'production',
sameSite: 'strict',
path: '/api/auth',
maxAge: config.jwt.refreshExpiry,
})
return {
access_token: accessToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
hotelId: user.hotel_id ?? null,
hotelSlug: user.hotel_slug ?? null,
},
}
},
)
// ── POST /api/auth/register ────────────────────────────────────────────────
fastify.post<{
Body: {
hotel_name: string
address: string
contact: string
email: string
phone: string
password: string
}
}>(
'/api/auth/register',
{
schema: {
body: {
type: 'object',
required: ['hotel_name', 'address', 'contact', 'email', 'phone', 'password'],
properties: {
hotel_name: { type: 'string', minLength: 2 },
address: { type: 'string', minLength: 2 },
contact: { type: 'string', minLength: 2 },
email: { type: 'string' },
phone: { type: 'string', minLength: 5 },
password: { type: 'string', minLength: 8 },
},
},
},
},
async (request, reply) => {
const { hotel_name: hotelName, address, contact, email, phone, password } = request.body
if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) {
return reply.code(400).send({ error: 'Пароль должен содержать хотя бы одну букву' })
}
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, 'hotel_admin', $4, $5, false, $6, NOW())`,
[contact, email.toLowerCase().trim(), passwordHash, hotel.id, phone ?? null, confirmToken],
)
// Template rental objects (inactive drafts — user activates as needed)
const templates = [
{ name: 'Теннисный корт', icon: '🎾', color: 'bg-green-500', text_color: 'text-green-700 dark:text-green-400', price_h: 1500, price_d: 8000, open: 8, close: 22, sort: 1 },
{ name: 'Баня / сауна', icon: '🛁', color: 'bg-orange-500', text_color: 'text-orange-700 dark:text-orange-400', price_h: 2500, price_d: 12000, open: 10, close: 23, sort: 2 },
{ name: 'Конференц-зал', icon: '🏛️', color: 'bg-blue-500', text_color: 'text-blue-700 dark:text-blue-400', price_h: 3000, price_d: 15000, open: 9, close: 20, sort: 3 },
]
for (const t of templates) {
await client.query(
`INSERT INTO rental_objects
(hotel_id, name, icon, color, text_color, price_per_hour, price_per_day,
open_hour, close_hour, sort_order, is_active)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, false)`,
[hotel.id, t.name, t.icon, t.color, t.text_color, t.price_h, t.price_d, t.open, t.close, t.sort],
)
}
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/resend-confirmation ────────────────────────────────────
fastify.post<{ Body: { email: string } }>(
'/api/auth/resend-confirmation',
{
schema: {
body: {
type: 'object',
required: ['email'],
properties: { email: { type: 'string' } },
},
},
},
async (request, reply) => {
const { email } = request.body
const { rows } = await db.query(
`SELECT id, name FROM users WHERE email = $1 AND email_confirmed = false`,
[email.toLowerCase().trim()],
)
// Always return 200 to prevent enumeration
if (rows.length === 0) return reply.code(200).send({ ok: true })
const user = rows[0]
const confirmToken = crypto.randomBytes(32).toString('hex')
await db.query(
`UPDATE users SET confirmation_token = $1, confirmation_sent_at = NOW() WHERE id = $2`,
[confirmToken, user.id],
)
try {
await sendConfirmationEmail(email, user.name as string, confirmToken)
} catch (err) {
console.error('[email] Resend confirmation failed:', err)
}
return reply.code(200).send({ ok: true })
},
)
// ── POST /api/auth/refresh ─────────────────────────────────────────────────
fastify.post('/api/auth/refresh', async (request, reply) => {
const refreshToken = request.cookies?.refresh_token
if (!refreshToken) return reply.code(401).send({ error: 'No refresh token' })
const stored = await redis.get(`refresh:${refreshToken}`)
if (!stored) return reply.code(401).send({ error: 'Invalid or expired refresh token' })
const cached = JSON.parse(stored) as JwtPayload
// Re-read role and active status from DB so role changes take effect immediately
const { rows } = await db.query(
`SELECT u.role, u.active, h.slug AS hotel_slug
FROM users u
LEFT JOIN hotels h ON h.id = u.hotel_id
WHERE u.id = $1`,
[cached.sub],
)
if (!rows[0] || !rows[0].active) {
await redis.del(`refresh:${refreshToken}`)
reply.clearCookie('refresh_token', { path: '/api/auth' })
return reply.code(401).send({ error: 'Account inactive or not found' })
}
const payload: JwtPayload = {
...cached,
role: rows[0].role,
hotelSlug: rows[0].hotel_slug ?? cached.hotelSlug,
}
// Update Redis with fresh payload
const ttl = await redis.ttl(`refresh:${refreshToken}`)
if (ttl > 0) await redis.set(`refresh:${refreshToken}`, JSON.stringify(payload), 'EX', ttl)
const accessToken = fastify.jwt.sign(payload, { expiresIn: config.jwt.accessExpiry })
return { access_token: accessToken }
})
// ── POST /api/auth/logout ──────────────────────────────────────────────────
fastify.post('/api/auth/logout', async (request, reply) => {
const refreshToken = request.cookies?.refresh_token
if (refreshToken) {
await redis.del(`refresh:${refreshToken}`)
}
reply.clearCookie('refresh_token', { path: '/api/auth' })
return { ok: true }
})
// ── POST /api/auth/forgot-password ────────────────────────────────────────
fastify.post<{ Body: { email: string } }>(
'/api/auth/forgot-password',
{
schema: {
body: {
type: 'object',
required: ['email'],
properties: { email: { type: 'string' } },
},
},
},
async (request, reply) => {
const { email } = request.body
const { rows } = await db.query(
`SELECT id, name FROM users WHERE email = $1 AND email_confirmed = true`,
[email.toLowerCase().trim()],
)
// Always return 200 to prevent email enumeration
if (rows.length === 0) return { ok: true }
const user = rows[0]
const resetToken = crypto.randomBytes(32).toString('hex')
const expires = new Date(Date.now() + 3600 * 1000) // 1 hour
await db.query(
`UPDATE users SET reset_token = $1, reset_token_expires = $2 WHERE id = $3`,
[resetToken, expires, user.id],
)
try {
await sendPasswordResetEmail(email, user.name as string, resetToken)
} catch (err) {
console.error('[email] Password reset email failed:', err)
}
return reply.code(200).send({ ok: true })
},
)
// ── POST /api/auth/reset-password ─────────────────────────────────────────
fastify.post<{ Body: { token: string; password: string } }>(
'/api/auth/reset-password',
{
schema: {
body: {
type: 'object',
required: ['token', 'password'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 8 },
},
},
},
},
async (request, reply) => {
const { token, password } = request.body
if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) {
return reply.code(400).send({ error: 'Пароль должен содержать хотя бы одну букву' })
}
const { rows } = await db.query(
`SELECT id FROM users
WHERE reset_token = $1
AND reset_token_expires > NOW()`,
[token],
)
if (rows.length === 0) {
return reply.code(400).send({ error: 'Ссылка недействительна или истекла' })
}
const passwordHash = await bcrypt.hash(password, 12)
await db.query(
`UPDATE users SET password_hash = $1, reset_token = NULL, reset_token_expires = NULL WHERE id = $2`,
[passwordHash, rows[0].id],
)
return { ok: true }
},
)
// ── POST /api/auth/accept-invite ──────────────────────────────────────────
fastify.post<{ Body: { token: string; password: string } }>(
'/api/auth/accept-invite',
{
schema: {
body: {
type: 'object',
required: ['token', 'password'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 6 },
},
},
},
},
async (request, reply) => {
const { token, password } = request.body
const { rows } = await db.query(
`SELECT u.*, h.slug AS hotel_slug
FROM users u
LEFT JOIN hotels h ON h.id = u.hotel_id
WHERE u.invite_token = $1 AND u.invite_expires > NOW()`,
[token],
)
if (rows.length === 0) {
return reply.code(400).send({ error: 'Ссылка недействительна или истекла' })
}
const user = rows[0]
const passwordHash = await bcrypt.hash(password, 12)
await db.query(
`UPDATE users
SET password_hash = $1,
email_confirmed = true,
active = true,
invite_token = NULL,
invite_expires = NULL,
updated_at = NOW()
WHERE id = $2`,
[passwordHash, user.id],
)
// Auto-login
const payload: JwtPayload = {
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
hotelId: user.hotel_id ?? null,
hotelSlug: user.hotel_slug ?? null,
}
const accessToken = fastify.jwt.sign(payload, { expiresIn: config.jwt.accessExpiry })
const refreshToken = crypto.randomBytes(40).toString('hex')
await redis.set(`refresh:${refreshToken}`, JSON.stringify(payload), 'EX', config.jwt.refreshExpiry)
reply.setCookie('refresh_token', refreshToken, {
httpOnly: true,
secure: config.nodeEnv === 'production',
sameSite: 'strict',
path: '/api/auth',
maxAge: config.jwt.refreshExpiry,
})
return {
access_token: accessToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
hotelId: user.hotel_id ?? null,
hotelSlug: user.hotel_slug ?? null,
},
}
},
)
// ── GET /api/auth/me ───────────────────────────────────────────────────────
fastify.get(
'/api/auth/me',
{ onRequest: [fastify.authenticate] },
async (request) => {
const { rows } = await db.query(
`SELECT u.id, u.email, u.name, u.role, u.hotel_id, u.created_at, h.slug AS hotel_slug
FROM users u
LEFT JOIN hotels h ON h.id = u.hotel_id
WHERE u.id = $1`,
[request.user.sub],
)
return rows[0] ?? null
},
)
}
export default auth