Major UX improvements across multiple pages
Шахматка: - Date/period picker dropdown on navigation button (choose start date + days window) - Cancelled bookings fade out with animation after 1 second Бронирования: - Clickable column headers with sort asc/desc (Гость, Заезд, Выезд, Статус, Источник, Сумма) Страница входа: - Removed role-based account selector — just email + password - System auto-detects role/hotel from credentials Настройки: - New "Бронирование" section with room assignment strategy (spread/together/sequential/manual) - Notifications: SMTP email config + SMS provider config (SMSC, SMS.ru, МТС, etc.) Модули: - Added Housekeeping and Channel Manager as proper modules - Channel Manager lists: Booking.com, Airbnb, Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip - Housekeeping visible to all roles (including housekeeper) via module status - Sidebar now uses module status to show/hide Уборка and Каналы Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
128
backend/src/routes/auth.ts
Normal file
128
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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 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 или пароль' })
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
// Refresh token — opaque, stored in Redis
|
||||
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/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 payload = JSON.parse(stored) as JwtPayload
|
||||
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 }
|
||||
})
|
||||
|
||||
// ── 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
|
||||
Reference in New Issue
Block a user