Files
hotelsync/src/pages/WiFiPage.tsx
HotelSync 0088457f51 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>
2026-03-29 12:08:51 +03:00

614 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}