Web App: two sites, one tab — a server you can switch

Two locations means two full stacks, and until now the app could only ever
see the one that served it. The login screen's "Server settings" could point
it elsewhere, but as a global swap: it replaced the server rather than
adding one, and forgot the session you already had.

There is now a server picker at the bottom of the app rail, after signing
in rather than on the login screen. It names the server you are reading —
which matters most when both sites look identical — and switches in a click.

Each server is its own PocketBase with its own users, so nothing is
federated: you sign into each one once and a token is kept per server. The
home server is the one that served the app, reached same-origin through the
BFF proxy; any other is added by address and called straight from the
browser, which asks nothing new of the network — an API Server the phone app
can reach is internet-facing already.

Two things the switch turned out to need:

Views load on mount, so swapping the session alone left B's user looking at
A's cars. The RouterView is keyed on the active server and a switch returns
to the garage, since record ids belong to the server that issued them.

A request now carries the id of the server it went out to, and a 401 clears
that session rather than whatever is active when the answer lands. A page
fires half a dozen calls at once: the first rejection used to switch away
and the rest then cleared the session of the server it had switched to,
turning one stale remote token into a full sign-out.

A remote session expiring falls back to the home server with the entry left
in place to sign into again. Only Log out clears them all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-21 11:55:50 +02:00
co-authored by Claude Opus 5
parent 00d4c17392
commit f521d2b220
11 changed files with 757 additions and 131 deletions
+49 -44
View File
@@ -1,45 +1,39 @@
// Single client for the Car Control API Server. The web app never talks to
// PocketBase directly — only to these endpoints (proxied to the API Server in
// dev via vite.config.js).
//
// Which server the calls go to is servers.js's business: the base URL and the
// bearer token are both read from whichever server is active, resolved fresh on
// every request so switching takes effect without a reload.
import { t } from "./i18n";
import { servers, serverById, baseFor, sessionFor, clearSession, setActive, isConnected, HOME_ID } from "./servers";
// Default API base: the Vite env override, else the same-origin "/api" (proxied
// to the API Server in dev). A user can override this at runtime via the login
// screen's "Server settings" — stored in localStorage and used for every call.
export const DEFAULT_API_BASE = import.meta.env.VITE_API_BASE || "/api";
const SERVER_KEY = "cc_server_url";
// Resolved fresh on each request so changing it takes effect without a reload.
function apiBase() {
return localStorage.getItem(SERVER_KEY) || DEFAULT_API_BASE;
// Every request is pinned to the server that was active when it went out: its
// base, its token, and — if it comes back 401 — its session and no other's.
// Re-reading the active server on the way back would let one server's rejection
// clear a different server's session, which is what happens when a page fires
// several calls at once and the switch lands between them.
function target() {
const id = servers.activeId;
const token = sessionFor(id)?.token;
return {
id,
base: baseFor(serverById(id)),
headers: token ? { Authorization: "Bearer " + token } : {},
};
}
export function getServerUrl() {
return localStorage.getItem(SERVER_KEY) || "";
}
// Persist a custom API base URL (trailing slashes trimmed). Empty/blank clears
// the override, falling back to the default.
export function setServerUrl(url) {
const trimmed = (url || "").trim().replace(/\/+$/, "");
if (trimmed) localStorage.setItem(SERVER_KEY, trimmed);
else localStorage.removeItem(SERVER_KEY);
}
export const TOKEN_KEY = "cc_token";
export const USER_KEY = "cc_user";
function authHeader() {
const t = localStorage.getItem(TOKEN_KEY);
return t ? { Authorization: "Bearer " + t } : {};
}
async function handleResponse(res, path) {
// An expired/invalid token on any non-login call ends the session.
async function handleResponse(res, path, serverId) {
// An expired/invalid token ends that server's session — but only that one. A
// remote server timing out shouldn't tip you out of the app, so fall back to
// the home server while it is still connected, and go to the login screen
// only when nothing is left to fall back to.
if (res.status === 401 && path !== "/auth/login") {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
if (location.pathname !== "/login") location.href = "/login";
clearSession(serverId);
if (servers.activeId === serverId) {
if (serverId !== HOME_ID && isConnected(HOME_ID)) setActive(HOME_ID);
else if (location.pathname !== "/login") location.href = "/login";
}
throw new Error(t("errors.sessionExpired"));
}
@@ -65,25 +59,28 @@ function errorMessage(data, fallback) {
}
async function request(path, options = {}) {
const res = await fetch(apiBase() + path, {
headers: { "Content-Type": "application/json", ...authHeader(), ...(options.headers || {}) },
const to = target();
const res = await fetch(to.base + path, {
headers: { "Content-Type": "application/json", ...to.headers, ...(options.headers || {}) },
...options,
});
return handleResponse(res, path);
return handleResponse(res, path, to.id);
}
// Like request(), but for multipart/form-data bodies (file uploads) — the
// browser sets its own Content-Type (with boundary), so we must not.
async function requestForm(path, options = {}) {
const res = await fetch(apiBase() + path, { headers: { ...authHeader() }, ...options });
return handleResponse(res, path);
const to = target();
const res = await fetch(to.base + path, { headers: { ...to.headers }, ...options });
return handleResponse(res, path, to.id);
}
// Fetches a binary response (image, export file) as a Blob, since it needs the
// Authorization header — a plain <img src> or <a href> can't attach one.
async function requestBlob(path) {
const res = await fetch(apiBase() + path, { headers: authHeader() });
if (res.status === 401) return handleResponse(res, path);
const to = target();
const res = await fetch(to.base + path, { headers: to.headers });
if (res.status === 401) return handleResponse(res, path, to.id);
if (!res.ok) throw new Error("Request failed: " + res.statusText);
const filename = (res.headers.get("Content-Disposition") || "").match(/filename="([^"]+)"/)?.[1];
return { blob: await res.blob(), filename };
@@ -109,9 +106,17 @@ const attachment = (path) => ({
});
export const api = {
// Auth
login: (email, password) =>
request("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
// Auth. loginAt takes the base explicitly rather than using the active
// server's: connecting to a newly added server must not make it active until
// its credentials have actually worked.
loginAt: async (base, email, password) => {
const res = await fetch(base + "/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
return handleResponse(res, "/auth/login", null);
},
me: () => request("/auth/me"),
// Cars