/** * 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 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 { 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 }