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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user