From f521d2b2209267bd7f0ce603686bbcf8382e71ed Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:55:50 +0200 Subject: [PATCH] =?UTF-8?q?Web=20App:=20two=20sites,=20one=20tab=20?= =?UTF-8?q?=E2=80=94=20a=20server=20you=20can=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Web App/README.md | 50 ++++- Web App/web/src/App.vue | 21 +- Web App/web/src/api.js | 93 ++++---- Web App/web/src/auth.js | 88 ++++++-- .../web/src/components/ServerConnectModal.vue | 163 ++++++++++++++ Web App/web/src/components/ServerSwitcher.vue | 133 +++++++++++ Web App/web/src/i18n/da.json | 27 ++- Web App/web/src/i18n/en.json | 27 ++- Web App/web/src/i18n/pl.json | 27 ++- Web App/web/src/servers.js | 212 ++++++++++++++++++ Web App/web/src/views/Login.vue | 47 +--- 11 files changed, 757 insertions(+), 131 deletions(-) create mode 100644 Web App/web/src/components/ServerConnectModal.vue create mode 100644 Web App/web/src/components/ServerSwitcher.vue create mode 100644 Web App/web/src/servers.js diff --git a/Web App/README.md b/Web App/README.md index 8bd1af7..39c8536 100644 --- a/Web App/README.md +++ b/Web App/README.md @@ -7,7 +7,9 @@ and all data access still flows through the API Server (never PocketBase directl ``` Browser ─► Web App BFF (:8090) ──/api/*──► API Server (:8080) ─► PocketBase - └── serves embedded Vue SPA + │ └── serves embedded Vue SPA + └──────────────────────────────────────► API Server at another site ─► its PocketBase + (added in the app, called straight from the browser) ``` ## Layout @@ -23,7 +25,8 @@ web/ Vue 3 + Vite + Tailwind v4 source main.js app bootstrap router.js /login, / (dashboard), /charging, /cars/:id, /settings api.js the only place that calls the API Server (base URL resolution) - auth.js token/profile state, isAdmin + servers.js the server list + a session per server; which one is active + auth.js token/profile state for the active server, isAdmin prefs.js theme/locale/date/font preferences -> i18n/ en / pl / da translation files + loader lib/format.js date/km formatting + next-service status badges @@ -34,7 +37,8 @@ web/ Vue 3 + Vite + Tailwind v4 source TechnicalCheckFormModal, MaintenanceFormModal, FuelFormModal, ChargingFormModal, DocumentFormModal, ReminderFormModal, PartFormModal, ShareModal, - OrgManager, AdminUsers, Logo + OrgManager, AdminUsers, Logo, ServerSwitcher, + ServerConnectModal views/ Login, Dashboard, CarDetail, Charging, Settings ``` @@ -62,9 +66,9 @@ override with `VITE_API_TARGET`), so the client uses same-origin relative URLs a avoids CORS. It also listens on all interfaces (`host: true`) so it's reachable on the LAN (e.g. `http://10.2.1.101:5173`). -At runtime, users can override the API base URL from the login screen's **Server -settings** (persisted in `localStorage` as `cc_server_url`); resolution order is -that override → `VITE_API_BASE` → `/api`. +The base URL each call goes to comes from the active server (see **More than one +server** below); with only the built-in one, that resolves to `VITE_API_BASE` → +`/api`. ## Build & run (production-style) @@ -168,11 +172,43 @@ Config (`server/.env`, copy from `.env.example`): - **Theming** — light/dark/system app-wide (Tailwind v4 class strategy); `prefs.js` toggles `.dark` on `` and applies the saved theme/locale/date/font. +## More than one server + +Two sites, two full DriverVault stacks — and one browser tab. The server picker +sits at the bottom of the app rail (after signing in, not on the login screen): +it names the server you are reading right now, and switches between them in a +click. + +- **The home server** is the one that served the app, reached same-origin through + the BFF's `/api` proxy. It is always in the list and can't be removed. +- **Any other server** is added by address — `https://garage.example.com`; the + `/api` is appended for you if you leave the path off — and is called **straight + from the browser**, not relayed through the BFF. That server therefore has to be + reachable from wherever the browser is, which it already is if the phone app + talks to it. +- **A session per server.** Each server is its own PocketBase with its own users, + so a token can't be carried across: you sign into each one once, and after that + switching needs no password. Sessions live in `localStorage` under + `cc_session_`, the list under `cc_servers`, the active one under + `cc_active_server`. +- **Switching goes back to the garage**, because record ids belong to the server + that issued them — a car page can't survive the change. +- **An expiring remote session doesn't sign you out of the app**: that server's + token is dropped, the app falls back to the home server, and the entry stays in + the list to sign into again. Only *Log out* clears every server at once. + +The remote server must allow the Web App's origin in **`CORS_ALLOW_ORIGINS`** +(API Server setting, `*` by default — so this works out of the box, and only +needs attention on a server whose list has been narrowed). Nothing needs to be +configured on the server you are browsing *from*. + ## Auth & access Login proxies to the API Server, which relays PocketBase's own token — there is no JWT the server mints and no server-side session list. The token is stored -client-side and sent as `Authorization` on every call. `auth.js` exposes +client-side, per server, and sent as `Authorization` on every call; each request +is pinned to the server that was active when it went out, so one server's `401` +can never drop another's session. `auth.js` exposes `isAdmin` and the current profile; the router guards `public` / `admin` routes. Cars are per-user (owned + shared), and the UI mirrors the server's read / write / owner access levels. diff --git a/Web App/web/src/App.vue b/Web App/web/src/App.vue index eacbc81..75015de 100644 --- a/Web App/web/src/App.vue +++ b/Web App/web/src/App.vue @@ -1,11 +1,13 @@ + + diff --git a/Web App/web/src/components/ServerSwitcher.vue b/Web App/web/src/components/ServerSwitcher.vue new file mode 100644 index 0000000..bd78cc3 --- /dev/null +++ b/Web App/web/src/components/ServerSwitcher.vue @@ -0,0 +1,133 @@ + + + diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index aeb2c9e..0a91227 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -78,11 +78,28 @@ "hidePassword": "Skjul adgangskode", "submit": "Log ind", "submitting": "Logger ind…", - "failed": "Login mislykkedes", - "serverSettings": "Serverindstillinger", - "apiServerUrl": "API-serverens adresse", - "leaveBlank": "Lad feltet stå tomt for at bruge standarden ({url}).", - "resetToDefault": "Nulstil til standard" + "failed": "Login mislykkedes" + }, + + "servers": { + "title": "Servere", + "home": "Denne server", + "sameOrigin": "Leveret sammen med appen", + "switchHint": "Skift hvilken server appen læser fra", + "add": "Tilføj server", + "addTitle": "Tilføj en server", + "name": "Navn", + "namePlaceholder": "Hjemmegarage", + "url": "Adresse", + "urlHint": "API-serverens offentlige adresse, f.eks. https://garage.example.com — /api tilføjes automatisk, hvis du udelader stien.", + "urlHomeHint": "Lad feltet stå tomt for at bruge den server, der kører denne app.", + "connect": "Forbind", + "connecting": "Forbinder…", + "connected": "Forbundet", + "notConnected": "Ikke forbundet — log ind for at skifte", + "signOut": "Log ud af denne server", + "removeConfirm": "Fjern {name}? Den gemte session glemmes også.", + "unknown": "Ukendt server" }, "dashboard": { diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 343b441..f10216e 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -96,11 +96,28 @@ "hidePassword": "Hide password", "submit": "Sign in", "submitting": "Signing in…", - "failed": "Login failed", - "serverSettings": "Server settings", - "apiServerUrl": "API server URL", - "leaveBlank": "Leave blank to use the default ({url}).", - "resetToDefault": "Reset to default" + "failed": "Login failed" + }, + + "servers": { + "title": "Servers", + "home": "This server", + "sameOrigin": "Served with this app", + "switchHint": "Switch which server this app reads from", + "add": "Add server", + "addTitle": "Add a server", + "name": "Name", + "namePlaceholder": "Home garage", + "url": "Address", + "urlHint": "The API Server's public address, e.g. https://garage.example.com — /api is added for you if you leave the path off.", + "urlHomeHint": "Leave blank to use the server that runs this app.", + "connect": "Connect", + "connecting": "Connecting…", + "connected": "Connected", + "notConnected": "Not connected — sign in to switch", + "signOut": "Sign out of this server", + "removeConfirm": "Remove {name}? The session held for it is forgotten too.", + "unknown": "Unknown server" }, "dashboard": { diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 18107a9..319ab05 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -80,11 +80,28 @@ "hidePassword": "Ukryj hasło", "submit": "Zaloguj się", "submitting": "Logowanie…", - "failed": "Logowanie nie powiodło się", - "serverSettings": "Ustawienia serwera", - "apiServerUrl": "Adres serwera API", - "leaveBlank": "Pozostaw puste, aby użyć domyślnego ({url}).", - "resetToDefault": "Przywróć domyślny" + "failed": "Logowanie nie powiodło się" + }, + + "servers": { + "title": "Serwery", + "home": "Ten serwer", + "sameOrigin": "Udostępniany razem z aplikacją", + "switchHint": "Przełącz serwer, z którego korzysta aplikacja", + "add": "Dodaj serwer", + "addTitle": "Dodaj serwer", + "name": "Nazwa", + "namePlaceholder": "Garaż domowy", + "url": "Adres", + "urlHint": "Publiczny adres API Servera, np. https://garage.example.com — /api zostanie dodane, jeśli pominiesz ścieżkę.", + "urlHomeHint": "Zostaw puste, aby użyć serwera, na którym działa ta aplikacja.", + "connect": "Połącz", + "connecting": "Łączenie…", + "connected": "Połączono", + "notConnected": "Brak połączenia — zaloguj się, aby przełączyć", + "signOut": "Wyloguj z tego serwera", + "removeConfirm": "Usunąć {name}? Zapisana sesja również zostanie zapomniana.", + "unknown": "Nieznany serwer" }, "dashboard": { diff --git a/Web App/web/src/servers.js b/Web App/web/src/servers.js new file mode 100644 index 0000000..f865cb8 --- /dev/null +++ b/Web App/web/src/servers.js @@ -0,0 +1,212 @@ +// The Web App can be pointed at more than one DriverVault API Server. One is +// the "home" server — the one that served this app, reached same-origin through +// the BFF's /api proxy — and any number of others are added by URL and called +// straight from the browser, which works because an API Server that a phone can +// reach is internet-facing already. +// +// Each server is its own PocketBase with its own users, so a session cannot be +// carried across: every server holds its own token under its own key. Only one +// server is active at a time and the whole app reads from it, so switching +// swaps the garage, the charging page and the settings together. +// +// This module deliberately knows nothing about auth.js — api.js and auth.js +// both read it, and auth.js watches the active id rather than being called from +// here, which keeps the imports one-way. +import { reactive, computed } from "vue"; +import { t } from "./i18n"; + +// Same-origin default: the BFF proxies /api to the API Server it was configured +// with, so the home server needs no URL of its own. +export const DEFAULT_API_BASE = import.meta.env.VITE_API_BASE || "/api"; +export const HOME_ID = "home"; + +const LIST_KEY = "cc_servers"; +const ACTIVE_KEY = "cc_active_server"; +const sessionKey = (id) => `cc_session_${id}`; + +// Pre-multi-server keys, read once at boot so an upgrade doesn't sign anyone +// out or forget the server they had pointed the app at. +const LEGACY_TOKEN_KEY = "cc_token"; +const LEGACY_USER_KEY = "cc_user"; +const LEGACY_SERVER_KEY = "cc_server_url"; + +// A server address as typed. Trailing slashes go, and a bare origin gets "/api" +// appended — every API Server route lives under it, so entering just +// "https://garage.example.com" should work without the user knowing that. +export function normalizeUrl(url) { + const trimmed = (url || "").trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + try { + const u = new URL(trimmed); + if (u.pathname === "" || u.pathname === "/") return `${u.origin}/api`; + } catch { + // Not an absolute URL (a relative base like "/api"): take it as given. + } + return trimmed; +} + +function loadList() { + let stored = []; + try { + stored = JSON.parse(localStorage.getItem(LIST_KEY) || "[]"); + } catch { + stored = []; + } + if (!Array.isArray(stored)) stored = []; + const list = stored + .filter((s) => s && s.id) + .map((s) => ({ id: String(s.id), name: String(s.name || ""), url: normalizeUrl(s.url) })) + // Home is the only entry allowed to have no URL — it falls back to the + // same-origin default. Anything else without one is unusable. + .filter((s) => s.id === HOME_ID || s.url); + if (!list.some((s) => s.id === HOME_ID)) list.unshift({ id: HOME_ID, name: "", url: "" }); + return list; +} + +function saveList() { + localStorage.setItem(LIST_KEY, JSON.stringify(servers.list)); +} + +function loadSession(id) { + try { + const parsed = JSON.parse(localStorage.getItem(sessionKey(id)) || "null"); + return parsed && parsed.token ? { token: parsed.token, user: parsed.user || null } : null; + } catch { + return null; + } +} + +// Carries a pre-multi-server session onto the home server. Any custom base the +// user had set on the login screen becomes home's URL, so the same server keeps +// answering after the upgrade. +function migrateLegacy() { + const token = localStorage.getItem(LEGACY_TOKEN_KEY); + const legacyUrl = localStorage.getItem(LEGACY_SERVER_KEY); + if (!token && !legacyUrl) return; + if (token && !localStorage.getItem(sessionKey(HOME_ID))) { + let user = null; + try { + user = JSON.parse(localStorage.getItem(LEGACY_USER_KEY) || "null"); + } catch { + user = null; + } + localStorage.setItem(sessionKey(HOME_ID), JSON.stringify({ token, user })); + } + if (legacyUrl && !localStorage.getItem(LIST_KEY)) { + localStorage.setItem( + LIST_KEY, + JSON.stringify([{ id: HOME_ID, name: "", url: normalizeUrl(legacyUrl) }]) + ); + } + localStorage.removeItem(LEGACY_TOKEN_KEY); + localStorage.removeItem(LEGACY_USER_KEY); + localStorage.removeItem(LEGACY_SERVER_KEY); +} + +export const servers = reactive({ + list: [], + activeId: HOME_ID, + // id -> {token, user} | null. Mirrors localStorage so a switch is instant and + // a reload keeps every server you were signed into. + sessions: {}, +}); + +migrateLegacy(); +servers.list = loadList(); +for (const s of servers.list) servers.sessions[s.id] = loadSession(s.id); +const storedActive = localStorage.getItem(ACTIVE_KEY); +servers.activeId = servers.list.some((s) => s.id === storedActive) ? storedActive : HOME_ID; + +export const activeServer = computed( + () => servers.list.find((s) => s.id === servers.activeId) || servers.list[0] +); + +// The base URL every request against `server` goes to. +export function baseFor(server) { + return (server?.url || "").trim() || DEFAULT_API_BASE; +} + +export const activeBase = computed(() => baseFor(activeServer.value)); + +// What to call a server in the UI: its own name, else the host it lives on, +// else — for a home server with no URL — a generic label for "the one serving +// this page". +export function displayName(server) { + if (!server) return ""; + if (server.name) return server.name; + const base = (server.url || "").trim(); + if (!base) return t("servers.home"); + try { + return new URL(base).host; + } catch { + return base; + } +} + +export function serverById(id) { + return servers.list.find((s) => s.id === id) || null; +} + +export function sessionFor(id) { + return servers.sessions[id] || null; +} + +export function isConnected(id) { + return !!servers.sessions[id]?.token; +} + +export function setSession(id, token, user) { + servers.sessions[id] = { token, user: user || null }; + localStorage.setItem(sessionKey(id), JSON.stringify(servers.sessions[id])); +} + +export function clearSession(id) { + servers.sessions[id] = null; + localStorage.removeItem(sessionKey(id)); +} + +export function clearAllSessions() { + for (const s of servers.list) clearSession(s.id); +} + +export function setActive(id) { + if (!serverById(id)) return; + servers.activeId = id; + localStorage.setItem(ACTIVE_KEY, id); +} + +export function addServer({ name, url }) { + const server = { + id: `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, + name: (name || "").trim(), + url: normalizeUrl(url), + }; + servers.list.push(server); + saveList(); + return server; +} + +export function updateServer(id, { name, url }) { + const server = serverById(id); + if (!server) return null; + if (name !== undefined) server.name = (name || "").trim(); + if (url !== undefined) { + const next = normalizeUrl(url); + // Moving a server to a different address invalidates the token held for it: + // it was minted by the PocketBase behind the old one. + if (next !== server.url) clearSession(id); + server.url = next; + } + saveList(); + return server; +} + +// Removing a server forgets its session too. Home cannot be removed — it is the +// app's own server, and there would be nothing left to fall back to. +export function removeServer(id) { + if (id === HOME_ID) return; + clearSession(id); + servers.list = servers.list.filter((s) => s.id !== id); + saveList(); + if (servers.activeId === id) setActive(HOME_ID); +} diff --git a/Web App/web/src/views/Login.vue b/Web App/web/src/views/Login.vue index fa5f947..3baeda5 100644 --- a/Web App/web/src/views/Login.vue +++ b/Web App/web/src/views/Login.vue @@ -2,8 +2,7 @@ import { ref } from "vue"; import { useRouter, useRoute } from "vue-router"; import { login } from "../auth"; -import { getServerUrl, setServerUrl, DEFAULT_API_BASE } from "../api"; -import { t, tSplit } from "../i18n"; +import { t } from "../i18n"; import Logo from "../components/Logo.vue"; const router = useRouter(); @@ -14,26 +13,6 @@ const error = ref(""); const loading = ref(false); const showPassword = ref(false); -// Server settings: an optional override of the API Server base URL, persisted -// locally. Empty means "use the default" (DEFAULT_API_BASE). -const showServer = ref(false); -const serverUrl = ref(getServerUrl()); -const serverSaved = ref(false); - -function saveServer() { - setServerUrl(serverUrl.value); - serverUrl.value = getServerUrl(); - serverSaved.value = true; - setTimeout(() => (serverSaved.value = false), 2000); -} - -function resetServer() { - setServerUrl(""); - serverUrl.value = ""; - serverSaved.value = true; - setTimeout(() => (serverSaved.value = false), 2000); -} - async function submit() { loading.value = true; error.value = ""; @@ -86,30 +65,6 @@ async function submit() { {{ loading ? t("login.submitting") : t("login.submit") }} - -
- -
- - -

- {{ tSplit("login.leaveBlank", "url").before - }}{{ DEFAULT_API_BASE }}{{ tSplit("login.leaveBlank", "url").after }} -

-
- - - {{ t("common.saved") }} -
-
-