import { useEffect, useState } from "react"; import { Globe } from "lucide-react"; import { useTranslation, type HostEditorSectionProps, } from "@termix/plugin-sdk/frontend"; import { Button, Checkbox, FakeSwitch, Input, SectionCard, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SettingRow, } from "@termix/plugin-sdk/ui"; import { resolveConnectionOrigin } from "@termix/plugin-sdk/ui"; import { isElectron } from "@termix/plugin-sdk/ui"; import { MAX_WEB_ENDPOINTS, MAX_WEB_ENDPOINT_LABEL_LENGTH, type WebEndpoint, } from "../shared/web-endpoint-config"; import { webEndpointRefusalReason, type WebEndpointRefusalReason, } from "./web-endpoint-url"; import { MAX_WEB_ENDPOINT_PORT, MIN_WEB_ENDPOINT_PORT, isWebEndpointPortValid, webEndpointErrorKey, webEndpointRowError, } from "./web-endpoint-validation"; type PluginSettingsForm = Record>; /** * crypto.randomUUID is undefined outside a secure context, so a plain-http web * deployment -- a first-class target for the direct+external path -- would * throw on "Add endpoint". */ function endpointId(): string { return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } // Only the tunnel-bind reasons render here; this preview sits inside the // tunnel-access block. The direct cookie refusal is enforced at open time in // WebEndpointTab / openWebEndpointExternally, where the target host is known. const BIND_HOST_REFUSAL_MESSAGES: Record< Exclude, string > = { "loopback-bind-on-remote-backend": "hosts.webUiBindHostUnreachable", "shares-session-cookie-with-termix": "hosts.webUiBindHostSharesSessionCookie", }; /** * True when the bind address reaches beyond the machine running the backend. A * forward bound there serves the target's web UI to anyone who can reach the * port, with no authentication in front of it, so the editor says so plainly * rather than leaving it to be discovered. */ function isExposedBindHost(bindHost: string | undefined): boolean { const value = (bindHost ?? "").trim(); if (!value) return false; return !["127.0.0.1", "localhost", "::1", "[::1]"].includes( value.toLowerCase(), ); } function newEndpoint(label: string): WebEndpoint { return { id: endpointId(), label, scheme: "https", port: 443, path: "/", access: "direct", render: "embedded", }; } /** * The host editor's Web UI tab. Writes into the form's pluginSettings for * this plugin, which the editor saves through the plugin's host settings * route after the host itself, exactly as the tunnels plugin's own section * does. * * Owns the async connection-origin resolution for the tunnel gate itself * (tunnelAvailable = enableSsh && originIsLocal), deliberately NOT gated on * isElectron(): the forward binds wherever the backend runs, exactly as the * tunnels plugin does, and a web deployment reaches it at the host serving * Termix provided the endpoint opted out of a loopback bind. * * connectionType is passed as "ssh" deliberately: a tunnel endpoint always * rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its * RDP/VNC/Telnet guacamole special case, which always resolves to "remote". * * Note the deliberate asymmetry with the sidebar: the sidebar entry appears on * enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is * an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at * all. That is the accepted behaviour, not an oversight. */ export function HostEditorWebUiSection({ form, updateForm, protocols, }: HostEditorSectionProps) { const { t } = useTranslation(); const [originIsLocal, setOriginIsLocal] = useState(false); const connectionOrigin = (form?.connectionOrigin as string | null) ?? null; useEffect(() => { let cancelled = false; void resolveConnectionOrigin({ connectionOrigin: connectionOrigin as "local" | "remote" | null, }).then((origin) => { if (!cancelled) setOriginIsLocal(origin === "local"); }); return () => { cancelled = true; }; }, [connectionOrigin]); const tunnelAvailable = protocols.enableSsh && originIsLocal; const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)?.[ "web-endpoint" ] ?? {}) as Record; const enableWebUi = settings.enableWebUi === true; const webUiConfig = (settings.webUiConfig ?? { endpoints: [] }) as { endpoints: WebEndpoint[]; }; const endpoints = webUiConfig.endpoints ?? []; const setSettings = (patch: Record) => updateForm((current) => { const all = (current.pluginSettings ?? {}) as PluginSettingsForm; return { ...current, pluginSettings: { ...all, "web-endpoint": { ...(all["web-endpoint"] ?? {}), ...patch }, }, }; }); const commit = (next: WebEndpoint[]) => setSettings({ webUiConfig: { endpoints: next } }); const update = (index: number, patch: Partial) => commit( endpoints.map((endpoint, i) => i === index ? { ...endpoint, ...patch } : endpoint, ), ); // Labels identify an endpoint in the sidebar picker, so a fresh row must not // silently collide with one already there. const uniqueLabel = (base: string) => { const taken = new Set(endpoints.map((e) => e.label.trim())); if (!taken.has(base)) return base; for (let n = 2; ; n += 1) { const candidate = `${base} ${n}`; if (!taken.has(candidate)) return candidate; } }; const addEndpoint = () => commit([ ...endpoints, newEndpoint(uniqueLabel(t("hosts.webUiNewEndpointLabel"))), ]); return ( // Two cards, as the tunnels tab does: settings first, then the list it // governs, each with its own header. <> } >
setSettings({ enableWebUi: v })} />

