Files
Yaojia Wang 675de771c7 feat(control-panel): web admin UI for the zero-touch tunnel
Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time,
signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy
that mints a fresh 60s manage capability token per call to the control-plane admin
API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security
headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests
pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
2026-07-19 19:47:51 +02:00

115 lines
3.4 KiB
TypeScript

/**
* SPA bootstrap + state machine. On load: check the session → render login or dashboard. The
* dashboard polls the host list on an interval and refreshes after mutations. A 401 from any proxy
* call bounces the operator back to the login screen (session expired).
*/
import { mount } from './dom.js'
import * as api from './api.js'
import { ApiError } from './api.js'
import { renderLogin, renderDashboard, pairingModal, confirmDialog, toast, type DashboardHandlers } from './views.js'
import type { HostView } from './api.js'
const POLL_INTERVAL_MS = 15_000
function rootEl(): HTMLElement {
const root = document.getElementById('app')
if (root === null) throw new Error('#app root not found')
return root
}
let pollTimer: number | undefined
function stopPolling(): void {
if (pollTimer !== undefined) {
clearInterval(pollTimer)
pollTimer = undefined
}
}
/** True if an error is an auth failure that should bounce to login. */
function isUnauthorized(err: unknown): boolean {
return err instanceof ApiError && err.status === 401
}
async function showLogin(): Promise<void> {
stopPolling()
const root = rootEl()
const view = renderLogin(async (password) => {
const errorNode = (view as HTMLElement & { errorNode?: HTMLElement }).errorNode
try {
await api.login(password)
await showDashboard()
} catch (err) {
const msg = err instanceof ApiError && err.status === 429 ? 'Too many attempts. Wait and retry.' : err instanceof ApiError && err.status === 503 ? 'Panel not configured (no password set).' : 'Incorrect password.'
if (errorNode) errorNode.textContent = msg
}
})
mount(root, view)
}
async function loadHosts(): Promise<readonly HostView[]> {
return api.getHosts()
}
async function refreshDashboard(root: HTMLElement, handlers: DashboardHandlers): Promise<void> {
try {
const hosts = await loadHosts()
mount(root, renderDashboard(hosts, handlers))
} catch (err) {
if (isUnauthorized(err)) {
await showLogin()
return
}
toast(root, 'Failed to load hosts.', 'error')
}
}
async function showDashboard(): Promise<void> {
const root = rootEl()
const handlers: DashboardHandlers = {
onRefresh: () => void refreshDashboard(root, handlers),
onLogout: async () => {
stopPolling()
await api.logout()
await showLogin()
},
onNewCode: async () => {
try {
const artifacts = await api.createPairingCode()
document.body.append(pairingModal(artifacts))
} catch (err) {
if (isUnauthorized(err)) return void showLogin()
toast(root, 'Failed to create pairing code.', 'error')
}
},
onRevoke: async (host) => {
const ok = await confirmDialog(`Revoke host "${host.subdomain}"? This removes its tunnel access.`)
if (!ok) return
try {
await api.revokeHost(host.hostId)
toast(root, `Revoked ${host.subdomain}.`, 'info')
await refreshDashboard(root, handlers)
} catch (err) {
if (isUnauthorized(err)) return void showLogin()
toast(root, 'Failed to revoke host.', 'error')
}
},
}
await refreshDashboard(root, handlers)
stopPolling()
pollTimer = window.setInterval(() => void refreshDashboard(root, handlers), POLL_INTERVAL_MS)
}
async function boot(): Promise<void> {
try {
const authed = await api.getSession()
await (authed ? showDashboard() : showLogin())
} catch {
await showLogin()
}
}
void boot()