diff --git a/backend/migrations/046_wifi_settings.sql b/backend/migrations/046_wifi_settings.sql new file mode 100644 index 0000000..607e435 --- /dev/null +++ b/backend/migrations/046_wifi_settings.sql @@ -0,0 +1,15 @@ +-- WiFi Auth module settings per hotel +CREATE TABLE IF NOT EXISTS hotel_wifi_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + api_token TEXT NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'), + auth_method TEXT NOT NULL DEFAULT 'room_any', + -- room_lastname | room_birthdate | room_any | room_only + ssid_name TEXT, + welcome_text TEXT DEFAULT 'Добро пожаловать! Введите данные для подключения к Wi-Fi.', + session_hours INT NOT NULL DEFAULT 24, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (hotel_id) +); diff --git a/backend/src/app.ts b/backend/src/app.ts index b43090e..d807b96 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -36,6 +36,7 @@ import loyaltyRoutes from './routes/loyalty' import chatRoutes from './routes/chat' import workstationRoutes from './routes/workstations' import agentReleaseRoutes from './routes/agent-release' +import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' @@ -123,6 +124,8 @@ export async function buildApp() { await fastify.register(chatRoutes) await fastify.register(workstationRoutes) await fastify.register(agentReleaseRoutes) + await fastify.register(wifiSettingsRoutes) + await fastify.register(wifiAuthVerify) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/routes/wifi-settings.ts b/backend/src/routes/wifi-settings.ts new file mode 100644 index 0000000..8c9883c --- /dev/null +++ b/backend/src/routes/wifi-settings.ts @@ -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 => { + 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( + '/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> }>( + '/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( + '/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 +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 diff --git a/src/App.tsx b/src/App.tsx index b9eaa80..24a7099 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import { TechnicalPage } from './pages/TechnicalPage' import { SchedulePage } from './pages/SchedulePage' import { BillingPage } from './pages/BillingPage' import { EquipmentPage } from './pages/EquipmentPage' +import { WiFiPage } from './pages/WiFiPage' import { ModuleGuard } from './components/ModuleGuard' export default function App() { @@ -94,6 +95,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9075d67..220146b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react' import { CalendarDays, BookOpen, BedDouble, Globe, Settings, FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound, - TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, + TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, Wifi, } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { useModules } from '../../contexts/ModulesContext' @@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'], service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)], management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'], - settingsGroup:['/modules', '/settings', '/billing', '/equipment'], + settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi'], devGroup: ['/api-docs'], } @@ -233,6 +233,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { { to: '/modules', icon: Puzzle, label: 'Модули' }, { to: '/settings', icon: Settings, label: 'Настройки' }, { to: '/equipment', icon: Monitor, label: 'Оборудование' }, + { to: '/wifi', icon: Wifi, label: 'WiFi авторизация' }, // dev { to: '/api-docs', icon: FileText, label: 'API & Документация' }, ] @@ -415,6 +416,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { + )} diff --git a/src/lib/api.ts b/src/lib/api.ts index 2b61d34..eb3253f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -581,6 +581,18 @@ export const api = { testCheckin: (slug: string, roomId: string) => req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test-checkin`, { room_id: roomId }), }, + + // ── WiFi Settings ───────────────────────────────────────────────────────── + wifi: { + get: (slug: string) => + req('GET', `/api/hotels/${slug}/wifi-settings`), + + update: (slug: string, data: Partial>) => + req('PATCH', `/api/hotels/${slug}/wifi-settings`, data), + + regenerateToken: (slug: string) => + req<{ apiToken: string }>('POST', `/api/hotels/${slug}/wifi-settings/regenerate-token`), + }, } // ── Schedule ───────────────────────────────────────────────────────────────── @@ -1029,6 +1041,17 @@ export interface Workstation { devices: WorkstationDevice[] | null } +export 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 +} + function toHotelPayload(h: HotelPayload): Record { const out: Record = {} if (h.name !== undefined) out.name = h.name diff --git a/src/pages/WiFiPage.tsx b/src/pages/WiFiPage.tsx new file mode 100644 index 0000000..8585026 --- /dev/null +++ b/src/pages/WiFiPage.tsx @@ -0,0 +1,613 @@ +import { useState, useEffect, useCallback } from 'react' +import { + Wifi, Copy, RefreshCw, CheckCircle, Eye, EyeOff, + ChevronDown, ChevronRight, AlertCircle, Info, Shield, + Smartphone, Router, Terminal, ExternalLink, BookOpen, + Check, X, Settings, ToggleLeft, ToggleRight, +} from 'lucide-react' +import { api, type WifiSettings } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' +import { cn } from '../lib/utils' + +// ── Copy button helper ──────────────────────────────────────────────────────── +function CopyBtn({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const copy = () => { + navigator.clipboard.writeText(text).catch(() => {}) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +// ── Code block with copy ────────────────────────────────────────────────────── +function CodeBlock({ code, lang = 'text' }: { code: string; lang?: string }) { + return ( +
+
+        {code}
+      
+
+ +
+
+ ) +} + +// ── Setup guide tabs ────────────────────────────────────────────────────────── +type GuideTabs = 'mikrotik' | 'unifi' | 'universal' + +function SetupGuide({ apiToken }: { apiToken: string }) { + const [tab, setTab] = useState('mikrotik') + const verifyUrl = 'https://api.hotelsync.ru/api/wifi-auth/verify' + const portalUrl = 'https://wifisync.ru/portal' + + const tabs: { id: GuideTabs; label: string }[] = [ + { id: 'mikrotik', label: 'MikroTik' }, + { id: 'unifi', label: 'Ubiquiti UniFi' }, + { id: 'universal', label: 'Универсальная' }, + ] + + return ( +
+ {/* Tab bar */} +
+ {tabs.map(t => ( + + ))} +
+ + {/* MikroTik */} + {tab === 'mikrotik' && ( +
+
+ + Инструкция для RouterOS v7+. Hotspot должен быть уже настроен на интерфейсе. +
+ +
+ +

+ В Winbox: IP → Hotspot → Hotspot Setup. Или через терминал: +

+ +
+ + +

+ Установите URL страницы входа на адрес wifisync.ru: +

+ +
+ + +

+ Добавьте Walled Garden для доступа к порталу без авторизации: +

+ +
+ + +
+
+ Portal URL + {portalUrl} + +
+
+ API Token + {apiToken} + +
+
+
+
+
+ )} + + {/* UniFi */} + {tab === 'unifi' && ( +
+
+ + Инструкция для UniFi Network Application (Controller). Требуется UniFi AP с поддержкой Guest Portal. +
+ +
+ +

+ Создайте гостевую сеть: Networks → Create New Network → включите Guest Network. +

+
+ + +

+ Settings → Guest Control → Guest Portal → External Portal Server +

+
+
+ Custom Portal URL + {portalUrl} + +
+
+
+ + +

+ Settings → Guest Control → Pre-Authorization Access → добавьте: +

+ +
+ + +
+
+ Portal URL + {portalUrl} + +
+
+ API Token + {apiToken} + +
+
+
+
+
+ )} + + {/* Universal */} + {tab === 'universal' && ( +
+
+ + Подходит для любого оборудования, поддерживающего внешний captive portal или редирект HTTP. +
+ +
+ +
    +
  1. Гость подключается к вашей гостевой WiFi сети
  2. +
  3. Роутер перенаправляет HTTP запрос на страницу авторизации wifisync.ru
  4. +
  5. Гость вводит номер комнаты и фамилию (или дату рождения)
  6. +
  7. wifisync.ru проверяет данные через HotelSync API
  8. +
  9. При успехе гость получает доступ в интернет
  10. +
+
+ + +
+ {portalUrl}?token={apiToken.slice(0, 8)}... + +
+

+ Укажите этот URL в настройках вашего роутера как «Login URL» или «Redirect URL» +

+
+ + +

+ Если вы реализуете собственный портал, используйте этот API: +

+ + +
+
+
+ )} +
+ ) +} + +function StepBlock({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { + return ( +
+
+ {n} +
+
+

{title}

+ {children} +
+
+ ) +} + +// ── Main Page ───────────────────────────────────────────────────────────────── +export function WiFiPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [showToken, setShowToken] = useState(false) + const [regenerating, setRegenerating] = useState(false) + const [showGuide, setShowGuide] = useState(false) + const [activeTab, setActiveTab] = useState<'settings' | 'guide'>('settings') + + // Form state + const [authMethod, setAuthMethod] = useState('room_any') + const [ssidName, setSsidName] = useState('') + const [welcomeText, setWelcomeText] = useState('') + const [sessionHours, setSessionHours] = useState('24') + const [enabled, setEnabled] = useState(false) + + const [saveOk, setSaveOk] = useState(false) + + const load = useCallback(async () => { + if (!slug) return + try { + setLoading(true) + const s = await api.wifi.get(slug) + setSettings(s) + setAuthMethod(s.authMethod) + setSsidName(s.ssidName ?? '') + setWelcomeText(s.welcomeText ?? '') + setSessionHours(String(s.sessionHours)) + setEnabled(s.enabled) + } catch { + // ignore + } finally { + setLoading(false) + } + }, [slug]) + + useEffect(() => { load() }, [load]) + + const save = async () => { + if (!slug) return + setSaving(true) + try { + const updated = await api.wifi.update(slug, { + enabled, + authMethod, + ssidName: ssidName.trim() || null, + welcomeText: welcomeText.trim() || null, + sessionHours: Math.max(1, Math.min(720, Number(sessionHours) || 24)), + }) + setSettings(updated) + setSaveOk(true) + setTimeout(() => setSaveOk(false), 2500) + } catch { + // ignore + } finally { + setSaving(false) + } + } + + const regenerate = async () => { + if (!slug) return + if (!window.confirm('Сгенерировать новый токен? Старый токен перестанет работать.')) return + setRegenerating(true) + try { + const { apiToken } = await api.wifi.regenerateToken(slug) + setSettings(prev => prev ? { ...prev, apiToken } : prev) + } catch { + // ignore + } finally { + setRegenerating(false) + } + } + + const toggleEnabled = async () => { + const newVal = !enabled + setEnabled(newVal) + if (!slug) return + try { + const updated = await api.wifi.update(slug, { enabled: newVal }) + setSettings(updated) + } catch { + setEnabled(!newVal) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + const token = settings?.apiToken ?? '' + const maskedToken = token ? token.slice(0, 8) + '••••••••••••••••••••••••' + token.slice(-4) : '' + + const AUTH_METHODS: { id: WifiSettings['authMethod']; label: string; desc: string }[] = [ + { id: 'room_any', label: 'Номер комнаты + Фамилия или Дата рождения', desc: 'Гость вводит то, что удобнее. Рекомендуется.' }, + { id: 'room_lastname', label: 'Номер комнаты + Фамилия', desc: 'Латиницей или кириллицей — как в базе.' }, + { id: 'room_birthdate',label: 'Номер комнаты + Дата рождения', desc: 'Только цифры, гость не ошибётся.' }, + { id: 'room_only', label: 'Только номер комнаты', desc: 'Без пароля. Для отелей без требований к безопасности.' }, + ] + + return ( +
+
+ + {/* ── Header ─────────────────────────────────────────────────────────── */} +
+
+
+ +
+
+

WiFi авторизация

+

Автоматический вход гостей по номеру комнаты

+
+
+ + {/* Enable toggle */} + +
+ + {/* ── Promo block (when disabled) ────────────────────────────────────── */} + {!enabled && ( +
+
+ {[ + { icon: Smartphone, title: 'Вход по данным брони', desc: 'Гость вводит номер комнаты и фамилию — никаких паролей' }, + { icon: Shield, title: 'Только реальные гости', desc: 'Доступ только активным гостям, сессия до даты выезда' }, + { icon: Router, title: 'Любое оборудование', desc: 'Работает с MikroTik, UniFi, TP-Link и любым роутером' }, + ].map(({ icon: Icon, title, desc }) => ( +
+
+ +
+

{title}

+

{desc}

+
+ ))} +
+

+ Сервис wifisync.ru предоставляет captive portal страницу и берёт на себя авторизацию гостей через HotelSync. + Вам нужно только скопировать токен и настроить роутер. +

+
+ )} + + {/* ── Tabs ───────────────────────────────────────────────────────────── */} +
+ {([['settings', 'Настройки'], ['guide', 'Инструкция по оборудованию']] as const).map(([id, label]) => ( + + ))} +
+ + {/* ── Settings tab ───────────────────────────────────────────────────── */} + {activeTab === 'settings' && ( +
+ + {/* Token block */} +
+
+
+ +
+
+ + {showToken ? token : maskedToken} + +
+ + +
+
+ +
+

+ Вставьте этот токен в настройки wifisync.ru при подключении отеля +

+
+ +
+ +
+
+ https://api.hotelsync.ru/api/wifi-auth/verify +
+ +
+

+ Этот эндпоинт вызывает wifisync.ru для проверки данных гостя +

+
+
+
+ + {/* Auth method */} +
+
+ {AUTH_METHODS.map(m => ( + + ))} +
+
+ + {/* Appearance */} +
+
+
+ + setSsidName(e.target.value)} + placeholder="Например: Grand Hotel Guest" + /> +

Отображается на странице авторизации

+
+ +
+ +