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.
This commit is contained in:
77
control-panel/public/api.ts
Normal file
77
control-panel/public/api.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Typed fetch client for the panel backend. Same-origin; the session cookie rides automatically
|
||||
* (HttpOnly — JS never reads it). Every call narrows the response and throws `ApiError` on failure
|
||||
* so the UI can react (e.g. bounce to the login screen on 401).
|
||||
*/
|
||||
export interface HostView {
|
||||
readonly hostId: string
|
||||
readonly subdomain: string
|
||||
readonly status: string
|
||||
readonly lastSeen?: string
|
||||
readonly createdAt?: string
|
||||
readonly revokedAt?: string | null
|
||||
readonly notAfter?: string
|
||||
}
|
||||
|
||||
export interface PairingArtifacts {
|
||||
readonly code: string
|
||||
readonly expiresAt: string
|
||||
readonly pairCommand: string
|
||||
readonly qrDataUrl: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method: string, url: string, body?: unknown): Promise<Response> {
|
||||
const init: RequestInit = { method, credentials: 'same-origin', headers: {} }
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'content-type': 'application/json' }
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
return fetch(url, init)
|
||||
}
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) throw new ApiError(res.status, `request failed (${res.status})`)
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<boolean> {
|
||||
const res = await request('GET', '/api/session')
|
||||
const data = await json<{ authenticated: boolean }>(res)
|
||||
return data.authenticated === true
|
||||
}
|
||||
|
||||
/** Attempt login. Returns true on success; throws ApiError (status) otherwise so callers can message. */
|
||||
export async function login(password: string): Promise<boolean> {
|
||||
const res = await request('POST', '/login', { password })
|
||||
if (res.ok) return true
|
||||
throw new ApiError(res.status, `login failed (${res.status})`)
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await request('POST', '/logout')
|
||||
}
|
||||
|
||||
export async function getHosts(): Promise<readonly HostView[]> {
|
||||
const res = await request('GET', '/api/hosts')
|
||||
const data = await json<{ hosts: HostView[] }>(res)
|
||||
return data.hosts ?? []
|
||||
}
|
||||
|
||||
export async function createPairingCode(): Promise<PairingArtifacts> {
|
||||
const res = await request('POST', '/api/pairing-codes')
|
||||
return json<PairingArtifacts>(res)
|
||||
}
|
||||
|
||||
export async function revokeHost(hostId: string): Promise<void> {
|
||||
const res = await request('DELETE', `/api/hosts/${encodeURIComponent(hostId)}`)
|
||||
if (!res.ok && res.status !== 204) throw new ApiError(res.status, `revoke failed (${res.status})`)
|
||||
}
|
||||
114
control-panel/public/app.ts
Normal file
114
control-panel/public/app.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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()
|
||||
60
control-panel/public/dom.ts
Normal file
60
control-panel/public/dom.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Tiny DOM builder. ALL text and attribute values are set via `textContent` / `setAttribute` — this
|
||||
* module NEVER assigns `innerHTML`, so untrusted server/host data (host subdomains, etc.) can never
|
||||
* inject markup. Children passed as strings become text nodes (also safe).
|
||||
*/
|
||||
export type Child = Node | string
|
||||
|
||||
export interface ElProps {
|
||||
class?: string
|
||||
text?: string
|
||||
type?: string
|
||||
name?: string
|
||||
placeholder?: string
|
||||
value?: string
|
||||
disabled?: boolean
|
||||
autocomplete?: string
|
||||
title?: string
|
||||
src?: string
|
||||
alt?: string
|
||||
role?: string
|
||||
ariaLabel?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
onSubmit?: (e: SubmitEvent) => void
|
||||
}
|
||||
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
props: ElProps = {},
|
||||
children: Child[] = [],
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag)
|
||||
if (props.class !== undefined) node.className = props.class
|
||||
if (props.text !== undefined) node.textContent = props.text
|
||||
if (props.type !== undefined) node.setAttribute('type', props.type)
|
||||
if (props.name !== undefined) node.setAttribute('name', props.name)
|
||||
if (props.placeholder !== undefined) node.setAttribute('placeholder', props.placeholder)
|
||||
if (props.value !== undefined) (node as HTMLInputElement).value = props.value
|
||||
if (props.disabled) node.setAttribute('disabled', 'true')
|
||||
if (props.autocomplete !== undefined) node.setAttribute('autocomplete', props.autocomplete)
|
||||
if (props.title !== undefined) node.setAttribute('title', props.title)
|
||||
if (props.src !== undefined) node.setAttribute('src', props.src)
|
||||
if (props.alt !== undefined) node.setAttribute('alt', props.alt)
|
||||
if (props.role !== undefined) node.setAttribute('role', props.role)
|
||||
if (props.ariaLabel !== undefined) node.setAttribute('aria-label', props.ariaLabel)
|
||||
if (props.onClick !== undefined) node.addEventListener('click', props.onClick as EventListener)
|
||||
if (props.onSubmit !== undefined) node.addEventListener('submit', props.onSubmit as EventListener)
|
||||
for (const child of children) node.append(child)
|
||||
return node
|
||||
}
|
||||
|
||||
/** Remove all children of a node. */
|
||||
export function clear(node: Element): void {
|
||||
while (node.firstChild) node.removeChild(node.firstChild)
|
||||
}
|
||||
|
||||
/** Replace a node's content with a single new child. */
|
||||
export function mount(root: Element, child: Node): void {
|
||||
clear(root)
|
||||
root.append(child)
|
||||
}
|
||||
17
control-panel/public/index.html
Normal file
17
control-panel/public/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Tunnel Control Panel</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="app">
|
||||
<div class="centered"><p class="muted">Loading…</p></div>
|
||||
</main>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
134
control-panel/public/styles.css
Normal file
134
control-panel/public/styles.css
Normal file
@@ -0,0 +1,134 @@
|
||||
/* Tunnel Control Panel — themed (light/dark via prefers-color-scheme), responsive. */
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f0f2f5;
|
||||
--text: #1b1f24;
|
||||
--muted: #5b6572;
|
||||
--border: #dfe3e8;
|
||||
--primary: #2563eb;
|
||||
--primary-text: #ffffff;
|
||||
--danger: #dc2626;
|
||||
--ok: #16a34a;
|
||||
--warn: #d97706;
|
||||
--shadow: 0 6px 24px rgba(20, 24, 33, 0.12);
|
||||
--radius: 12px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1319;
|
||||
--surface: #171c24;
|
||||
--surface-2: #1f2630;
|
||||
--text: #e7ebf0;
|
||||
--muted: #9aa5b3;
|
||||
--border: #2a323d;
|
||||
--primary: #3b82f6;
|
||||
--danger: #ef4444;
|
||||
--ok: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 13px; }
|
||||
h1 { font-size: 22px; margin: 0; }
|
||||
h2 { font-size: 17px; margin: 0 0 12px; }
|
||||
|
||||
.app { min-height: 100vh; }
|
||||
.centered { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
padding: 9px 14px;
|
||||
border-radius: 9px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, transform 0.02s ease;
|
||||
}
|
||||
.btn:hover { border-color: var(--primary); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-text); }
|
||||
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
|
||||
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.small { padding: 6px 10px; font-size: 13px; }
|
||||
.icon-btn { background: none; border: none; color: var(--muted); font-size: 18px; cursor: pointer; line-height: 1; }
|
||||
|
||||
/* Login */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 28px;
|
||||
}
|
||||
.login { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.field-label { font-size: 13px; color: var(--muted); }
|
||||
.login input {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
.login input:focus { outline: 2px solid var(--primary); outline-offset: 1px; }
|
||||
.error { color: var(--danger); font-size: 13px; min-height: 18px; margin: 0; }
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard { max-width: 960px; margin: 0 auto; padding: 24px 20px 48px; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
|
||||
.topbar .actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.dashboard .card { padding: 20px; }
|
||||
|
||||
.table-scroll { overflow-x: auto; }
|
||||
table.hosts { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
table.hosts th, table.hosts td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
table.hosts th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||
table.hosts tr:last-child td { border-bottom: none; }
|
||||
.empty { color: var(--muted); padding: 24px 4px; text-align: center; }
|
||||
|
||||
/* Status pills */
|
||||
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--border); }
|
||||
.pill-online, .pill-active { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); background: color-mix(in srgb, var(--ok) 12%, transparent); }
|
||||
.pill-offline { color: var(--muted); }
|
||||
.pill-revoked, .pill-suspended { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); }
|
||||
.pill-unknown { color: var(--warn); }
|
||||
|
||||
/* Modal */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(6, 9, 14, 0.55); display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 50; }
|
||||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); width: 100%; max-width: 420px; }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; align-items: center; text-align: center; }
|
||||
.code-big { font-size: 26px; font-weight: 700; letter-spacing: 0.08em; padding: 8px 12px; background: var(--surface-2); border-radius: 9px; }
|
||||
.qr { width: 200px; height: 200px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 9px; }
|
||||
.command-row { display: flex; gap: 8px; align-items: center; width: 100%; }
|
||||
.command-row.end { justify-content: flex-end; }
|
||||
.command { flex: 1; text-align: left; background: var(--surface-2); padding: 9px 11px; border-radius: 8px; font-size: 13px; overflow-x: auto; white-space: nowrap; }
|
||||
|
||||
/* Toasts */
|
||||
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 10px 16px; border-radius: 9px; box-shadow: var(--shadow); z-index: 60; font-size: 14px; }
|
||||
.toast-info { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
|
||||
.toast-error { background: var(--danger); color: #fff; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.topbar { flex-direction: column; align-items: stretch; }
|
||||
.topbar .actions { justify-content: stretch; }
|
||||
.topbar .actions .btn { flex: 1; }
|
||||
}
|
||||
161
control-panel/public/views.ts
Normal file
161
control-panel/public/views.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* View builders. Every dynamic value (host subdomain/status/dates, pairing code, command) is placed
|
||||
* with `textContent` via the `el` helper — NEVER innerHTML — so host data (treated as untrusted for
|
||||
* XSS even though it comes from an authenticated CP) can't inject markup. The QR is an <img> whose
|
||||
* src is a data: URL our own backend generated with the `qrcode` lib.
|
||||
*/
|
||||
import { el, clear, type Child } from './dom.js'
|
||||
import type { HostView, PairingArtifacts } from './api.js'
|
||||
|
||||
export interface DashboardHandlers {
|
||||
onNewCode: () => void
|
||||
onRevoke: (host: HostView) => void
|
||||
onLogout: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
/** Login card with a password field; `onSubmit(password)` fires on submit. */
|
||||
export function renderLogin(onSubmit: (password: string) => void): HTMLElement {
|
||||
const input = el('input', { type: 'password', name: 'password', placeholder: 'Panel password', autocomplete: 'current-password' })
|
||||
const error = el('p', { class: 'error', role: 'alert' })
|
||||
const form = el(
|
||||
'form',
|
||||
{
|
||||
class: 'card login',
|
||||
onSubmit: (e) => {
|
||||
e.preventDefault()
|
||||
error.textContent = ''
|
||||
onSubmit(input.value)
|
||||
},
|
||||
},
|
||||
[
|
||||
el('h1', { text: 'Tunnel Control Panel' }),
|
||||
el('label', { text: 'Password', class: 'field-label' }),
|
||||
input,
|
||||
el('button', { type: 'submit', class: 'btn primary', text: 'Sign in' }),
|
||||
error,
|
||||
],
|
||||
)
|
||||
const wrap = el('div', { class: 'centered' }, [form])
|
||||
// Expose the error node so the app can show login failures.
|
||||
;(wrap as HTMLElement & { errorNode?: HTMLElement }).errorNode = error
|
||||
queueMicrotask(() => input.focus())
|
||||
return wrap
|
||||
}
|
||||
|
||||
function statusPill(status: string): HTMLElement {
|
||||
const known = ['online', 'offline', 'revoked', 'suspended', 'active'].includes(status)
|
||||
return el('span', { class: `pill pill-${known ? status : 'unknown'}`, text: status })
|
||||
}
|
||||
|
||||
function fmtDate(value: string | undefined | null): string {
|
||||
if (value === undefined || value === null || value === '') return '—'
|
||||
const t = Date.parse(value)
|
||||
return Number.isNaN(t) ? value : new Date(t).toLocaleString()
|
||||
}
|
||||
|
||||
function hostRow(host: HostView, handlers: DashboardHandlers): HTMLElement {
|
||||
const revoke = el('button', {
|
||||
class: 'btn danger small',
|
||||
text: 'Revoke',
|
||||
title: `Revoke ${host.subdomain}`,
|
||||
onClick: () => handlers.onRevoke(host),
|
||||
})
|
||||
return el('tr', {}, [
|
||||
el('td', { text: host.subdomain, class: 'mono' }),
|
||||
el('td', {}, [statusPill(host.status)]),
|
||||
el('td', { text: fmtDate(host.notAfter), class: 'muted' }),
|
||||
el('td', { text: fmtDate(host.lastSeen), class: 'muted' }),
|
||||
el('td', {}, [revoke]),
|
||||
])
|
||||
}
|
||||
|
||||
function hostsTable(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
|
||||
if (hosts.length === 0) {
|
||||
return el('div', { class: 'empty', text: 'No hosts enrolled yet. Create a pairing code to add one.' })
|
||||
}
|
||||
const head = el('thead', {}, [
|
||||
el('tr', {}, [
|
||||
el('th', { text: 'Subdomain' }),
|
||||
el('th', { text: 'Status' }),
|
||||
el('th', { text: 'Cert expiry' }),
|
||||
el('th', { text: 'Last seen' }),
|
||||
el('th', { text: '' }),
|
||||
]),
|
||||
])
|
||||
const body = el('tbody', {}, hosts.map((h) => hostRow(h, handlers)))
|
||||
return el('div', { class: 'table-scroll' }, [el('table', { class: 'hosts' }, [head, body])])
|
||||
}
|
||||
|
||||
/** Full dashboard: header (refresh / new-code / logout) + hosts table. */
|
||||
export function renderDashboard(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
|
||||
const header = el('header', { class: 'topbar' }, [
|
||||
el('h1', { text: 'Tunnel Control Panel' }),
|
||||
el('div', { class: 'actions' }, [
|
||||
el('button', { class: 'btn', text: 'Refresh', onClick: () => handlers.onRefresh() }),
|
||||
el('button', { class: 'btn primary', text: 'New pairing code', onClick: () => handlers.onNewCode() }),
|
||||
el('button', { class: 'btn ghost', text: 'Log out', onClick: () => handlers.onLogout() }),
|
||||
]),
|
||||
])
|
||||
return el('div', { class: 'dashboard' }, [
|
||||
header,
|
||||
el('section', { class: 'card' }, [el('h2', { text: 'Hosts' }), hostsTable(hosts, handlers)]),
|
||||
])
|
||||
}
|
||||
|
||||
/** Generic modal overlay; returns { overlay, close }. Clicking the backdrop or ✕ closes it. */
|
||||
function modal(title: string, children: Child[]): { overlay: HTMLElement; close: () => void } {
|
||||
const close = (): void => overlay.remove()
|
||||
const dialog = el('div', { class: 'modal', role: 'dialog' }, [
|
||||
el('div', { class: 'modal-head' }, [el('h2', { text: title }), el('button', { class: 'icon-btn', text: '✕', ariaLabel: 'Close', onClick: close })]),
|
||||
el('div', { class: 'modal-body' }, children),
|
||||
])
|
||||
const overlay = el('div', { class: 'overlay', onClick: (e) => { if (e.target === overlay) close() } }, [dialog])
|
||||
return { overlay, close }
|
||||
}
|
||||
|
||||
/** Pairing-code modal: the code, its QR, the copyable command, and the expiry. */
|
||||
export function pairingModal(artifacts: PairingArtifacts): HTMLElement {
|
||||
const command = el('code', { class: 'command', text: artifacts.pairCommand })
|
||||
const copyBtn = el('button', {
|
||||
class: 'btn small',
|
||||
text: 'Copy command',
|
||||
onClick: () => {
|
||||
void navigator.clipboard?.writeText(artifacts.pairCommand).then(
|
||||
() => { copyBtn.textContent = 'Copied!' },
|
||||
() => { copyBtn.textContent = 'Copy failed' },
|
||||
)
|
||||
},
|
||||
})
|
||||
const { overlay } = modal('Pairing code', [
|
||||
el('p', { class: 'muted', text: 'Run this on the new host, or scan the QR from the phone client. Single-use.' }),
|
||||
el('div', { class: 'code-big mono', text: artifacts.code }),
|
||||
el('img', { class: 'qr', src: artifacts.qrDataUrl, alt: 'Pairing code QR' }),
|
||||
el('div', { class: 'command-row' }, [command, copyBtn]),
|
||||
el('p', { class: 'muted small', text: `Expires: ${fmtDate(artifacts.expiresAt)}` }),
|
||||
])
|
||||
return overlay
|
||||
}
|
||||
|
||||
/** Confirm dialog → resolves true (confirmed) / false (cancelled). */
|
||||
export function confirmDialog(message: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const { overlay, close } = modal('Please confirm', [
|
||||
el('p', { text: message }),
|
||||
el('div', { class: 'command-row end' }, [
|
||||
el('button', { class: 'btn ghost', text: 'Cancel', onClick: () => { close(); resolve(false) } }),
|
||||
el('button', { class: 'btn danger', text: 'Revoke', onClick: () => { close(); resolve(true) } }),
|
||||
]),
|
||||
])
|
||||
document.body.append(overlay)
|
||||
})
|
||||
}
|
||||
|
||||
/** A transient toast for errors/success (textContent only). */
|
||||
export function toast(root: HTMLElement, message: string, kind: 'error' | 'info' = 'info'): void {
|
||||
const t = el('div', { class: `toast toast-${kind}`, text: message, role: 'status' })
|
||||
root.append(t)
|
||||
setTimeout(() => t.remove(), 4000)
|
||||
}
|
||||
|
||||
export { clear }
|
||||
Reference in New Issue
Block a user