Files
plainleaf/client/admin_ui/components/SpaceForm.tsx
T

282 lines
9.1 KiB
TypeScript

import { Fragment } from "preact";
import { useEffect, useState } from "preact/hooks";
import { api } from "../api.ts";
import { slugify } from "../bindings.ts";
import type { Binding, FieldError, SpaceInfo } from "../types.ts";
import { FolderField } from "./FolderField.tsx";
export function SpaceForm({
id,
initial,
onSaved,
onCancel,
}: {
id?: string;
initial?: SpaceInfo;
onSaved: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [folder, setFolder] = useState(initial?.folder ?? "");
// The folder and (prefix-type) binding value track a sanitized version of
// the name until the user edits them by hand (or the space already exists —
// then the stored values are authoritative).
const [folderTouched, setFolderTouched] = useState(!!initial);
const [bindValueTouched, setBindValueTouched] = useState(!!initial);
const [bindType, setBindType] = useState<"prefix" | "host">(
initial?.binding.host ? "host" : "prefix",
);
const [bindValue, setBindValue] = useState(
initial?.binding.host ?? initial?.binding.prefix ?? "",
);
const [authMode, setAuthMode] = useState(initial?.auth.mode ?? "inherit");
const [authUser, setAuthUser] = useState(initial?.auth.user ?? "");
const [password, setPassword] = useState("");
const [readOnly, setReadOnly] = useState(initial?.readOnly ?? false);
const [shellEnabled, setShellEnabled] = useState(
initial?.shell.enabled ?? true,
);
const [runtimeApi, setRuntimeApi] = useState(initial?.runtimeApi ?? false);
const [indexPage, setIndexPage] = useState(initial?.indexPage ?? "index");
const [errors, setErrors] = useState<FieldError[]>([]);
const [hostStatus, setHostStatus] = useState<
"verified" | "mismatch" | "unreachable" | null
>(null);
// Live hostname check: probe the candidate hostname from the browser and
// compare the answering server's per-boot instance id with our own. Proves
// DNS + routing + proxy forwarding end to end (from this browser's vantage
// point). `/.instance` answers on any Host, so this works before the
// binding exists.
useEffect(() => {
if (
bindType !== "host" ||
!bindValue ||
bindValue.includes("/") ||
bindValue.includes(":")
) {
setHostStatus(null);
return;
}
const t = setTimeout(async () => {
try {
const own = await (await fetch("/.instance")).json();
const port = location.port ? `:${location.port}` : "";
const probe = await fetch(
`${location.protocol}//${bindValue}${port}/.instance`,
{ signal: AbortSignal.timeout(4000) },
);
const remote = await probe.json();
setHostStatus(
remote.instance === own.instance ? "verified" : "mismatch",
);
} catch {
setHostStatus("unreachable");
}
}, 400);
return () => clearTimeout(t);
}, [bindType, bindValue]);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const binding: Binding =
bindType === "host" ? { host: bindValue } : { prefix: bindValue };
const auth =
authMode === "custom"
? { mode: authMode, user: authUser }
: { mode: authMode };
const payload = {
name,
folder,
binding,
auth,
readOnly,
shell: {
enabled: shellEnabled,
whitelist: initial?.shell.whitelist ?? [],
},
runtimeApi,
indexPage,
};
try {
let spaceId = id;
if (id) await api("PUT", `api/spaces/${id}`, payload);
else spaceId = (await api("POST", "api/spaces", payload)).id;
if (authMode === "custom" && password) {
await api("POST", `api/spaces/${spaceId}/password`, { password });
}
onSaved();
} catch (errs) {
setErrors(
Array.isArray(errs)
? errs
: [{ field: "", message: "Request failed" }],
);
}
}}
>
<h2>{id ? "Edit space" : "Create space"}</h2>
{errors.map((e) => (
<p class="sb-admin-error" key={e.field}>
{e.field}: {e.message}
</p>
))}
<label for="space-name">Name</label>
<input
id="space-name"
type="text"
value={name}
onInput={(e) => {
const newName = e.currentTarget.value;
setName(newName);
const slug = slugify(newName);
if (!folderTouched) {
setFolder(slug ? `spaces/${slug}` : "");
}
if (!bindValueTouched && bindType === "prefix") {
setBindValue(slug ? `/${slug}` : "");
}
}}
/>
<label for="space-bind-type">Binding</label>
<select
id="space-bind-type"
value={bindType}
onChange={(e) => {
const newType = e.currentTarget.value as "prefix" | "host";
setBindType(newType);
// An untouched value keeps tracking the name for prefixes, and
// resets for hostnames (those can't be derived from the name).
if (!bindValueTouched) {
const slug = slugify(name);
setBindValue(newType === "prefix" && slug ? `/${slug}` : "");
}
}}
>
<option value="prefix">URL prefix (this host)</option>
<option value="host">Hostname</option>
</select>
<label for="space-bind-value">
{bindType === "prefix" ? "Prefix" : "Hostname"}
</label>
<div class="sb-url-input">
<span class="sb-url-affix">
{bindType === "prefix" ? location.origin : `${location.protocol}//`}
</span>
<input
id="space-bind-value"
type="text"
value={bindValue}
placeholder={bindType === "prefix" ? "/work" : "notes.example.com"}
onInput={(e) => {
setBindValue(e.currentTarget.value);
setBindValueTouched(true);
}}
/>
{bindType === "host" && (
<span class="sb-url-affix">
{location.port ? `:${location.port}/` : "/"}
</span>
)}
</div>
{bindType === "host" && hostStatus && (
<Fragment>
{hostStatus === "verified" && (
<span class="sb-admin-ok">✓ hostname reaches this server</span>
)}
{hostStatus === "mismatch" && (
<span class="sb-admin-error">
hostname reaches a different server
</span>
)}
{hostStatus === "unreachable" && (
<span class="sb-admin-warn">
could not verify: hostname does not reach this server from your
browser (DNS or proxy not set up yet?)
</span>
)}
</Fragment>
)}
<FolderField
value={folder}
onChange={(v) => {
setFolder(v);
setFolderTouched(true);
}}
/>
<label for="space-auth-mode">Authentication</label>
<select
id="space-auth-mode"
value={authMode}
onChange={(e) => setAuthMode(e.currentTarget.value as any)}
>
<option value="inherit">Admin credentials (inherit)</option>
<option value="custom">Space-specific user</option>
<option value="none">Open (no login)</option>
</select>
{authMode === "custom" && (
<Fragment>
<label for="space-auth-user">Space username</label>
<input
id="space-auth-user"
type="text"
value={authUser}
onInput={(e) => setAuthUser(e.currentTarget.value)}
/>
<label for="space-password">
Space password {initial?.hasPassword ? "(leave empty to keep)" : ""}
</label>
<input
id="space-password"
type="password"
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
/>
</Fragment>
)}
<details>
<summary>Advanced</summary>
<label>
<input
type="checkbox"
checked={readOnly}
onChange={(e) => setReadOnly(e.currentTarget.checked)}
/>{" "}
Read-only
</label>
<label>
<input
type="checkbox"
checked={shellEnabled}
onChange={(e) => setShellEnabled(e.currentTarget.checked)}
/>{" "}
Enable shell commands
</label>
<label>
<input
type="checkbox"
checked={runtimeApi}
onChange={(e) => setRuntimeApi(e.currentTarget.checked)}
/>{" "}
Enable runtime API (headless Chrome)
</label>
<label for="space-index-page">Index page</label>
<input
id="space-index-page"
type="text"
value={indexPage}
onInput={(e) => setIndexPage(e.currentTarget.value)}
/>
</details>
<div class="row">
<button type="submit">{id ? "Save" : "Create"}</button>
<button type="button" onClick={onCancel}>
Cancel
</button>
</div>
</form>
);
}