Files
DriverVault/Web App/web/src/components/ServerSwitcher.vue
T
tajniak81andClaude Opus 5 f521d2b220 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>
2026-08-21 11:55:50 +02:00

134 lines
5.5 KiB
Vue

<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>