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:
2026-03-11 15:20:05 +03:00
parent 4e615359eb
commit c233590c4b
29 changed files with 2171 additions and 149 deletions

128
backend/src/routes/auth.ts Normal file
View 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