{t("hosts.webUiRequirementsText")}

} action={ enableWebUi ? ( // Same header control, and the same classes, as the tunnels list. ) : undefined } >
{enableWebUi && (
{endpoints.map((endpoint, index) => { const rowError = webEndpointRowError(endpoint); return (
{t("hosts.webUiEndpointLabel", { number: index + 1 })}
update(index, { label: e.target.value }) } maxLength={MAX_WEB_ENDPOINT_LABEL_LENGTH} className="h-7 text-xs" />
{rowError && (

{t(webEndpointErrorKey(rowError), { row: index + 1 })}

)}
{ // Number("") is 0, which the normalizer rejects -- // so clearing the field would otherwise write an // endpoint that is silently dropped on save. // Commit only a value the normalizer would keep; // anything else leaves the last good port. const parsed = Number(e.target.value); if (isWebEndpointPortValid(parsed)) { update(index, { port: parsed }); } }} className="h-7 w-20 text-xs" /> update(index, { path: e.target.value }) } className="h-7 flex-1 text-xs" />
{endpoint.access === "tunnel" && (
update(index, { bindHost: e.target.value }) } className="h-7 text-xs" />

{t("hosts.webUiBindHostDesc")}

{ const raw = e.target.value; if (raw === "") { update(index, { localPort: undefined }); return; } const parsed = Number(raw); if (isWebEndpointPortValid(parsed)) { update(index, { localPort: parsed }); } }} className="h-7 text-xs" />

{t("hosts.webUiLocalPortDesc")}

{isExposedBindHost(endpoint.bindHost) && (

{t("hosts.webUiBindHostExposed")}

)} {(() => { // Said while configuring, not only when the tab fails // to load: a loopback bind on a backend that is not // this machine opens fine and then leaves the browser // with nothing to connect to, and a tunnel reached at // Termix's own hostname would hand the tunnelled // service this session. const refusal = webEndpointRefusalReason( endpoint, isElectron(), undefined, ); if (!refusal) return null; // Unreachable from this tunnel-only block; narrows // the union to the reasons this preview renders. if (refusal === "direct-shares-session-cookie") { return null; } return (

{t(BIND_HOST_REFUSAL_MESSAGES[refusal])}

); })()}
)} {endpoint.render === "embedded" && (

{t("hosts.webUiRenderEmbeddedDesc")}

)} {endpoint.access === "direct" && (

{t("hosts.webUiIgnoreCertDesc")}

)}
); })} {!tunnelAvailable && endpoints.length > 0 && ( // Shown whenever tunnelling is unavailable, not only once a row // already asks for it: the Tunnel option renders disabled, and // a disabled control with no stated reason reads as the // dropdown being broken.

{t("hosts.webUiAccessTunnelUnavailable")}

)}
)}
); }