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") }} -
-
-