feat: add WiFi authorization module

- Backend: hotel_wifi_settings table (migration 046), CRUD routes
- Public API endpoint /api/wifi-auth/verify for captive portal
- Auth methods: room+lastname, room+birthdate, room+any, room_only
- Frontend: WiFiPage with settings, token management, setup guide
- Guide tabs: MikroTik, UniFi, Universal with copy-paste instructions
- Sidebar: WiFi item in Settings group

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 12:08:51 +03:00
parent 46524077a0
commit 0088457f51
7 changed files with 886 additions and 2 deletions

View File

@@ -0,0 +1,226 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { decryptField } from '../lib/crypto'
import crypto from 'crypto'
type SlugParam = { Params: { slug: string } }
interface WifiSettings {
id: string
hotelId: string
enabled: boolean
apiToken: string
authMethod: 'room_lastname' | 'room_birthdate' | 'room_any' | 'room_only'
ssidName: string | null
welcomeText: string | null
sessionHours: number
}
const wifiSettings: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id ?? null
}
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// Ensures a wifi settings row exists for this hotel, returns it
const ensureSettings = async (hotelId: string) => {
const { rows } = await db.query(
`INSERT INTO hotel_wifi_settings (hotel_id)
VALUES ($1)
ON CONFLICT (hotel_id) DO UPDATE SET updated_at = hotel_wifi_settings.updated_at
RETURNING *`,
[hotelId],
)
return rows[0]
}
// ── GET /api/hotels/:slug/wifi-settings ────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/wifi-settings',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { slug } = req.params
if (!canAccess(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const row = await ensureSettings(hotelId)
return {
id: row.id,
hotelId: row.hotel_id,
enabled: row.enabled,
apiToken: row.api_token,
authMethod: row.auth_method,
ssidName: row.ssid_name,
welcomeText: row.welcome_text,
sessionHours: row.session_hours,
} satisfies WifiSettings
},
)
// ── PATCH /api/hotels/:slug/wifi-settings ──────────────────────────────────
fastify.patch<SlugParam & { Body: Partial<Omit<WifiSettings, 'id' | 'hotelId' | 'apiToken'>> }>(
'/api/hotels/:slug/wifi-settings',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { slug } = req.params
if (!canAccess(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
await ensureSettings(hotelId)
const b = req.body
const sets: string[] = []
const vals: unknown[] = [hotelId]
let idx = 2
const add = (col: string, val: unknown) => {
if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) }
}
add('enabled', b.enabled)
add('auth_method', b.authMethod)
add('ssid_name', b.ssidName)
add('welcome_text', b.welcomeText)
add('session_hours', b.sessionHours)
if (sets.length === 0) return reply.code(400).send({ error: 'No fields to update' })
sets.push('updated_at = NOW()')
const { rows: [row] } = await db.query(
`UPDATE hotel_wifi_settings SET ${sets.join(', ')} WHERE hotel_id = $1 RETURNING *`,
vals,
)
return {
id: row.id,
hotelId: row.hotel_id,
enabled: row.enabled,
apiToken: row.api_token,
authMethod: row.auth_method,
ssidName: row.ssid_name,
welcomeText: row.welcome_text,
sessionHours: row.session_hours,
}
},
)
// ── POST /api/hotels/:slug/wifi-settings/regenerate-token ──────────────────
fastify.post<SlugParam>(
'/api/hotels/:slug/wifi-settings/regenerate-token',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { slug } = req.params
if (!canAccess(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
await ensureSettings(hotelId)
const newToken = crypto.randomBytes(32).toString('hex')
const { rows: [row] } = await db.query(
`UPDATE hotel_wifi_settings SET api_token = $2, updated_at = NOW()
WHERE hotel_id = $1 RETURNING api_token`,
[hotelId, newToken],
)
return { apiToken: row.api_token }
},
)
}
// ── Public: POST /api/wifi-auth/verify ─────────────────────────────────────
// Called by wifisync.ru captive portal to verify a guest
// Auth: Bearer <api_token from hotel_wifi_settings>
export const wifiAuthVerify: FastifyPluginAsync = async (fastify) => {
fastify.post<{
Body: { room_number: string; last_name?: string; birth_date?: string }
}>(
'/api/wifi-auth/verify',
async (req, reply) => {
const authHeader = req.headers.authorization ?? ''
if (!authHeader.startsWith('Bearer ')) return reply.code(401).send({ ok: false, error: 'Unauthorized' })
const token = authHeader.slice(7).trim()
// Find hotel by token
const { rows: [ws] } = await db.query(
`SELECT hotel_id, auth_method, session_hours, enabled
FROM hotel_wifi_settings WHERE api_token = $1`,
[token],
)
if (!ws) return reply.code(401).send({ ok: false, error: 'Invalid token' })
if (!ws.enabled) return reply.code(403).send({ ok: false, error: 'WiFi auth is disabled' })
const { room_number, last_name, birth_date } = req.body
if (!room_number?.trim()) return reply.code(400).send({ ok: false, error: 'room_number required' })
// Find active booking in this room
const today = new Date().toISOString().slice(0, 10)
const { rows: bookings } = await db.query(
`SELECT b.id, b.guest_id, b.check_out,
g.first_name, g.last_name AS last_name_enc, g.birth_date AS birth_date_enc
FROM bookings b
JOIN rooms r ON r.id = b.room_id
LEFT JOIN guests g ON g.id = b.guest_id
WHERE b.hotel_id = $1
AND r.number = $2
AND b.status IN ('confirmed', 'checked_in')
AND b.check_in <= $3
AND b.check_out >= $3`,
[ws.hotel_id, room_number.trim(), today],
)
if (bookings.length === 0) {
return { ok: false, error: 'Бронирование не найдено. Обратитесь на ресепшн.' }
}
const method: string = ws.auth_method
// room_only — no extra check needed
if (method === 'room_only') {
const b = bookings[0]
return {
ok: true,
guestName: [b.first_name, decryptField(b.last_name_enc)].filter(Boolean).join(' ') || null,
checkout: b.check_out,
sessionHours: ws.session_hours,
}
}
// Check credentials against all bookings in that room
for (const b of bookings) {
const dbLastName = (decryptField(b.last_name_enc) ?? '').toLowerCase().trim()
const dbBirthDate = decryptField(b.birth_date_enc) ?? '' // YYYY-MM-DD
const lastNameMatch = last_name && dbLastName && last_name.toLowerCase().trim() === dbLastName
const birthDateMatch = birth_date && dbBirthDate && birth_date === dbBirthDate
const matches =
(method === 'room_lastname' && lastNameMatch) ||
(method === 'room_birthdate' && birthDateMatch) ||
(method === 'room_any' && (lastNameMatch || birthDateMatch))
if (matches) {
return {
ok: true,
guestName: [b.first_name, decryptField(b.last_name_enc)].filter(Boolean).join(' ') || null,
checkout: b.check_out,
sessionHours: ws.session_hours,
}
}
}
return { ok: false, error: 'Неверные данные. Проверьте фамилию или дату рождения.' }
},
)
}
export default wifiSettings