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:
15
backend/migrations/046_wifi_settings.sql
Normal file
15
backend/migrations/046_wifi_settings.sql
Normal file
@@ -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)
|
||||||
|
);
|
||||||
@@ -36,6 +36,7 @@ import loyaltyRoutes from './routes/loyalty'
|
|||||||
import chatRoutes from './routes/chat'
|
import chatRoutes from './routes/chat'
|
||||||
import workstationRoutes from './routes/workstations'
|
import workstationRoutes from './routes/workstations'
|
||||||
import agentReleaseRoutes from './routes/agent-release'
|
import agentReleaseRoutes from './routes/agent-release'
|
||||||
|
import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings'
|
||||||
import { setupAgentWsRoute } from './agent-ws'
|
import { setupAgentWsRoute } from './agent-ws'
|
||||||
import { startJobs } from './jobs'
|
import { startJobs } from './jobs'
|
||||||
|
|
||||||
@@ -123,6 +124,8 @@ export async function buildApp() {
|
|||||||
await fastify.register(chatRoutes)
|
await fastify.register(chatRoutes)
|
||||||
await fastify.register(workstationRoutes)
|
await fastify.register(workstationRoutes)
|
||||||
await fastify.register(agentReleaseRoutes)
|
await fastify.register(agentReleaseRoutes)
|
||||||
|
await fastify.register(wifiSettingsRoutes)
|
||||||
|
await fastify.register(wifiAuthVerify)
|
||||||
await fastify.register(setupAgentWsRoute)
|
await fastify.register(setupAgentWsRoute)
|
||||||
|
|
||||||
startJobs()
|
startJobs()
|
||||||
|
|||||||
226
backend/src/routes/wifi-settings.ts
Normal file
226
backend/src/routes/wifi-settings.ts
Normal 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
|
||||||
@@ -42,6 +42,7 @@ import { TechnicalPage } from './pages/TechnicalPage'
|
|||||||
import { SchedulePage } from './pages/SchedulePage'
|
import { SchedulePage } from './pages/SchedulePage'
|
||||||
import { BillingPage } from './pages/BillingPage'
|
import { BillingPage } from './pages/BillingPage'
|
||||||
import { EquipmentPage } from './pages/EquipmentPage'
|
import { EquipmentPage } from './pages/EquipmentPage'
|
||||||
|
import { WiFiPage } from './pages/WiFiPage'
|
||||||
import { ModuleGuard } from './components/ModuleGuard'
|
import { ModuleGuard } from './components/ModuleGuard'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -94,6 +95,7 @@ export default function App() {
|
|||||||
<Route path="/technical" element={<TechnicalPage />} />
|
<Route path="/technical" element={<TechnicalPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/equipment" element={<EquipmentPage />} />
|
<Route path="/equipment" element={<EquipmentPage />} />
|
||||||
|
<Route path="/wifi" element={<WiFiPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import {
|
import {
|
||||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
|
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'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useModules } from '../../contexts/ModulesContext'
|
import { useModules } from '../../contexts/ModulesContext'
|
||||||
@@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
||||||
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
||||||
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
||||||
settingsGroup:['/modules', '/settings', '/billing', '/equipment'],
|
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi'],
|
||||||
devGroup: ['/api-docs'],
|
devGroup: ['/api-docs'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +233,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
{ to: '/modules', icon: Puzzle, label: 'Модули' },
|
{ to: '/modules', icon: Puzzle, label: 'Модули' },
|
||||||
{ to: '/settings', icon: Settings, label: 'Настройки' },
|
{ to: '/settings', icon: Settings, label: 'Настройки' },
|
||||||
{ to: '/equipment', icon: Monitor, label: 'Оборудование' },
|
{ to: '/equipment', icon: Monitor, label: 'Оборудование' },
|
||||||
|
{ to: '/wifi', icon: Wifi, label: 'WiFi авторизация' },
|
||||||
// dev
|
// dev
|
||||||
{ to: '/api-docs', icon: FileText, label: 'API & Документация' },
|
{ to: '/api-docs', icon: FileText, label: 'API & Документация' },
|
||||||
]
|
]
|
||||||
@@ -415,6 +416,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
|
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
|
||||||
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
|
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
|
||||||
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
|
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
|
||||||
|
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -581,6 +581,18 @@ export const api = {
|
|||||||
testCheckin: (slug: string, roomId: string) =>
|
testCheckin: (slug: string, roomId: string) =>
|
||||||
req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test-checkin`, { room_id: roomId }),
|
req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test-checkin`, { room_id: roomId }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── WiFi Settings ─────────────────────────────────────────────────────────
|
||||||
|
wifi: {
|
||||||
|
get: (slug: string) =>
|
||||||
|
req<WifiSettings>('GET', `/api/hotels/${slug}/wifi-settings`),
|
||||||
|
|
||||||
|
update: (slug: string, data: Partial<Omit<WifiSettings, 'id' | 'hotelId' | 'apiToken'>>) =>
|
||||||
|
req<WifiSettings>('PATCH', `/api/hotels/${slug}/wifi-settings`, data),
|
||||||
|
|
||||||
|
regenerateToken: (slug: string) =>
|
||||||
|
req<{ apiToken: string }>('POST', `/api/hotels/${slug}/wifi-settings/regenerate-token`),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Schedule ─────────────────────────────────────────────────────────────────
|
// ── Schedule ─────────────────────────────────────────────────────────────────
|
||||||
@@ -1029,6 +1041,17 @@ export interface Workstation {
|
|||||||
devices: WorkstationDevice[] | null
|
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<string, unknown> {
|
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {}
|
const out: Record<string, unknown> = {}
|
||||||
if (h.name !== undefined) out.name = h.name
|
if (h.name !== undefined) out.name = h.name
|
||||||
|
|||||||
613
src/pages/WiFiPage.tsx
Normal file
613
src/pages/WiFiPage.tsx
Normal file
@@ -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 (
|
||||||
|
<button onClick={copy} className="btn-ghost p-1.5 text-slate-400 hover:text-slate-600 shrink-0" title="Копировать">
|
||||||
|
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Code block with copy ──────────────────────────────────────────────────────
|
||||||
|
function CodeBlock({ code, lang = 'text' }: { code: string; lang?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative group">
|
||||||
|
<pre className={`bg-slate-900 text-slate-100 rounded-lg p-4 text-xs overflow-x-auto font-mono leading-relaxed lang-${lang}`}>
|
||||||
|
{code}
|
||||||
|
</pre>
|
||||||
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<CopyBtn text={code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup guide tabs ──────────────────────────────────────────────────────────
|
||||||
|
type GuideTabs = 'mikrotik' | 'unifi' | 'universal'
|
||||||
|
|
||||||
|
function SetupGuide({ apiToken }: { apiToken: string }) {
|
||||||
|
const [tab, setTab] = useState<GuideTabs>('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 (
|
||||||
|
<div>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700 mb-5">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
tab === t.id
|
||||||
|
? 'border-brand-600 text-brand-600 dark:text-brand-400'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MikroTik */}
|
||||||
|
{tab === 'mikrotik' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
<Info size={15} className="mt-0.5 shrink-0" />
|
||||||
|
<span>Инструкция для RouterOS v7+. Hotspot должен быть уже настроен на интерфейсе.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<StepBlock n={1} title="Создайте сервер Hotspot">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
В Winbox: IP → Hotspot → Hotspot Setup. Или через терминал:
|
||||||
|
</p>
|
||||||
|
<CodeBlock lang="mikrotik" code={`/ip hotspot setup
|
||||||
|
# Укажите интерфейс (например wlan1), диапазон адресов и DNS
|
||||||
|
# Страницу входа настроим отдельно`} />
|
||||||
|
</StepBlock>
|
||||||
|
|
||||||
|
<StepBlock n={2} title="Настройте внешний Captive Portal (wifisync.ru)">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
Установите URL страницы входа на адрес wifisync.ru:
|
||||||
|
</p>
|
||||||
|
<CodeBlock lang="mikrotik" code={`/ip hotspot profile set [find default=yes] \\
|
||||||
|
login-by=http-chap \\
|
||||||
|
http-cookie-lifetime=1d \\
|
||||||
|
use-radius=no`} />
|
||||||
|
</StepBlock>
|
||||||
|
|
||||||
|
<StepBlock n={3} title="Настройте редирект на портал">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
Добавьте Walled Garden для доступа к порталу без авторизации:
|
||||||
|
</p>
|
||||||
|
<CodeBlock lang="mikrotik" code={`/ip hotspot walled-garden ip add dst-address=wifisync.ru action=accept
|
||||||
|
/ip hotspot walled-garden ip add dst-address=api.hotelsync.ru action=accept`} />
|
||||||
|
</StepBlock>
|
||||||
|
|
||||||
|
<StepBlock n={4} title="Ваши данные для подключения">
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 bg-slate-50 dark:bg-slate-800 rounded-lg px-3 py-2">
|
||||||
|
<span className="text-slate-500 w-20 shrink-0">Portal URL</span>
|
||||||
|
<code className="text-brand-600 font-mono flex-1">{portalUrl}</code>
|
||||||
|
<CopyBtn text={portalUrl} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-slate-50 dark:bg-slate-800 rounded-lg px-3 py-2">
|
||||||
|
<span className="text-slate-500 w-20 shrink-0">API Token</span>
|
||||||
|
<code className="text-brand-600 font-mono flex-1 break-all">{apiToken}</code>
|
||||||
|
<CopyBtn text={apiToken} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StepBlock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* UniFi */}
|
||||||
|
{tab === 'unifi' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
<Info size={15} className="mt-0.5 shrink-0" />
|
||||||
|
<span>Инструкция для UniFi Network Application (Controller). Требуется UniFi AP с поддержкой Guest Portal.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<StepBlock n={1} title="Включите Guest Hotspot">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Создайте гостевую сеть: <strong>Networks → Create New Network</strong> → включите <strong>Guest Network</strong>.
|
||||||
|
</p>
|
||||||
|
</StepBlock>
|
||||||
|
|
||||||
|
<StepBlock n={2} title="Настройте External Portal">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
Settings → Guest Control → Guest Portal → <strong>External Portal Server</strong>
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-slate-500 w-28 shrink-0">Custom Portal URL</span>
|
||||||
|
<code className="text-brand-600 font-mono">{portalUrl}</code>
|
||||||
|
<CopyBtn text={portalUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StepBlock>
|
||||||
|
|
||||||
|
<StepBlock n={3} title="Разрешите домены в Pre-Authorization Access">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
Settings → Guest Control → Pre-Authorization Access → добавьте:
|
||||||
|
</p>
|
||||||
|
<CodeBlock code={`wifisync.ru\napi.hotelsync.ru`} />
|
||||||
|
</StepBlock>
|
||||||
|
|
||||||
|
<StepBlock n={4} title="Ваши данные для подключения">
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 bg-slate-50 dark:bg-slate-800 rounded-lg px-3 py-2">
|
||||||
|
<span className="text-slate-500 w-20 shrink-0">Portal URL</span>
|
||||||
|
<code className="text-brand-600 font-mono flex-1">{portalUrl}</code>
|
||||||
|
<CopyBtn text={portalUrl} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-slate-50 dark:bg-slate-800 rounded-lg px-3 py-2">
|
||||||
|
<span className="text-slate-500 w-20 shrink-0">API Token</span>
|
||||||
|
<code className="text-brand-600 font-mono flex-1 break-all">{apiToken}</code>
|
||||||
|
<CopyBtn text={apiToken} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StepBlock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Universal */}
|
||||||
|
{tab === 'universal' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
<Info size={15} className="mt-0.5 shrink-0" />
|
||||||
|
<span>Подходит для любого оборудования, поддерживающего внешний captive portal или редирект HTTP.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<StepBlock n={1} title="Как работает авторизация">
|
||||||
|
<ol className="text-sm text-slate-600 dark:text-slate-400 space-y-1 list-decimal list-inside">
|
||||||
|
<li>Гость подключается к вашей гостевой WiFi сети</li>
|
||||||
|
<li>Роутер перенаправляет HTTP запрос на страницу авторизации wifisync.ru</li>
|
||||||
|
<li>Гость вводит номер комнаты и фамилию (или дату рождения)</li>
|
||||||
|
<li>wifisync.ru проверяет данные через HotelSync API</li>
|
||||||
|
<li>При успехе гость получает доступ в интернет</li>
|
||||||
|
</ol>
|
||||||
|
</StepBlock>
|
||||||
|
|
||||||
|
<StepBlock n={2} title="URL страницы авторизации">
|
||||||
|
<div className="flex items-center gap-2 bg-slate-50 dark:bg-slate-800 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<code className="text-brand-600 font-mono flex-1 break-all">{portalUrl}?token={apiToken.slice(0, 8)}...</code>
|
||||||
|
<CopyBtn text={`${portalUrl}?token=${apiToken}`} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Укажите этот URL в настройках вашего роутера как «Login URL» или «Redirect URL»
|
||||||
|
</p>
|
||||||
|
</StepBlock>
|
||||||
|
|
||||||
|
<StepBlock n={3} title="API эндпоинт для проверки гостей">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
Если вы реализуете собственный портал, используйте этот API:
|
||||||
|
</p>
|
||||||
|
<CodeBlock lang="http" code={`POST ${verifyUrl}
|
||||||
|
Authorization: Bearer ${apiToken}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"room_number": "101",
|
||||||
|
"last_name": "Иванов",
|
||||||
|
"birth_date": "1990-01-15"
|
||||||
|
}`} />
|
||||||
|
<CodeBlock lang="json" code={`// Успешный ответ:
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"guestName": "Иван Иванов",
|
||||||
|
"checkout": "2026-04-01",
|
||||||
|
"sessionHours": 24
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ошибка:
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "Неверные данные. Проверьте фамилию или дату рождения."
|
||||||
|
}`} />
|
||||||
|
</StepBlock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepBlock({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/40 text-brand-600 dark:text-brand-400 text-xs font-bold flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
{n}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 mb-1">{title}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
export function WiFiPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<WifiSettings | null>(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<WifiSettings['authMethod']>('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 (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<RefreshCw size={20} className="animate-spin text-slate-400" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex-1 overflow-y-auto bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center">
|
||||||
|
<Wifi size={20} className="text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">WiFi авторизация</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Автоматический вход гостей по номеру комнаты</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enable toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleEnabled}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors shrink-0',
|
||||||
|
enabled
|
||||||
|
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{enabled ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
|
||||||
|
{enabled ? 'Включён' : 'Выключен'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Promo block (when disabled) ────────────────────────────────────── */}
|
||||||
|
{!enabled && (
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-6 mb-6">
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4 mb-5">
|
||||||
|
{[
|
||||||
|
{ icon: Smartphone, title: 'Вход по данным брони', desc: 'Гость вводит номер комнаты и фамилию — никаких паролей' },
|
||||||
|
{ icon: Shield, title: 'Только реальные гости', desc: 'Доступ только активным гостям, сессия до даты выезда' },
|
||||||
|
{ icon: Router, title: 'Любое оборудование', desc: 'Работает с MikroTik, UniFi, TP-Link и любым роутером' },
|
||||||
|
].map(({ icon: Icon, title, desc }) => (
|
||||||
|
<div key={title} className="text-center p-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Icon size={18} className="text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 mb-0.5">{title}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 text-center">
|
||||||
|
Сервис <strong>wifisync.ru</strong> предоставляет captive portal страницу и берёт на себя авторизацию гостей через HotelSync.
|
||||||
|
Вам нужно только скопировать токен и настроить роутер.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tabs ───────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700 mb-6">
|
||||||
|
{([['settings', 'Настройки'], ['guide', 'Инструкция по оборудованию']] as const).map(([id, label]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setActiveTab(id)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
activeTab === id
|
||||||
|
? 'border-brand-600 text-brand-600 dark:text-brand-400'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Settings tab ───────────────────────────────────────────────────── */}
|
||||||
|
{activeTab === 'settings' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Token block */}
|
||||||
|
<Section title="Подключение к wifisync.ru" icon={Shield}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">API токен</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="input flex-1 flex items-center justify-between gap-2 font-mono text-sm">
|
||||||
|
<span className="truncate text-slate-700 dark:text-slate-300">
|
||||||
|
{showToken ? token : maskedToken}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<button onClick={() => setShowToken(v => !v)} className="btn-ghost p-1.5 text-slate-400 hover:text-slate-600" title={showToken ? 'Скрыть' : 'Показать'}>
|
||||||
|
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
<CopyBtn text={token} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={regenerate}
|
||||||
|
disabled={regenerating}
|
||||||
|
className="btn-secondary whitespace-nowrap"
|
||||||
|
title="Сгенерировать новый токен"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={regenerating ? 'animate-spin' : ''} />
|
||||||
|
Новый токен
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Вставьте этот токен в настройки wifisync.ru при подключении отеля
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">URL проверки гостей</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="input flex-1 text-sm font-mono text-slate-600 dark:text-slate-400 select-all">
|
||||||
|
https://api.hotelsync.ru/api/wifi-auth/verify
|
||||||
|
</div>
|
||||||
|
<CopyBtn text="https://api.hotelsync.ru/api/wifi-auth/verify" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Этот эндпоинт вызывает wifisync.ru для проверки данных гостя
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Auth method */}
|
||||||
|
<Section title="Метод авторизации гостя" icon={Settings}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{AUTH_METHODS.map(m => (
|
||||||
|
<label
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors',
|
||||||
|
authMethod === m.id
|
||||||
|
? 'border-brand-400 bg-brand-50 dark:border-brand-600 dark:bg-brand-900/20'
|
||||||
|
: 'border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="authMethod"
|
||||||
|
value={m.id}
|
||||||
|
checked={authMethod === m.id}
|
||||||
|
onChange={() => setAuthMethod(m.id)}
|
||||||
|
className="mt-0.5 accent-brand-600"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{m.label}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{m.desc}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<Section title="Внешний вид портала" icon={Wifi}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Название WiFi сети (SSID)</label>
|
||||||
|
<input
|
||||||
|
className="input w-full"
|
||||||
|
value={ssidName}
|
||||||
|
onChange={e => setSsidName(e.target.value)}
|
||||||
|
placeholder="Например: Grand Hotel Guest"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Отображается на странице авторизации</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Текст приветствия</label>
|
||||||
|
<textarea
|
||||||
|
className="input w-full resize-none"
|
||||||
|
rows={3}
|
||||||
|
value={welcomeText}
|
||||||
|
onChange={e => setWelcomeText(e.target.value)}
|
||||||
|
placeholder="Добро пожаловать! Введите данные для подключения к Wi-Fi."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Длительность сессии (часов)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input w-32"
|
||||||
|
value={sessionHours}
|
||||||
|
onChange={e => setSessionHours(e.target.value)}
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Гость будет переавторизован по истечении. Максимум: 720 ч (30 дней).
|
||||||
|
По умолчанию сессия длится до даты выезда.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{saving ? <RefreshCw size={14} className="animate-spin" /> : null}
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
{saveOk && (
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
Сохранено
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Guide tab ──────────────────────────────────────────────────────── */}
|
||||||
|
{activeTab === 'guide' && (
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-6">
|
||||||
|
<SetupGuide apiToken={token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section wrapper ───────────────────────────────────────────────────────────
|
||||||
|
function Section({
|
||||||
|
title, icon: Icon, children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: React.ElementType
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Icon size={16} className="text-brand-600 dark:text-brand-400" />
|
||||||
|
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-200">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user