169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
/**
|
|
* Browser entry point and intentionally small client-side router.
|
|
*
|
|
* `/obs/:publicId` is selected before the control application is initialized.
|
|
* That early split is a security and reliability boundary: OBS sources never
|
|
* register the control-console PWA or execute authenticated dashboard requests.
|
|
* All other routes share the session bootstrap and passwordless auth flow.
|
|
*/
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
|
import { EnrollmentPage, LoginPage } from './auth'
|
|
import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control'
|
|
import { Overlay, tokenFromFragment } from './overlay'
|
|
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
|
import type { Session } from './types'
|
|
import './style.css'
|
|
import './control.css'
|
|
|
|
function Redirect({ to }: { to: string }) {
|
|
useEffect(() => {
|
|
location.replace(to)
|
|
}, [to])
|
|
return <main className="route-loading">正在前往云台…</main>
|
|
}
|
|
|
|
function NotFoundPage() {
|
|
return (
|
|
<main className="auth-page">
|
|
<section className="auth-card jade-panel not-found">
|
|
<div className="auth-mark" aria-hidden="true">
|
|
云
|
|
</div>
|
|
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
|
|
<h1>这里没有组件</h1>
|
|
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
|
|
<a className="button-link" href="/control/">
|
|
返回控制台
|
|
</a>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function App() {
|
|
const [session, setSession] = useState<Session>()
|
|
const [loadError, setLoadError] = useState('')
|
|
const [online, setOnline] = useState(navigator.onLine)
|
|
const path = location.pathname.replace(/\/+$/, '') || '/'
|
|
|
|
const refreshSession = useCallback(async () => {
|
|
setLoadError('')
|
|
try {
|
|
const payload = await api<unknown>('/api/v1/auth/me')
|
|
setSession(normalizeSession(payload))
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 401) {
|
|
setSession({ user: null, setupRequired: false })
|
|
return
|
|
}
|
|
setLoadError(errorMessage(error, '无法连接认证服务'))
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void refreshSession()
|
|
}, [refreshSession])
|
|
|
|
useEffect(() => {
|
|
const wentOnline = () => {
|
|
setOnline(true)
|
|
if (loadError) void refreshSession()
|
|
}
|
|
const wentOffline = () => setOnline(false)
|
|
window.addEventListener('online', wentOnline)
|
|
window.addEventListener('offline', wentOffline)
|
|
return () => {
|
|
window.removeEventListener('online', wentOnline)
|
|
window.removeEventListener('offline', wentOffline)
|
|
}
|
|
}, [loadError, refreshSession])
|
|
|
|
useEffect(() => {
|
|
const expired = () => {
|
|
setSession({ user: null, setupRequired: false })
|
|
if (location.pathname.startsWith('/control')) location.assign('/control/login')
|
|
}
|
|
window.addEventListener('lxc:session-expired', expired)
|
|
return () => window.removeEventListener('lxc:session-expired', expired)
|
|
}, [])
|
|
|
|
if (loadError) {
|
|
const offline = !online
|
|
return (
|
|
<main className="auth-page">
|
|
<section className="auth-card jade-panel">
|
|
<PwaControls />
|
|
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
|
|
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
|
|
{offline && (
|
|
<p className="auth-lead">
|
|
应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。
|
|
</p>
|
|
)}
|
|
<div className="notice error">{loadError}</div>
|
|
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
|
|
重新连接
|
|
</button>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
if (!session) return <main className="route-loading">正在验证安全会话…</main>
|
|
|
|
const logout = async () => {
|
|
try {
|
|
await api('/api/v1/auth/logout', json('POST'))
|
|
} finally {
|
|
setSession({ user: null, setupRequired: false })
|
|
location.assign(authRoute('login'))
|
|
}
|
|
}
|
|
|
|
if (path === '/') return <Redirect to={session.user ? '/control/' : '/login'} />
|
|
if (path === '/login' || path === '/control/login') {
|
|
if (session.user) return <Redirect to="/control/" />
|
|
return <LoginPage onAuthenticated={refreshSession} setupRequired={session.setupRequired} />
|
|
}
|
|
if (path === '/setup' || path === '/control/setup') {
|
|
if (session.user) return <Redirect to="/control/" />
|
|
if (!session.setupRequired) return <Redirect to={authRoute('login')} />
|
|
return <EnrollmentPage mode="setup" onAuthenticated={refreshSession} />
|
|
}
|
|
if (path === '/register' || path === '/control/register') {
|
|
if (session.user) return <Redirect to="/control/" />
|
|
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} />
|
|
}
|
|
if (path === '/control') {
|
|
if (location.pathname === '/control') return <Redirect to="/control/" />
|
|
if (!session.user) return <Redirect to="/control/login" />
|
|
return <ComponentsPage user={session.user} onLogout={logout} />
|
|
}
|
|
if (path === '/control/invitations') {
|
|
if (!session.user) return <Redirect to="/control/login" />
|
|
if (session.user.role !== 'system_admin')
|
|
return <ForbiddenPage user={session.user} onLogout={logout} />
|
|
return <InvitationsPage user={session.user} onLogout={logout} />
|
|
}
|
|
return <NotFoundPage />
|
|
}
|
|
|
|
const obsMatch = location.pathname.match(/^\/obs\/([^/]+)\/?$/)
|
|
const root = createRoot(document.getElementById('root')!)
|
|
if (obsMatch) {
|
|
// A short-lived migration only: remove the root-scoped worker from early
|
|
// development builds so it cannot keep controlling an OBS browser source.
|
|
cleanupLegacyPwa()
|
|
let publicId = ''
|
|
try {
|
|
publicId = decodeURIComponent(obsMatch[1])
|
|
} catch {
|
|
publicId = ''
|
|
}
|
|
root.render(<Overlay publicId={publicId} accessToken={tokenFromFragment()} />)
|
|
} else {
|
|
initializePwa()
|
|
root.render(<App />)
|
|
}
|