452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
import { Fragment } from "preact";
|
|
import { useEffect, useState } from "preact/hooks";
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Checkbox,
|
|
Input,
|
|
Select,
|
|
UrlPrefixInput,
|
|
} from "@silverbulletmd/silverbullet/ui";
|
|
import { adminApi, getServerInfo, listUsers } from "../api.ts";
|
|
import { FolderPicker } from "../FolderPicker.tsx";
|
|
import {
|
|
type RuntimeAvailability,
|
|
runtimeApiUnavailableReason,
|
|
} from "../runtime_availability.ts";
|
|
import { FieldErrors, useSlugDefaults } from "../space_fields.tsx";
|
|
import type { Binding, FieldError, SpaceInfo, UserInfo } from "../types.ts";
|
|
|
|
export function SpaceForm({
|
|
id,
|
|
initial,
|
|
onSaved,
|
|
cancelHref,
|
|
onDeleted,
|
|
onUnauthorized,
|
|
}: {
|
|
id?: string;
|
|
initial?: SpaceInfo;
|
|
onSaved: (id: string) => void;
|
|
cancelHref: string;
|
|
onDeleted: () => void;
|
|
onUnauthorized: () => void;
|
|
}) {
|
|
const [name, setName] = useState(initial?.name ?? "");
|
|
// The folder and prefix track a sanitized version of the name until the
|
|
// user edits them by hand. Hostnames never derive from the name, so they
|
|
// get their own plain state below rather than living in this hook.
|
|
const { folder, folderTouched, prefix, onNameChange, setFolder, setPrefix } =
|
|
useSlugDefaults((slug) => `spaces/${slug}`);
|
|
const [bindType, setBindType] = useState<"prefix" | "host">(
|
|
initial?.binding.host ? "host" : "prefix",
|
|
);
|
|
const [hostValue, setHostValue] = useState(initial?.binding.host ?? "");
|
|
const bindValue = bindType === "host" ? hostValue : prefix;
|
|
|
|
// Bootstrap from `initial` (edit mode) exactly once. Going through the
|
|
// hook's setters — rather than lazy initial state — also marks folder/prefix
|
|
// as touched, which is what protects an existing space's stored values from
|
|
// being clobbered by a later name edit (mirroring the old `!!initial` seed
|
|
// for `folderTouched`/`bindValueTouched`).
|
|
useEffect(() => {
|
|
if (initial) {
|
|
setFolder(initial.folder);
|
|
if (!initial.binding.host) setPrefix(initial.binding.prefix ?? "");
|
|
}
|
|
// Intentionally run once on mount only.
|
|
}, []);
|
|
|
|
const [isPublic, setIsPublic] = useState(initial?.public ?? false);
|
|
const [members, setMembers] = useState<Set<string>>(
|
|
new Set(Object.keys(initial?.members ?? {})),
|
|
);
|
|
const [users, setUsers] = useState<Record<string, UserInfo>>({});
|
|
const [usersError, setUsersError] = useState(false);
|
|
const [readOnly, setReadOnly] = useState(initial?.readOnly ?? false);
|
|
const [shellEnabled, setShellEnabled] = useState(
|
|
initial?.shell.enabled ?? true,
|
|
);
|
|
// Edited as the space-separated string the server's own SB_SHELL_WHITELIST
|
|
// uses, and split back into the config's array on save.
|
|
const [shellWhitelist, setShellWhitelist] = useState(
|
|
(initial?.shell.whitelist ?? []).join(" "),
|
|
);
|
|
// Matches the server's own `runtimeApi` default for a fresh space.
|
|
const [runtimeApi, setRuntimeApi] = useState(initial?.runtimeApi ?? true);
|
|
const [runtimeAvailability, setRuntimeAvailability] =
|
|
useState<RuntimeAvailability | null>(null);
|
|
const [indexPage, setIndexPage] = useState(initial?.indexPage ?? "index");
|
|
const [errors, setErrors] = useState<FieldError[]>([]);
|
|
// Only ever "saving": a successful save navigates away — to the space list
|
|
// when editing, to the new space's own screen when creating — so leaving
|
|
// this screen *is* the confirmation, and there is no "saved" state left for
|
|
// this form to render.
|
|
const [saveState, setSaveState] = useState<"idle" | "saving">("idle");
|
|
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.
|
|
//
|
|
// Deliberately probed on this page's own scheme and port rather than the
|
|
// https:// shown in the affix: the question is whether the hostname reaches
|
|
// *this* server from here, and an admin on http://localhost:3000 has no TLS
|
|
// to probe. Answering "unreachable" for every local setup would make the
|
|
// check worthless where it is needed most.
|
|
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]);
|
|
|
|
// Known users, for the member checklist below.
|
|
const loadUsers = () => {
|
|
setUsersError(false);
|
|
listUsers()
|
|
.then(setUsers)
|
|
.catch((e: any) => {
|
|
if (e.unauthorized) onUnauthorized();
|
|
else setUsersError(true);
|
|
});
|
|
};
|
|
useEffect(loadUsers, []);
|
|
|
|
// Whether this server can run the Lua runtime at all — decided at server
|
|
// boot, not per space. A failure here deliberately leaves the checkbox
|
|
// usable: a transient error should not lock an admin out of a setting.
|
|
useEffect(() => {
|
|
getServerInfo()
|
|
.then((info) => setRuntimeAvailability(info.runtimeApi))
|
|
.catch(() => {});
|
|
}, []);
|
|
const runtimeApiUnavailable =
|
|
runtimeApiUnavailableReason(runtimeAvailability);
|
|
|
|
return (
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
if (bindType === "prefix" && !prefix.trim()) {
|
|
setErrors([{ field: "binding", message: "prefix is required" }]);
|
|
return;
|
|
}
|
|
const binding: Binding =
|
|
bindType === "host" ? { host: hostValue } : { prefix };
|
|
const payload = {
|
|
name,
|
|
folder,
|
|
binding,
|
|
public: isPublic,
|
|
members: Object.fromEntries([...members].map((m) => [m, {}])),
|
|
readOnly,
|
|
shell: {
|
|
enabled: shellEnabled,
|
|
// Sent even while the field is hidden, so turning shell commands
|
|
// off and back on does not silently discard the allow list.
|
|
whitelist: shellWhitelist.split(/\s+/).filter(Boolean),
|
|
},
|
|
runtimeApi,
|
|
indexPage,
|
|
};
|
|
setErrors([]);
|
|
setSaveState("saving");
|
|
try {
|
|
// PATCH, not PUT: this payload omits description, themeColor,
|
|
// headHtml, spaceIgnore and logPush, which the form has no inputs
|
|
// for. A full-replace PUT resets them on every save.
|
|
if (id) {
|
|
await adminApi("PATCH", `spaces/${id}`, payload);
|
|
setSaveState("idle");
|
|
onSaved(id);
|
|
} else {
|
|
const result = await adminApi("POST", "spaces", payload);
|
|
setSaveState("idle");
|
|
onSaved(result.id);
|
|
}
|
|
} catch (errs) {
|
|
setSaveState("idle");
|
|
if ((errs as any)?.unauthorized) {
|
|
onUnauthorized();
|
|
return;
|
|
}
|
|
setErrors(
|
|
Array.isArray(errs)
|
|
? errs
|
|
: [{ field: "", message: "Request failed" }],
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<h1>{id ? "Edit space" : "Create space"}</h1>
|
|
<FieldErrors errors={errors} />
|
|
<label for="space-name">Name</label>
|
|
<Input
|
|
id="space-name"
|
|
value={name}
|
|
onInput={(e) => {
|
|
const newName = e.currentTarget.value;
|
|
setName(newName);
|
|
onNameChange(newName);
|
|
}}
|
|
/>
|
|
<label for="space-bind-type">Binding</label>
|
|
<Select
|
|
id="space-bind-type"
|
|
value={bindType}
|
|
onChange={(e) =>
|
|
setBindType(e.currentTarget.value as "prefix" | "host")
|
|
}
|
|
>
|
|
<option value="prefix">URL prefix (this host)</option>
|
|
<option value="host">Hostname</option>
|
|
</Select>
|
|
<label for="space-bind-value">
|
|
{bindType === "prefix" ? "Prefix" : "Hostname"}
|
|
</label>
|
|
{bindType === "prefix" ? (
|
|
<UrlPrefixInput
|
|
id="space-bind-value"
|
|
origin={location.origin}
|
|
value={prefix}
|
|
onInput={setPrefix}
|
|
/>
|
|
) : (
|
|
<div class="sb-url-input">
|
|
{/* Only the scheme is fixed, and it is always https://: SilverBullet
|
|
requires TLS, and a host-bound space is reached through whatever
|
|
proxy terminates it — never on this server's own listening port.
|
|
Nothing follows the hostname, so there is no trailing affix; a
|
|
bare "/" only added noise. */}
|
|
<span class="sb-url-affix">https://</span>
|
|
<Input
|
|
id="space-bind-value"
|
|
value={hostValue}
|
|
placeholder="notes.example.com"
|
|
onInput={(e) => setHostValue(e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
{bindType === "host" && hostStatus && (
|
|
<Fragment>
|
|
{hostStatus === "verified" && (
|
|
<span class="sb-spaces-ok">✓ hostname reaches this server</span>
|
|
)}
|
|
{hostStatus === "mismatch" && (
|
|
<span class="sb-spaces-error">
|
|
hostname reaches a different server
|
|
</span>
|
|
)}
|
|
{hostStatus === "unreachable" && (
|
|
<span class="sb-spaces-warn">
|
|
could not verify: hostname does not reach this server from your
|
|
browser (DNS or proxy not set up yet?)
|
|
</span>
|
|
)}
|
|
</Fragment>
|
|
)}
|
|
<label for="space-folder">Folder</label>
|
|
<FolderPicker
|
|
id="space-folder"
|
|
value={folder}
|
|
onChange={setFolder}
|
|
apiBase="api/admin"
|
|
// The default value tracks `spaces/<slug>` (relative to the server
|
|
// root) and doesn't exist yet — browsing its parent ("spaces") shows
|
|
// the existing space folders to pick from, same idea as the wizard's
|
|
// parentDir(folder). Once the user (or an existing space's stored
|
|
// value) has a real folder, just browse that value's own
|
|
// subdirectories (the picker's default).
|
|
browseStart={folderTouched ? undefined : "spaces"}
|
|
/>
|
|
<h3>Access</h3>
|
|
<fieldset class="sb-member-list">
|
|
<legend>Members</legend>
|
|
{usersError && (
|
|
<Alert variant="error">
|
|
Could not load users —{" "}
|
|
<a
|
|
href="#"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
loadUsers();
|
|
}}
|
|
>
|
|
retry
|
|
</a>
|
|
</Alert>
|
|
)}
|
|
{!usersError && Object.keys(users).length === 0 && (
|
|
<p>No other users yet — create some in the Users tab.</p>
|
|
)}
|
|
{Object.entries(users)
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([username, u]) => (
|
|
<label class="sb-member-row" key={username}>
|
|
<Checkbox
|
|
checked={u.admin || members.has(username)}
|
|
disabled={u.admin}
|
|
onChange={(e) => {
|
|
const checked = e.currentTarget.checked;
|
|
setMembers((prev) => {
|
|
const next = new Set(prev);
|
|
if (checked) next.add(username);
|
|
else next.delete(username);
|
|
return next;
|
|
});
|
|
}}
|
|
/>{" "}
|
|
{username}
|
|
{u.admin && <Badge>admin</Badge>}
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
<details>
|
|
<summary>Advanced</summary>
|
|
<label>
|
|
<Checkbox
|
|
checked={isPublic}
|
|
onChange={(e) => setIsPublic(e.currentTarget.checked)}
|
|
/>{" "}
|
|
Public (no login required)
|
|
</label>
|
|
{/* Kept next to the toggle that causes it: a warning about the
|
|
selected combination is useless where you cannot see the
|
|
checkboxes it refers to. */}
|
|
{isPublic && !readOnly && (
|
|
<Alert variant="warning">
|
|
Anyone can read AND EDIT this space without logging in — intended
|
|
for auth-proxy deployments.
|
|
</Alert>
|
|
)}
|
|
<label>
|
|
<Checkbox
|
|
checked={readOnly}
|
|
onChange={(e) => setReadOnly(e.currentTarget.checked)}
|
|
/>{" "}
|
|
Read-only
|
|
</label>
|
|
<label>
|
|
<Checkbox
|
|
checked={shellEnabled}
|
|
onChange={(e) => setShellEnabled(e.currentTarget.checked)}
|
|
/>{" "}
|
|
Enable shell commands
|
|
</label>
|
|
{/* Only meaningful while shell commands are on, so it appears with
|
|
them rather than sitting there greyed out. */}
|
|
{shellEnabled && (
|
|
<Fragment>
|
|
<label for="space-shell-whitelist">
|
|
Allowed commands
|
|
<span class="sb-help-text">
|
|
Space-separated. Leave empty to allow every command.
|
|
</span>
|
|
</label>
|
|
<Input
|
|
id="space-shell-whitelist"
|
|
value={shellWhitelist}
|
|
placeholder="git pandoc"
|
|
onInput={(e) => setShellWhitelist(e.currentTarget.value)}
|
|
/>
|
|
</Fragment>
|
|
)}
|
|
{/* The stored flag and its availability stay orthogonal: `runtimeApi`
|
|
means "this space wants the runtime API", availability means "this
|
|
server can currently provide it". Locking the control does not
|
|
rewrite the value, so installing Chrome and restarting lights the
|
|
space up without the admin having to come back here. */}
|
|
<label>
|
|
<Checkbox
|
|
checked={runtimeApi}
|
|
disabled={runtimeApiUnavailable !== null}
|
|
onChange={(e) => setRuntimeApi(e.currentTarget.checked)}
|
|
/>{" "}
|
|
Enable runtime API
|
|
{runtimeApiUnavailable && (
|
|
<span class="sb-help-text">{runtimeApiUnavailable}</span>
|
|
)}
|
|
</label>
|
|
<label for="space-index-page">Index page</label>
|
|
<Input
|
|
id="space-index-page"
|
|
value={indexPage}
|
|
onInput={(e) => setIndexPage(e.currentTarget.value)}
|
|
/>
|
|
</details>
|
|
<div class="row">
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
disabled={saveState === "saving"}
|
|
>
|
|
{saveState === "saving" ? "Saving…" : id ? "Save" : "Create"}
|
|
</Button>
|
|
<a class="sb-button" href={cancelHref}>
|
|
Cancel
|
|
</a>
|
|
</div>
|
|
{id && (
|
|
<div class="sb-danger-zone">
|
|
<Button
|
|
variant="danger"
|
|
onClick={async () => {
|
|
if (
|
|
!confirm(
|
|
`Remove "${initial?.name ?? id}" from the server? Files on disk are kept.`,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
await adminApi("DELETE", `spaces/${id}`);
|
|
onDeleted();
|
|
} catch (errs) {
|
|
if ((errs as any)?.unauthorized) {
|
|
onUnauthorized();
|
|
return;
|
|
}
|
|
setErrors(
|
|
Array.isArray(errs)
|
|
? errs
|
|
: [{ field: "", message: "Request failed" }],
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
Delete space
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|