feat(desktop): runtime-configurable backend server address
Build & push images / images (push) Successful in 3m19s
Release desktop / bundle (push) Successful in 4m57s

The desktop shell is one generic .deb/.AppImage distributed via
mca/public_releases, not built per-booth, but the backend origin was baked
in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the
same installer could never point at a different appliance without a
rebuild.

Adds ConnectScreen (shown before Login in Tauri when no backend is saved),
backed by tauri-plugin-store persisting the operator-entered URL across
restarts. CSP's connect-src tightens to 'self' only — all backend traffic
already routes through tauri-plugin-http/websocket, which run Rust-side
and are outside connect-src's reach anyway — and the real access boundary
moves to capabilities/default.json's http:default scope, wildcarded so an
operator-chosen host is actually reachable. Adds a "Change server" control
in Setup (desktop-only) to repoint an already-configured install.

While tracing the desktop auth path for this: tauri-plugin-http's fetch()
runs through Rust's reqwest, which keeps its own cookie jar separate from
the webview, so document.cookie on tauri://localhost never sees the
parking_csrf cookie the server sets (open upstream bug,
tauri-apps/tauri#13045/#11518). This means the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was
first built — pre-existing, independent of this change. Fixed by having
sessionView() (routes/auth.ts) also echo the CSRF value in the login/me
JSON body; the desktop client stashes it in memory and echoes that instead
of reading document.cookie. assertCsrf() itself is untouched.

Verified end-to-end against a real LAN-bound dev server: login returns a
csrfToken matching the cookie, a mutation using the body-sourced token in
X-CSRF-Token succeeds (200), and the same mutation without it still
correctly 403s.
This commit is contained in:
2026-09-04 10:32:03 +02:00
parent 969bf2b191
commit 5c6a21e2c3
21 changed files with 575 additions and 47 deletions
+50
View File
@@ -130,6 +130,55 @@ function DesktopVersionBadge() {
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
}
/** Desktop-only "change which server this install talks to" control. No-op /
* renders nothing in a browser (the concept doesn't apply — same-origin).
* Simplest correct action: clear the saved backend URL and reload, which
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
* mirrors clearing the session (logout → back to Login), not an inline
* editor, since repointing the app is a rare, deliberate admin action. */
function DesktopServerButton() {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
if (!inTauri()) return null;
return (
<>
<button
type="button"
className="btn btn-ghost btn-sm ml-2"
onClick={() => setConfirming(true)}
>
{t("connect.changeServer")}
</button>
{confirming && (
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
<div className="text-[0.8125rem]">
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
{t("subs.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={busy}
onClick={async () => {
setBusy(true);
const { clearBackendUrl } = await import("./lib/backend-config.js");
await clearBackendUrl();
window.location.reload();
}}
>
{busy ? <Spinner /> : t("connect.changeServer")}
</button>
</div>
</div>
</Modal>
)}
</>
);
}
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
* deep links and the back button work and a denied tab redirects to the booth. */
@@ -150,6 +199,7 @@ function SetupLayout() {
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{show("site:read") && <VersionBadge />}
<DesktopVersionBadge />
<DesktopServerButton />
</nav>
<Outlet />
</div>