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:
co-authored by
Claude Opus 5
parent
00d4c17392
commit
f521d2b220
+43
-7
@@ -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 -> <html>
|
||||
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 `<html>` 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_<id>`, 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.
|
||||
|
||||
+18
-3
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, computed, ref } from "vue";
|
||||
import { onMounted, onBeforeUnmount, computed, ref, watch } from "vue";
|
||||
import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
|
||||
import { state, isAuthenticated, logout, refreshProfile } from "./auth";
|
||||
import { prefs, applyProfilePrefs } from "./prefs";
|
||||
import { servers } from "./servers";
|
||||
import { api } from "./api";
|
||||
import { t } from "./i18n";
|
||||
import Logo from "./components/Logo.vue";
|
||||
import ServerSwitcher from "./components/ServerSwitcher.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -40,6 +42,17 @@ function toggleDragLock() {
|
||||
if (isAuthenticated.value) api.updateMe({ dragLocked: next }).catch(() => {});
|
||||
}
|
||||
|
||||
// Switching servers puts you in front of a different garage. Record ids belong
|
||||
// to the server that issued them, so nothing on screen survives the change —
|
||||
// hence back to the garage every time, and a keyed RouterView below so the view
|
||||
// remounts and re-fetches even when the route itself doesn't change.
|
||||
watch(
|
||||
() => servers.activeId,
|
||||
() => {
|
||||
if (route.name !== "login") router.replace({ name: "dashboard" });
|
||||
}
|
||||
);
|
||||
|
||||
const userInitial = computed(() =>
|
||||
(state.user?.name || state.user?.email || "?").charAt(0).toUpperCase()
|
||||
);
|
||||
@@ -102,8 +115,10 @@ onBeforeUnmount(() => themeObserver?.disconnect());
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- Footer: drag lock + theme toggle + user + logout -->
|
||||
<!-- Footer: server + drag lock + theme toggle + user + logout -->
|
||||
<div class="mt-auto flex flex-col gap-2 border-t border-white/10 pt-3">
|
||||
<ServerSwitcher />
|
||||
|
||||
<button
|
||||
class="flex items-center gap-3 rounded-control px-2.5 py-2 text-[15px] font-medium transition-colors hover:bg-white/5 hover:text-white md:px-3"
|
||||
:class="prefs.dragLocked ? 'text-white/80' : 'text-white/60'"
|
||||
@@ -141,7 +156,7 @@ onBeforeUnmount(() => themeObserver?.disconnect());
|
||||
<!-- Main content -->
|
||||
<main class="min-w-0 flex-1 px-5 py-6 md:px-8 lg:px-10">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<RouterView />
|
||||
<RouterView :key="servers.activeId" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+49
-44
@@ -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
|
||||
|
||||
+72
-16
@@ -1,18 +1,52 @@
|
||||
// Reactive auth state shared across the app. Token + user are persisted to
|
||||
// localStorage so a refresh keeps the session. The actual network calls live in
|
||||
// api.js (which reads the token from localStorage on each request).
|
||||
import { reactive, computed } from "vue";
|
||||
import { api, TOKEN_KEY, USER_KEY } from "./api";
|
||||
// Reactive auth state for whichever server is active. Tokens are held per
|
||||
// server by servers.js (each one is a separate PocketBase, so a session cannot
|
||||
// be shared); this module mirrors the active one into `state` and keeps the
|
||||
// richer profile in step, so every view can go on reading `state.user` without
|
||||
// knowing that more than one server exists.
|
||||
import { reactive, computed, watch } from "vue";
|
||||
import { api } from "./api";
|
||||
import {
|
||||
servers,
|
||||
serverById,
|
||||
sessionFor,
|
||||
setSession,
|
||||
clearSession,
|
||||
clearAllSessions,
|
||||
setActive,
|
||||
baseFor,
|
||||
HOME_ID,
|
||||
} from "./servers";
|
||||
import { applyProfilePrefs } from "./prefs";
|
||||
import { t } from "./i18n";
|
||||
|
||||
export const state = reactive({
|
||||
token: localStorage.getItem(TOKEN_KEY) || "",
|
||||
user: JSON.parse(localStorage.getItem(USER_KEY) || "null"),
|
||||
token: "",
|
||||
user: null,
|
||||
// Full settings-panel profile (bio, theme, locale, ...), fetched separately
|
||||
// from /api/me since the login response only carries id/email/name.
|
||||
profile: null,
|
||||
});
|
||||
|
||||
function syncFromActive() {
|
||||
const session = sessionFor(servers.activeId);
|
||||
state.token = session?.token || "";
|
||||
state.user = session?.user || null;
|
||||
}
|
||||
syncFromActive();
|
||||
|
||||
// Switching servers swaps the whole session: a different PocketBase, a
|
||||
// different user record, a different set of preferences. Watching the active id
|
||||
// *and* its token also covers a session being cleared underneath us — which is
|
||||
// what api.js does when a server answers 401.
|
||||
watch(
|
||||
() => [servers.activeId, sessionFor(servers.activeId)?.token],
|
||||
([id], [prevId]) => {
|
||||
syncFromActive();
|
||||
if (id !== prevId) state.profile = null;
|
||||
if (state.token && !state.profile) refreshProfile().catch(() => {});
|
||||
}
|
||||
);
|
||||
|
||||
export const isAuthenticated = computed(() => !!state.token);
|
||||
|
||||
// Roles that may manage users. A superadmin is an admin that also spans every
|
||||
@@ -22,30 +56,52 @@ const MANAGER_ROLES = ["admin", "superadmin"];
|
||||
|
||||
// Admin gate for the UI. Driven by the full profile (fetched from /api/me),
|
||||
// which always reflects the current role from the DB — so a promotion/demotion
|
||||
// takes effect on the next profile refresh without needing a re-login.
|
||||
// takes effect on the next profile refresh without needing a re-login. It is
|
||||
// per-server: being an admin on one server says nothing about the other.
|
||||
export const isAdmin = computed(
|
||||
() => MANAGER_ROLES.includes(state.profile?.role) || MANAGER_ROLES.includes(state.user?.role)
|
||||
);
|
||||
|
||||
export async function login(email, password) {
|
||||
// Logs into one server and makes it active. The login screen calls it for
|
||||
// whichever server is active; the switcher's connect dialog calls it for one
|
||||
// that isn't yet, which is why the credentials are checked against that
|
||||
// server's own base before anything switches.
|
||||
export async function connect(serverId, email, password) {
|
||||
const server = serverById(serverId);
|
||||
if (!server) throw new Error(t("servers.unknown"));
|
||||
// The API Server proxies login to PocketBase and relays its response
|
||||
// verbatim, so the user is under `record` (PocketBase's name) and the token
|
||||
// is PocketBase's own — this app no longer holds a server-minted JWT.
|
||||
const res = await api.login(email, password);
|
||||
state.token = res.token;
|
||||
state.user = res.record;
|
||||
localStorage.setItem(TOKEN_KEY, res.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(res.record));
|
||||
const res = await api.loginAt(baseFor(server), email, password);
|
||||
setSession(serverId, res.token, res.record);
|
||||
state.profile = null;
|
||||
if (servers.activeId === serverId) syncFromActive();
|
||||
else setActive(serverId);
|
||||
await refreshProfile();
|
||||
return res;
|
||||
}
|
||||
|
||||
export function login(email, password) {
|
||||
return connect(servers.activeId, email, password);
|
||||
}
|
||||
|
||||
// Signs out of one server without leaving the app — the switcher's per-server
|
||||
// action. Dropping the active one falls back to home, the same way an expired
|
||||
// token does.
|
||||
export function disconnect(serverId) {
|
||||
clearSession(serverId);
|
||||
if (servers.activeId === serverId && serverId !== HOME_ID) setActive(HOME_ID);
|
||||
}
|
||||
|
||||
// Signs out everywhere. Leaving the app means leaving every server you reached
|
||||
// from it — leaving a live token behind on a shared browser would be worse than
|
||||
// the inconvenience of logging back in.
|
||||
export function logout() {
|
||||
clearAllSessions();
|
||||
setActive(HOME_ID);
|
||||
state.token = "";
|
||||
state.user = null;
|
||||
state.profile = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
// Fetches the full profile (used for Settings + to apply appearance prefs).
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
// Add a server, edit one, or log into it — one dialog, because for a server you
|
||||
// have just typed in they are the same act: an address is only worth keeping
|
||||
// once something has answered at it.
|
||||
import { ref, computed } from "vue";
|
||||
import {
|
||||
DEFAULT_API_BASE,
|
||||
HOME_ID,
|
||||
addServer,
|
||||
updateServer,
|
||||
removeServer,
|
||||
isConnected,
|
||||
displayName,
|
||||
} from "../servers";
|
||||
import { connect, disconnect } from "../auth";
|
||||
import { t } from "../i18n";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
// An existing server to edit/connect to, or null to add a new one.
|
||||
server: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["done", "close"]);
|
||||
|
||||
const isNew = !props.server;
|
||||
const isHome = props.server?.id === HOME_ID;
|
||||
const connected = computed(() => !!props.server && isConnected(props.server.id));
|
||||
|
||||
const name = ref(props.server?.name || "");
|
||||
const url = ref(props.server?.url || "");
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const showPassword = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const title = computed(() =>
|
||||
isNew ? t("servers.addTitle") : displayName(props.server)
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
// Keep the id of a server added in this attempt, so a second try after a bad
|
||||
// password edits that entry instead of stacking up duplicates.
|
||||
let target = props.server;
|
||||
try {
|
||||
if (isNew) target = addServer({ name: name.value, url: url.value });
|
||||
else updateServer(target.id, { name: name.value, url: url.value });
|
||||
|
||||
if (connected.value) emit("done");
|
||||
else {
|
||||
await connect(target.id, email.value.trim(), password.value);
|
||||
emit("done");
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || t("login.failed");
|
||||
// A server that never answered is not worth keeping in the list.
|
||||
if (isNew && target) {
|
||||
removeServer(target.id);
|
||||
target = null;
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDisconnect() {
|
||||
disconnect(props.server.id);
|
||||
emit("done");
|
||||
}
|
||||
|
||||
function onRemove() {
|
||||
if (!confirm(t("servers.removeConfirm", { name: displayName(props.server) }))) return;
|
||||
removeServer(props.server.id);
|
||||
emit("done");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="title" @close="emit('close')">
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">{{ t("servers.name") }}</label>
|
||||
<input v-model="name" type="text" :placeholder="t('servers.namePlaceholder')" class="dh-input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">{{ t("servers.url") }}</label>
|
||||
<input
|
||||
v-model="url"
|
||||
type="text"
|
||||
:placeholder="isHome ? DEFAULT_API_BASE : 'https://garage.example.com'"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="dh-input data"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted">{{ isHome ? t("servers.urlHomeHint") : t("servers.urlHint") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Credentials only while this server has no session: each server is its
|
||||
own PocketBase, so signing in happens once per server. -->
|
||||
<template v-if="!connected">
|
||||
<div class="border-t border-subtle pt-4">
|
||||
<label class="dh-label">{{ t("login.email") }}</label>
|
||||
<input v-model="email" type="email" required autocomplete="off" class="dh-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("login.password") }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="dh-input pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-muted transition-colors hover:text-strong"
|
||||
:aria-label="showPassword ? t('login.hidePassword') : t('login.showPassword')"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5">
|
||||
<path v-if="showPassword" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<template v-else>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<button type="submit" :disabled="busy" class="dh-btn dh-btn-primary">
|
||||
{{ busy ? t("servers.connecting") : connected ? t("common.save") : t("servers.connect") }}
|
||||
</button>
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
|
||||
<span class="flex-1"></span>
|
||||
<button
|
||||
v-if="connected"
|
||||
type="button"
|
||||
class="dh-btn !px-3 !py-1.5 !text-xs text-muted hover:bg-sunken"
|
||||
@click="onDisconnect"
|
||||
>
|
||||
{{ t("servers.signOut") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isNew && !isHome"
|
||||
type="button"
|
||||
class="dh-btn !px-3 !py-1.5 !text-xs text-danger hover:bg-danger-soft"
|
||||
@click="onRemove"
|
||||
>
|
||||
{{ t("common.remove") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
// The server picker in the app rail. It names the server you are reading right
|
||||
// now — which matters most when two sites hold different cars and the pages
|
||||
// otherwise look identical — and switches between them in one click once each
|
||||
// has been signed into.
|
||||
import { ref, computed } from "vue";
|
||||
import { servers, activeServer, displayName, isConnected, setActive } from "../servers";
|
||||
import { t } from "../i18n";
|
||||
import ServerConnectModal from "./ServerConnectModal.vue";
|
||||
|
||||
const open = ref(false);
|
||||
// The server the dialog is working on: an existing entry to edit/connect, or
|
||||
// null with `adding` set for a new one.
|
||||
const editing = ref(null);
|
||||
const adding = ref(false);
|
||||
|
||||
const current = computed(() => activeServer.value);
|
||||
const showModal = computed(() => adding.value || !!editing.value);
|
||||
|
||||
// Only worth showing the rail entry once there is a choice to make, or a second
|
||||
// server has been added and is waiting to be signed into.
|
||||
const hasChoice = computed(() => servers.list.length > 1);
|
||||
|
||||
function choose(server) {
|
||||
if (isConnected(server.id)) {
|
||||
setActive(server.id);
|
||||
open.value = false;
|
||||
return;
|
||||
}
|
||||
// No session for it yet — the same dialog that adds a server signs you in.
|
||||
editing.value = server;
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function edit(server) {
|
||||
editing.value = server;
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function add() {
|
||||
adding.value = true;
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
editing.value = null;
|
||||
adding.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- One rail entry either way: it names the active server once there is a
|
||||
choice, and offers to add the second one when there isn't. -->
|
||||
<button
|
||||
class="flex w-full items-center gap-3 rounded-control px-2.5 py-2 text-left text-[15px] font-medium text-white/60 transition-colors hover:bg-white/5 hover:text-white md:px-3"
|
||||
:title="hasChoice ? t('servers.switchHint') : t('servers.add')"
|
||||
@click="hasChoice ? (open = !open) : add()"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0">
|
||||
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||
<path stroke-linecap="round" d="M7 7.5h.01M7 16.5h.01" />
|
||||
</svg>
|
||||
<span class="hidden min-w-0 flex-1 truncate md:inline">
|
||||
{{ hasChoice ? displayName(current) : t("servers.add") }}
|
||||
</span>
|
||||
<svg v-if="hasChoice" viewBox="0 0 20 20" fill="currentColor" class="hidden h-4 w-4 shrink-0 md:block">
|
||||
<path fill-rule="evenodd" d="M10 3a.75.75 0 0 1 .55.24l3.25 3.5a.75.75 0 1 1-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 0 1-1.1-1.02l3.25-3.5A.75.75 0 0 1 10 3Zm-3.8 9.24a.75.75 0 0 1 1.06-.04l2.74 2.908 2.7-2.908a.75.75 0 1 1 1.1 1.02l-3.25 3.5a.75.75 0 0 1-1.1 0l-3.25-3.5a.75.75 0 0 1 .04-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Click-away backdrop, behind the menu but over everything else. -->
|
||||
<div v-if="open" class="fixed inset-0 z-20" @click="open = false"></div>
|
||||
|
||||
<div v-if="open" class="dh-card absolute bottom-full left-0 z-30 mb-2 w-64 overflow-hidden p-1.5 shadow-pop">
|
||||
<p class="eyebrow px-2 pb-1 pt-1.5">{{ t("servers.title") }}</p>
|
||||
|
||||
<div
|
||||
v-for="s in servers.list"
|
||||
:key="s.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-control px-2 py-2 text-left transition-colors hover:bg-sunken"
|
||||
@click="choose(s)"
|
||||
@keydown.enter="choose(s)"
|
||||
>
|
||||
<!-- Green once this server has a session of its own; grey means picking
|
||||
it will ask for credentials rather than switch. -->
|
||||
<span
|
||||
class="h-2 w-2 flex-none rounded-full"
|
||||
:class="isConnected(s.id) ? 'bg-success' : 'bg-muted/40'"
|
||||
:title="isConnected(s.id) ? t('servers.connected') : t('servers.notConnected')"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-strong">{{ displayName(s) }}</span>
|
||||
<span class="data block truncate text-[11px] text-muted">{{ s.url || t("servers.sameOrigin") }}</span>
|
||||
</span>
|
||||
<svg
|
||||
v-if="s.id === servers.activeId"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 flex-none text-brand-600"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0l-3.5-3.5a1 1 0 1 1 1.4-1.4l2.8 2.8 6.8-6.8a1 1 0 0 1 1.4 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-none rounded p-1 text-muted transition-colors hover:bg-page hover:text-strong"
|
||||
:title="t('common.edit')"
|
||||
@click.stop="edit(s)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-3.5 w-3.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.5 3.5 4 4L8 20H4v-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 flex w-full items-center gap-2 border-t border-subtle px-2 pb-1 pt-2.5 text-left text-sm font-medium text-muted transition-colors hover:text-strong"
|
||||
@click="add"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4">
|
||||
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
{{ t("servers.add") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ServerConnectModal v-if="showModal" :server="editing" @done="closeModal" @close="closeModal" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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") }}
|
||||
</button>
|
||||
|
||||
<!-- Server settings: optional override of the API server address -->
|
||||
<div class="border-t border-subtle pt-3">
|
||||
<button type="button" @click="showServer = !showServer"
|
||||
class="flex w-full items-center justify-between text-xs font-semibold text-muted transition-colors hover:text-strong">
|
||||
<span>{{ t("login.serverSettings") }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||
class="h-4 w-4 transition-transform" :class="showServer ? 'rotate-180' : ''">
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showServer" class="mt-3 space-y-2">
|
||||
<label class="eyebrow block">{{ t("login.apiServerUrl") }}</label>
|
||||
<input v-model="serverUrl" type="text" :placeholder="DEFAULT_API_BASE" autocomplete="off" class="dh-input data" />
|
||||
<p class="text-xs text-muted">
|
||||
{{ tSplit("login.leaveBlank", "url").before
|
||||
}}<span class="data">{{ DEFAULT_API_BASE }}</span>{{ tSplit("login.leaveBlank", "url").after }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" @click="saveServer" class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-xs">{{ t("common.save") }}</button>
|
||||
<button type="button" @click="resetServer" class="dh-btn !px-3 !py-1.5 !text-xs text-muted hover:bg-sunken">{{ t("login.resetToDefault") }}</button>
|
||||
<span v-if="serverSaved" class="text-xs font-medium text-success">{{ t("common.saved") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user