mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-18 00:59:38 +00:00
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:
parent
368ef7912b
commit
dffb81ab31
9 changed files with 415 additions and 277 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
78
frontend/src/component/navigation/user-menu.vue
Normal file
78
frontend/src/component/navigation/user-menu.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
59
frontend/tests/vitest/component/user-menu.test.js
Normal file
59
frontend/tests/vitest/component/user-menu.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue