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