Frontend: Move the instance switcher into the shared navigation user menu

Add a shared component/navigation/user-menu.vue (Settings / Switch Instance /
Log Out) wired into the navigation avatar, so every edition gets the switcher.
Instances record their identity under their storage namespace at session auth and
clear it on logout (common/instances + session.js), and the menu lists same-origin
peers that hold a live session. Keeps the distinctive base-path instanceLabel and
the post-login redirect loop guard. Replaces the bootstrap-time directory recording.
This commit is contained in:
Michael Mayer 2026-06-03 19:42:56 +00:00
parent 368ef7912b
commit dffb81ab31
9 changed files with 415 additions and 277 deletions

View file

@ -34,7 +34,6 @@ import $event from "common/event";
import $log from "common/log";
import { registerServiceWorker } from "common/pwa";
import $util from "common/util";
import { recordInstanceFromConfig } from "common/instances";
import * as components from "component/components";
import icons from "component/icons";
import defaults from "component/defaults";
@ -77,10 +76,6 @@ $config.update().finally(() => {
// Check if running in public mode.
const $isPublic = $config.isPublic();
// Record this instance in the shared browser-storage directory so same-origin
// siblings can offer it as an instance-switcher target (see common/instances).
recordInstanceFromConfig($config);
let app = createApp(PhotoPrism);
// Initialize language and detect its alignment.

View file

@ -23,67 +23,29 @@ Additional information can be found in our Developer Guide:
*/
// Shared, cross-namespace browser-storage directory that lets same-origin
// PhotoPrism instances discover one another for the account-menu instance
// switcher. Each instance records its own {siteUrl, name} under its storage
// namespace (the SHA-256 of its SiteUrl, see config StorageNamespace and
// assets/templates/auth.gohtml). The switcher then renders the directory
// entries that currently hold a live session token (common/storage.js
// listAuthSessions) so a user can jump between instances they are signed in to,
// without relying on any cluster API. On standalone or separate-origin
// instances the directory only ever contains the current instance, so the
// switcher stays hidden.
import { listAuthSessions, buildNamespace } from "common/storage";
import { listAuthSessions } from "common/storage";
// Storage suffixes under which each instance records its public identity, so
// peers on the same shared-domain origin can render a navigation switcher entry.
// The namespace is a SHA-256 hash of the SiteUrl and is not reversible, so the
// URL and title must be persisted explicitly under the instance's namespace.
const InstanceUrlKey = "instance.url";
const InstanceTitleKey = "instance.title";
// DirectoryKey is the raw localStorage key holding the instance directory. It
// is intentionally NOT namespaced (no "pp:<ns>:" prefix) so every same-origin
// instance reads and writes the same map; parseNamespace() ignores it because
// it has no second separator.
export const DirectoryKey = "pp:instances";
// InstanceIdentityKeys lists the suffix keys written by persistInstanceIdentity,
// so callers (e.g. session logout) can clear them across storage backends.
export const InstanceIdentityKeys = [InstanceUrlKey, InstanceTitleKey];
// getWindow safely accesses the browser window if it exists.
const getWindow = () => (typeof window === "undefined" ? null : window);
// getLocalStorage returns the explicit storage or the raw window localStorage.
// The directory deliberately uses raw (un-namespaced) storage so it is shared
// across instance namespaces on the same origin.
const getLocalStorage = (storage) => {
if (storage) {
return storage;
}
const w = getWindow();
return w ? w.localStorage : null;
};
// readConfigValue reads a string field from a Config-like object, tolerating
// both the live $config (with .values / .get()) and a plain values object.
const readConfigValue = (config, key) => {
if (!config) {
return "";
}
if (config.values && typeof config.values[key] === "string") {
return config.values[key];
}
if (typeof config.get === "function") {
const v = config.get(key);
if (typeof v === "string") {
return v;
}
}
if (typeof config[key] === "string") {
return config[key];
}
return "";
};
// safeWindow returns the browser window if available, else null.
const safeWindow = () => (typeof window === "undefined" ? null : window);
// instanceLabel derives a short, distinctive display name from a SiteUrl — the
// last base-path segment (e.g. "pro-1" for ".../i/pro-1/"). The switcher can
// only surface same-origin instances, which always differ by path, so the path
// segment is more distinctive than the frequently-generic site caption/title
// (multiple instances commonly share the default "PhotoPrism" caption). Returns
// "" for a root-path or unparseable URL so the caller falls back to the caption.
const instanceLabel = (siteUrl) => {
// "" for a root-path or unparseable URL so the caller falls back to the title.
export function instanceLabel(siteUrl) {
if (!siteUrl || typeof siteUrl !== "string") {
return "";
}
@ -93,95 +55,78 @@ const instanceLabel = (siteUrl) => {
} catch {
return "";
}
};
}
// instanceIdentity extracts {namespace, siteUrl, name} from a Config-like
// object. The name prefers the distinctive base-path segment, then the site
// caption/title/app name, then the SiteUrl so an entry is always labelable.
export const instanceIdentity = (config) => {
const namespace = readConfigValue(config, "storageNamespace");
const siteUrl = readConfigValue(config, "siteUrl");
const name =
instanceLabel(siteUrl) ||
readConfigValue(config, "siteCaption") ||
readConfigValue(config, "siteTitle") ||
readConfigValue(config, "name") ||
siteUrl;
return { namespace, siteUrl, name };
};
// readDirectory parses the instance directory map; returns {} on any error.
export const readDirectory = (storage) => {
const store = getLocalStorage(storage);
if (!store || typeof store.getItem !== "function") {
return {};
}
try {
const raw = store.getItem(DirectoryKey);
if (!raw) {
return {};
}
const dir = JSON.parse(raw);
return dir && typeof dir === "object" ? dir : {};
} catch {
return {};
}
};
// recordInstance upserts an instance identity into the shared directory. A
// missing namespace or siteUrl is a no-op, so it is safe to call during early
// bootstrap before the client config is fully resolved.
export const recordInstance = (identity, storage) => {
const { namespace, siteUrl, name } = identity || {};
if (!namespace || !siteUrl) {
// persistInstanceIdentity records this instance's SiteUrl and display title in
// the given (namespaced) store, so other instances on the same origin can list
// it in the navigation instance switcher. No-op without a URL or usable store.
export function persistInstanceIdentity(store, identity) {
if (!store || typeof store.setItem !== "function" || !identity || !identity.url) {
return;
}
const store = getLocalStorage(storage);
if (!store || typeof store.setItem !== "function") {
return;
store.setItem(InstanceUrlKey, identity.url);
if (identity.title) {
store.setItem(InstanceTitleKey, identity.title);
} else {
store.removeItem(InstanceTitleKey);
}
try {
const dir = readDirectory(store);
const next = name || siteUrl;
const existing = dir[namespace];
if (existing && existing.siteUrl === siteUrl && existing.name === next) {
}
// listReachableInstances returns the instances (other than currentNamespace) that
// have a live session token and a recorded identity in shared browser storage,
// for the navigation instance switcher. Both localStorage (persistent sessions)
// and sessionStorage (ephemeral sessions, shared across same-tab navigations) are
// scanned, since recordInstanceIdentity writes to whichever the instance uses.
// Returns an empty array on standalone or subdomain-isolated deployments where no
// peer sessions are discoverable.
export function listReachableInstances(options) {
const opts = options || {};
let stores;
if (opts.storage) {
stores = [opts.storage];
} else if (Array.isArray(opts.stores)) {
stores = opts.stores;
} else {
const w = safeWindow();
stores = [w?.localStorage, w?.sessionStorage];
}
const currentPrefix = buildNamespace(opts.currentNamespace);
const seen = new Set();
const instances = [];
stores.forEach((store) => {
if (!store || typeof store.getItem !== "function") {
return;
}
dir[namespace] = { siteUrl, name: next };
store.setItem(DirectoryKey, JSON.stringify(dir));
} catch {
/* ignore quota / serialization errors — the directory is best-effort */
}
};
// recordInstanceFromConfig records the current instance using its client config.
export const recordInstanceFromConfig = (config, storage) => {
recordInstance(instanceIdentity(config), storage);
};
listAuthSessions(store).forEach((session) => {
const namespace = session && session.namespace;
if (!namespace) {
return;
}
// signedInInstances returns the directory entries — excluding the current
// instance — that currently hold a live session token, as the switch-instance
// target list. Each target is {namespace, siteUrl, name}, sorted by name for a
// deterministic menu order.
export const signedInInstances = (config, storage) => {
const store = getLocalStorage(storage);
if (!store) {
return [];
}
const dir = readDirectory(store);
const current = readConfigValue(config, "storageNamespace");
const live = new Set(listAuthSessions(store).map((s) => s.namespace));
const out = [];
for (const namespace of Object.keys(dir)) {
if (namespace === current || !live.has(namespace)) {
continue;
}
const entry = dir[namespace];
if (!entry || !entry.siteUrl) {
continue;
}
out.push({ namespace, siteUrl: entry.siteUrl, name: entry.name || entry.siteUrl });
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
};
const prefix = buildNamespace(namespace);
if (prefix === currentPrefix || seen.has(prefix)) {
return;
}
const url = store.getItem(prefix + InstanceUrlKey);
if (!url) {
return;
}
seen.add(prefix);
instances.push({
namespace,
url,
title: store.getItem(prefix + InstanceTitleKey) || url,
});
});
});
return instances;
}

View file

@ -26,6 +26,7 @@ Additional information can be found in our Developer Guide:
import $api from "common/api";
import $event from "common/event";
import { createNamespacedStorage } from "common/storage";
import { persistInstanceIdentity, InstanceIdentityKeys, instanceLabel } from "common/instances";
import { $view } from "common/view";
import User from "model/user";
import Socket from "websocket.js";
@ -154,6 +155,11 @@ export default class Session {
// Authenticated?
this.auth = this.isUser();
// Record this instance's identity so peers on a shared domain can switch to it.
if (this.auth) {
this.recordInstanceIdentity();
}
// Subscribe to session events.
$event.subscribe("session.logout", () => {
return this.onLogout();
@ -444,6 +450,28 @@ export default class Session {
this.user = new User(user);
this.storage.setItem(this.storageKey + ".user", JSON.stringify(user));
this.auth = this.isUser();
if (this.auth) {
this.recordInstanceIdentity();
}
}
// recordInstanceIdentity stores this instance's SiteUrl and display title under
// the active namespace so other instances on the same origin can offer a switch
// entry. The title prefers the distinctive base-path segment (instanceLabel) so
// co-located instances that share a generic caption stay distinguishable. No-op
// when the site URL is unknown or storage is unavailable.
recordInstanceIdentity() {
const values = this.config?.values;
if (!values || !values.siteUrl) {
return;
}
persistInstanceIdentity(this.storage, {
url: values.siteUrl,
title: instanceLabel(values.siteUrl) || values.siteTitle || values.name || values.siteUrl,
});
}
getUser() {
@ -615,6 +643,9 @@ export default class Session {
this.clearNamespacedKey(this.storageKey + ".user");
this.clearLegacyKey("user");
this.clearLegacyKey("session.user");
// Drop this instance's switcher identity so a logged-out instance is no
// longer offered as a switch target by peers on the same shared domain.
InstanceIdentityKeys.forEach((key) => this.clearNamespacedKey(key));
}
deleteClipboard() {

View file

@ -641,18 +641,15 @@
<div v-show="auth && !isPublic && !disconnected" class="nav-info user-info">
<div class="nav-info__underlay"></div>
<div class="nav-user-avatar text-center my-1 mx-2 clickable" @click.stop="showAccountSettings">
<img :src="userAvatarURL" :alt="accountInfo" :title="accountInfo" class="rounded-circle" />
</div>
<div v-if="!isMini" class="text-start mt-1 flex-grow-1 clickable" @click.stop="showAccountSettings">
<p class="text-body-2">{{ displayName }}</p>
<p class="text-caption opacity-70">{{ accountInfo }}</p>
</div>
<div class="text-center">
<v-btn icon variant="text" :elevation="0" @click.stop.prevent="onLogout">
<v-icon>mdi-power</v-icon>
</v-btn>
</div>
<p-user-menu @settings="showAccountSettings" @logout="onLogout">
<div class="nav-user-avatar text-center my-1 mx-2">
<img :src="userAvatarURL" :alt="accountInfo" :title="accountInfo" class="rounded-circle" />
</div>
<div v-if="!isMini" class="nav-user-text text-start mt-1 flex-grow-1">
<p class="text-body-2">{{ displayName }}</p>
<p class="text-caption opacity-70">{{ accountInfo }}</p>
</div>
</p-user-menu>
</div>
</div>
</v-navigation-drawer>
@ -770,11 +767,15 @@
<script>
import links from "common/links";
import { getAppStorage } from "common/storage";
import PUserMenu from "component/navigation/user-menu.vue";
const appStorage = getAppStorage();
export default {
name: "PNavigation",
components: {
PUserMenu,
},
data() {
const appName = this.$config.getName();

View file

@ -0,0 +1,78 @@
<template>
<v-menu
v-model="open"
location="top"
offset="6"
:min-width="240"
class="nav-user-menu"
@update:model-value="onToggle"
>
<template #activator="{ props }">
<div v-bind="props" class="nav-user-activator clickable" :title="$gettext('Account')">
<slot></slot>
</div>
</template>
<v-list slim nav density="compact" bg-color="navigation" class="nav-user-menu__list">
<v-list-item prepend-icon="mdi-cog-outline" :title="$gettext('Settings')" class="action-settings" @click="$emit('settings')"></v-list-item>
<template v-if="instances.length > 0">
<v-divider class="my-1"></v-divider>
<v-list-subheader class="nav-user-menu__subheader">{{ $gettext("Switch Instance") }}</v-list-subheader>
<v-list-item
v-for="instance in instances"
:key="instance.namespace"
prepend-icon="mdi-server"
:title="instance.title"
:subtitle="instance.url"
class="action-switch-instance"
@click="onSwitch(instance)"
></v-list-item>
</template>
<v-divider class="my-1"></v-divider>
<v-list-item prepend-icon="mdi-power" :title="$gettext('Log Out')" base-color="danger" class="action-logout" @click="$emit('logout')"></v-list-item>
</v-list>
</v-menu>
</template>
<script>
import { listReachableInstances } from "common/instances";
// PUserMenu is the navigation avatar overlay menu: Settings, Log Out, and when
// more than one instance is reachable on a shared domain a Switch Instance list.
export default {
name: "PUserMenu",
emits: ["settings", "logout"],
data() {
return {
open: false,
instances: [],
};
},
mounted() {
this.refresh();
},
methods: {
// refresh rescans shared localStorage for reachable peer instances.
refresh() {
const namespace = this.$config?.values?.storageNamespace;
this.instances = listReachableInstances({ currentNamespace: namespace });
},
// onToggle rescans peers each time the menu opens so the switcher stays current.
onToggle(open) {
if (open) {
this.refresh();
}
},
// onSwitch navigates the browser to the selected instance's site URL, whose
// own login completes the standard OIDC flow when a session is required.
onSwitch(instance) {
if (instance && instance.url) {
this.navigate(instance.url);
}
},
// navigate sends the browser to the given URL (a seam so onSwitch is testable).
navigate(url) {
window.location.assign(url);
},
},
};
</script>

View file

@ -54,6 +54,33 @@ nav .v-list__item__title.title {
margin-right: auto;
}
#p-navigation .nav-user-activator {
display: flex;
align-items: center;
flex-grow: 1;
gap: 8px;
min-width: 0;
padding: 2px 4px;
border-radius: 8px;
cursor: pointer;
}
#p-navigation .nav-user-activator .nav-user-text {
min-width: 0;
overflow: hidden;
}
#p-navigation .nav-user-activator .nav-user-text p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-sidebar.v-navigation-drawer--rail .nav-container > .nav-info > .nav-user-activator {
flex-grow: 0;
justify-content: center;
}
#p-navigation .nav-sidebar .nav-logo {
width: 40px;
text-align: inherit;

View file

@ -61,7 +61,13 @@ export default class Page {
async logout() {
await menu.openNav();
await t.click(Selector("button i.mdi-power"));
await t.click(Selector("div.nav-user-avatar"));
await t.click(Selector(".nav-user-menu__list .action-logout"));
}
async openAccount() {
await t.click(Selector("div.nav-user-avatar"));
await t.click(Selector(".nav-user-menu__list .action-settings"));
}
async clickCardTitleOfUID(uid) {

View file

@ -1,132 +1,128 @@
import { describe, expect, it } from "vitest";
import { DirectoryKey, instanceIdentity, readDirectory, recordInstance, recordInstanceFromConfig, signedInInstances } from "common/instances";
import { describe, it, expect } from "vitest";
import StorageShim from "node-storage-shim";
import { buildNamespace, createNamespacedStorage } from "common/storage";
import { persistInstanceIdentity, listReachableInstances, InstanceIdentityKeys, instanceLabel } from "common/instances";
// FakeStorage mirrors the subset of the Web Storage API that common/storage and
// common/instances rely on (length/key/getItem/setItem/removeItem), with
// insertion-ordered keys so listAuthSessions can enumerate namespaces.
class FakeStorage {
constructor() {
this.map = new Map();
// seedInstance writes a peer instance's session token and identity into a shared store.
const seedInstance = (store, namespace, { token = "tok", url, title } = {}) => {
const prefix = buildNamespace(namespace);
if (token) {
store.setItem(prefix + "session.token", token);
}
get length() {
return this.map.size;
if (url) {
store.setItem(prefix + "instance.url", url);
}
key(i) {
const keys = Array.from(this.map.keys());
return i >= 0 && i < keys.length ? keys[i] : null;
if (title) {
store.setItem(prefix + "instance.title", title);
}
getItem(k) {
return this.map.has(k) ? this.map.get(k) : null;
}
setItem(k, v) {
this.map.set(k, String(v));
}
removeItem(k) {
this.map.delete(k);
}
}
};
const NS_PORTAL = "aaaaaaaaaaaa";
const NS_P1 = "bbbbbbbbbbbb";
const NS_P2 = "cccccccccccc";
const seedDirectory = (store) =>
store.setItem(
DirectoryKey,
JSON.stringify({
[NS_PORTAL]: { siteUrl: "https://x/", name: "Portal" },
[NS_P1]: { siteUrl: "https://x/i/pro-1", name: "Pro One" },
[NS_P2]: { siteUrl: "https://x/i/pro-2", name: "Pro Two" },
})
);
describe("common/instances instanceIdentity", () => {
it("derives the name from the distinctive base-path segment", () => {
const config = { values: { storageNamespace: NS_P1, siteUrl: "https://app.example.com/i/pro-1/", siteCaption: "AI-Powered DAM" } };
expect(instanceIdentity(config)).toEqual({ namespace: NS_P1, siteUrl: "https://app.example.com/i/pro-1/", name: "pro-1" });
describe("common/instances", () => {
describe("instanceLabel", () => {
it("derives the last base-path segment as a distinctive label", () => {
expect(instanceLabel("https://app.example.com/i/pro-1/")).toBe("pro-1");
expect(instanceLabel("https://app.example.com/i/pro-2")).toBe("pro-2");
});
it("returns an empty string for a root-path or unparseable url", () => {
expect(instanceLabel("https://app.example.com/")).toBe("");
expect(instanceLabel("not a url")).toBe("");
expect(instanceLabel("")).toBe("");
expect(instanceLabel(null)).toBe("");
});
});
it("falls back to the site caption when the URL has no base path", () => {
const config = { values: { storageNamespace: NS_PORTAL, siteUrl: "https://x/", siteCaption: "Portal" } };
expect(instanceIdentity(config)).toEqual({ namespace: NS_PORTAL, siteUrl: "https://x/", name: "Portal" });
describe("persistInstanceIdentity", () => {
it("writes url and title under the namespace", () => {
const store = new StorageShim();
const ns = createNamespacedStorage(store, "ns-pro-1");
persistInstanceIdentity(ns, { url: "https://pro-1.example.com/", title: "Pro One" });
const prefix = buildNamespace("ns-pro-1");
expect(store.getItem(prefix + "instance.url")).toBe("https://pro-1.example.com/");
expect(store.getItem(prefix + "instance.title")).toBe("Pro One");
});
it("clears a stale title when none is provided", () => {
const store = new StorageShim();
const ns = createNamespacedStorage(store, "ns-pro-1");
persistInstanceIdentity(ns, { url: "https://pro-1.example.com/", title: "Pro One" });
persistInstanceIdentity(ns, { url: "https://pro-1.example.com/" });
const prefix = buildNamespace("ns-pro-1");
expect(store.getItem(prefix + "instance.title")).toBeNull();
});
it("is a no-op without a url", () => {
const store = new StorageShim();
const ns = createNamespacedStorage(store, "ns-pro-1");
persistInstanceIdentity(ns, { title: "Pro One" });
expect(store.length).toBe(0);
});
it("is a no-op without a usable store", () => {
expect(() => persistInstanceIdentity(null, { url: "https://pro-1.example.com/" })).not.toThrow();
});
it("exposes the identity suffix keys for cleanup", () => {
expect(InstanceIdentityKeys).toContain("instance.url");
expect(InstanceIdentityKeys).toContain("instance.title");
});
});
it("falls back to the siteUrl when nothing else is available", () => {
expect(instanceIdentity({ values: { storageNamespace: NS_P1, siteUrl: "https://x/" } }).name).toBe("https://x/");
});
it("tolerates a missing config", () => {
expect(instanceIdentity(undefined)).toEqual({ namespace: "", siteUrl: "", name: "" });
});
});
describe("common/instances readDirectory", () => {
it("returns an empty map when nothing is stored", () => {
expect(readDirectory(new FakeStorage())).toEqual({});
});
it("returns an empty map for malformed JSON", () => {
const store = new FakeStorage();
store.setItem(DirectoryKey, "{not json");
expect(readDirectory(store)).toEqual({});
});
});
describe("common/instances recordInstance", () => {
it("upserts an instance under its namespace", () => {
const store = new FakeStorage();
recordInstance({ namespace: NS_P1, siteUrl: "https://x/i/pro-1", name: "Pro One" }, store);
expect(readDirectory(store)[NS_P1]).toEqual({ siteUrl: "https://x/i/pro-1", name: "Pro One" });
});
it("labels with the siteUrl when no name is supplied", () => {
const store = new FakeStorage();
recordInstance({ namespace: NS_P1, siteUrl: "https://x/i/pro-1" }, store);
expect(readDirectory(store)[NS_P1].name).toBe("https://x/i/pro-1");
});
it("is a no-op without a namespace or siteUrl", () => {
const store = new FakeStorage();
recordInstance({ namespace: "", siteUrl: "https://x/" }, store);
recordInstance({ namespace: NS_P1, siteUrl: "" }, store);
expect(readDirectory(store)).toEqual({});
});
it("does not rewrite an unchanged entry", () => {
const store = new FakeStorage();
recordInstance({ namespace: NS_P1, siteUrl: "https://x/i/pro-1", name: "Pro One" }, store);
const before = store.getItem(DirectoryKey);
store.setItem = () => {
throw new Error("must not write");
};
expect(() => recordInstance({ namespace: NS_P1, siteUrl: "https://x/i/pro-1", name: "Pro One" }, store)).not.toThrow();
expect(store.getItem(DirectoryKey)).toBe(before);
});
});
describe("common/instances recordInstanceFromConfig", () => {
it("records the identity derived from a config", () => {
const store = new FakeStorage();
recordInstanceFromConfig({ values: { storageNamespace: NS_P2, siteUrl: "https://x/i/pro-2", siteCaption: "Pro Two" } }, store);
expect(readDirectory(store)[NS_P2]).toEqual({ siteUrl: "https://x/i/pro-2", name: "pro-2" });
});
});
describe("common/instances signedInInstances", () => {
it("returns other instances that currently hold a live session, excluding the current one", () => {
const store = new FakeStorage();
seedDirectory(store);
store.setItem(`pp:${NS_PORTAL}:session.token`, "tok-portal");
store.setItem(`pp:${NS_P1}:session.token`, "tok-p1");
// pro-2 is in the directory but has no live session token.
const config = { values: { storageNamespace: NS_PORTAL } };
expect(signedInInstances(config, store)).toEqual([{ namespace: NS_P1, siteUrl: "https://x/i/pro-1", name: "Pro One" }]);
});
it("returns an empty list when no sibling has a live session", () => {
const store = new FakeStorage();
seedDirectory(store);
store.setItem(`pp:${NS_PORTAL}:session.token`, "tok-portal");
expect(signedInInstances({ values: { storageNamespace: NS_PORTAL } }, store)).toEqual([]);
});
it("sorts targets by name", () => {
const store = new FakeStorage();
seedDirectory(store);
store.setItem(`pp:${NS_P1}:session.token`, "tok-p1");
store.setItem(`pp:${NS_P2}:session.token`, "tok-p2");
const names = signedInInstances({ values: { storageNamespace: NS_PORTAL } }, store).map((t) => t.name);
expect(names).toEqual(["Pro One", "Pro Two"]);
describe("listReachableInstances", () => {
it("returns peer instances excluding the current namespace", () => {
const store = new StorageShim();
seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/", title: "Pro One" });
seedInstance(store, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" });
const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store });
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ namespace: "ns-pro-2", url: "https://pro-2.example.com/", title: "Pro Two" });
});
it("falls back to the url when no title is recorded", () => {
const store = new StorageShim();
seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" });
seedInstance(store, "ns-pro-2", { url: "https://pro-2.example.com/" });
const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store });
expect(result[0].title).toBe("https://pro-2.example.com/");
});
it("ignores namespaces that have a session but no recorded identity", () => {
const store = new StorageShim();
seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" });
seedInstance(store, "ns-pro-2", { token: "tok2" }); // session only, no identity
const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store });
expect(result).toHaveLength(0);
});
it("ignores namespaces that have an identity but no live session", () => {
const store = new StorageShim();
seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" });
seedInstance(store, "ns-pro-2", { token: "", url: "https://pro-2.example.com/" }); // identity only
const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store });
expect(result).toHaveLength(0);
});
it("returns an empty array for a standalone deployment", () => {
const store = new StorageShim();
seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" });
const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store });
expect(result).toEqual([]);
});
it("de-duplicates a namespace seen more than once", () => {
const store = new StorageShim();
seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" });
seedInstance(store, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" });
store.setItem(buildNamespace("ns-pro-2") + "session.token", "tok-dup");
const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store });
expect(result).toHaveLength(1);
});
it("discovers ephemeral peers from sessionStorage as well as localStorage", () => {
const local = new StorageShim();
const session = new StorageShim();
seedInstance(local, "ns-pro-1", { url: "https://pro-1.example.com/", title: "Pro One" });
seedInstance(session, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" });
const result = listReachableInstances({ currentNamespace: "ns-pro-1", stores: [local, session] });
expect(result.map((i) => i.namespace)).toEqual(["ns-pro-2"]);
});
it("de-duplicates a namespace present in both storage backends", () => {
const local = new StorageShim();
const session = new StorageShim();
seedInstance(local, "ns-pro-1", { url: "https://pro-1.example.com/" });
seedInstance(local, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" });
seedInstance(session, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" });
const result = listReachableInstances({ currentNamespace: "ns-pro-1", stores: [local, session] });
expect(result).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// PUserMenu derives its Switch Instance entries from listReachableInstances; mock
// it so the menu logic can be exercised without seeding browser storage.
vi.mock("common/instances", () => ({
listReachableInstances: vi.fn(),
}));
import { listReachableInstances } from "common/instances";
import UserMenu from "component/navigation/user-menu.vue";
describe("component/navigation/user-menu", () => {
beforeEach(() => {
listReachableInstances.mockReset();
});
describe("refresh", () => {
it("loads reachable peers using the current namespace", () => {
const peers = [{ namespace: "ns-pro-2", url: "https://pro-2.example.com/", title: "Pro Two" }];
listReachableInstances.mockReturnValue(peers);
const ctx = { $config: { values: { storageNamespace: "ns-pro-1" } }, instances: [] };
UserMenu.methods.refresh.call(ctx);
expect(listReachableInstances).toHaveBeenCalledWith({ currentNamespace: "ns-pro-1" });
expect(ctx.instances).toEqual(peers);
});
it("yields an empty list when no peers are reachable, hiding the switcher", () => {
listReachableInstances.mockReturnValue([]);
const ctx = { $config: { values: { storageNamespace: "ns-pro-1" } }, instances: [{ namespace: "stale" }] };
UserMenu.methods.refresh.call(ctx);
expect(ctx.instances).toEqual([]);
});
});
describe("onToggle", () => {
it("refreshes the peer list when the menu opens", () => {
const refresh = vi.fn();
UserMenu.methods.onToggle.call({ refresh }, true);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("does not refresh when the menu closes", () => {
const refresh = vi.fn();
UserMenu.methods.onToggle.call({ refresh }, false);
expect(refresh).not.toHaveBeenCalled();
});
});
describe("onSwitch", () => {
it("navigates to the selected instance url", () => {
const navigate = vi.fn();
UserMenu.methods.onSwitch.call({ navigate }, { url: "https://pro-2.example.com/" });
expect(navigate).toHaveBeenCalledWith("https://pro-2.example.com/");
});
it("does nothing for an entry without a url", () => {
const navigate = vi.fn();
UserMenu.methods.onSwitch.call({ navigate }, {});
expect(navigate).not.toHaveBeenCalled();
});
});
});