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