Ask where the backend lives on first start (runtime setup)

Replaces the build-time VITE_API_BASE knob with a runtime setup screen so
one build works same-origin and cross-origin:

- frontend/src/lib/backend.ts stores the API origin in localStorage
  (empty = same origin). DEFAULT_BACKEND_URL is the clearly marked,
  easily edited prefilled default — the matrix.org equivalent; set it to
  your public API origin.
- Production builds show /setup before anything else on first start,
  with a connection test against /health (or leave it blank for this
  server). The route stays reachable from Account -> Backend connection;
  switching backends clears the previous backend's token and reloads.
- Input normalisation: scheme defaulted (https, http for localhost),
  trailing slashes trimmed; a failed cross-origin test points at
  CORS_ALLOWED_ORIGINS.
- Dev keeps defaulting to the same-origin Vite proxy; /setup can be
  visited manually.

Verified: normalisation cases in Node, /health returns CORS headers for
an allowed origin, tsc/oxlint/build clean.
This commit is contained in:
2026-09-17 22:56:31 -05:00
parent 16907c39ca
commit a93500154c
7 changed files with 261 additions and 14 deletions
@@ -14,6 +14,7 @@ import { useE621 } from "@/store/e621";
import { toast } from "@/store/toasts";
import { AvatarCard } from "./AvatarCard";
import { BackendCard } from "./BackendCard";
import { PreferencesCard } from "./PreferencesCard";
const BASE_URL_OPTIONS = [
@@ -215,6 +216,7 @@ export default function AccountPage() {
<>
<AvatarCard />
<PreferencesCard />
<BackendCard />
</>
) : null}
{loading && !credentials ? (
@@ -0,0 +1,27 @@
import { Link } from "react-router-dom";
import { linkButtonClass } from "@/components/ui";
import { getBackendUrl } from "@/lib/backend";
export function BackendCard() {
const backend = getBackendUrl();
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Backend connection
</h2>
<p className="mt-1 text-xs text-ctp-overlay0">
Where this app sends its API requests. Changing it signs you out.
</p>
<p className="mt-3 break-all font-mono text-xs text-ctp-subtext0">
{backend || `${window.location.origin} (same origin)`}
</p>
<div className="mt-3">
<Link to="/setup" className={linkButtonClass}>
Change backend
</Link>
</div>
</section>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { Cable, CheckCircle2, XCircle } from "lucide-react";
import { useState } from "react";
import { Button, Spinner, inputClass } from "@/components/ui";
import { cn } from "@/lib/cn";
import {
DEFAULT_BACKEND_URL,
getBackendUrl,
getStoredBackendUrl,
normalizeBackendUrl,
setBackendUrl,
} from "@/lib/backend";
import { queryClient } from "@/lib/queryClient";
import { setToken } from "@/lib/api";
type TestResult = { ok: boolean; text: string } | null;
async function testBackend(base: string): Promise<TestResult> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 5_000);
try {
const response = await fetch(`${base}/health`, {
signal: controller.signal,
});
const data = (await response.json().catch(() => null)) as {
status?: string;
database?: boolean;
} | null;
if (response.ok) {
const database =
data?.database === true
? "database ok"
: data?.database === false
? "database unreachable"
: "database unknown";
return { ok: true, text: `Connected — ${data?.status ?? "ok"}, ${database}.` };
}
return {
ok: false,
text: `The backend answered HTTP ${response.status}.`,
};
} catch {
return {
ok: false,
text:
"Could not reach the backend. Check the URL, and if it lives on " +
"another domain add this site to CORS_ALLOWED_ORIGINS there.",
};
} finally {
window.clearTimeout(timeout);
}
}
/**
* First-start (production) and on-demand screen that stores where the API
* lives. Saving reloads the app so every request uses the new origin.
*/
export function SetupPage() {
const stored = getStoredBackendUrl();
const [value, setValue] = useState(stored ?? DEFAULT_BACKEND_URL);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<TestResult>(null);
async function runTest() {
const base = normalizeBackendUrl(value);
setTesting(true);
setResult(await testBackend(base));
setTesting(false);
}
function save(next: string) {
const normalized = normalizeBackendUrl(next);
if (normalized !== getBackendUrl()) {
// The token belongs to the previous backend.
setToken(null);
queryClient.clear();
}
setBackendUrl(normalized);
window.location.replace("/");
}
return (
<div className="flex min-h-screen items-center justify-center bg-ctp-crust px-4">
<div className="w-full max-w-lg">
<div className="flex items-center gap-3">
<span className="rounded bg-ctp-mauve px-2.5 py-1.5 font-mono text-sm font-bold tracking-wide text-ctp-crust">
J621
</span>
<span className="font-mono text-[11px] uppercase tracking-widest text-ctp-overlay0">
Self-hosted media archive
</span>
</div>
<div className="mt-6 rounded-lg border border-ctp-surface0 bg-ctp-base p-6">
<div className="flex items-start gap-3">
<Cable className="mt-0.5 h-5 w-5 shrink-0 text-ctp-mauve" />
<h1 className="text-lg font-semibold leading-snug text-ctp-text">
Where is your backend?
</h1>
</div>
<p className="mt-3 text-sm leading-relaxed text-ctp-subtext0">
Enter the origin this app should call for its API. Leave it blank
when the app and the API are served from the same domain.
</p>
<label className="mt-5 flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Backend URL
</span>
<input
className={cn(inputClass, "font-mono")}
placeholder="https://j621.example.com — blank for this server"
value={value}
onChange={(event) => {
setValue(event.target.value);
setResult(null);
}}
autoFocus
spellCheck={false}
/>
</label>
{result ? (
<p
className={cn(
"mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-sm",
result.ok
? "border-ctp-green/30 bg-ctp-green/10 text-ctp-green"
: "border-ctp-red/30 bg-ctp-red/10 text-ctp-red",
)}
>
{result.ok ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
)}
{result.text}
</p>
) : null}
<div className="mt-6 flex flex-wrap items-center gap-3">
<Button onClick={() => save(value)}>Save and continue</Button>
<Button
variant="secondary"
disabled={testing}
onClick={() => void runTest()}
>
{testing ? <Spinner className="h-3.5 w-3.5" /> : null}
{testing ? "Testing…" : "Test connection"}
</Button>
{value.trim() ? (
<Button variant="ghost" onClick={() => save("")}>
Use this server
</Button>
) : null}
</div>
<p className="mt-4 text-xs leading-relaxed text-ctp-overlay0">
Stored in this browser only. Changing the backend signs you out of
the previous one.
</p>
</div>
</div>
</div>
);
}