Frontend: Refine the navigation auth menu and instance switcher

Show the instance app icon and base path in the switcher, persisting the icon
alongside the SiteUrl and title per namespace (default to the logo when none is
set). Add a Manage Account item gated on the account feature, and open the menu
on hover like the other action menus.
This commit is contained in:
Michael Mayer 2026-06-09 00:41:43 +00:00
parent c3c75dad18
commit 427313c6c7
8 changed files with 161 additions and 40 deletions

View file

@ -31,10 +31,11 @@ import { listAuthSessions, buildNamespace } from "common/storage";
// URL and title must be persisted explicitly under the instance's namespace.
const InstanceUrlKey = "instance.url";
const InstanceTitleKey = "instance.title";
const InstanceIconKey = "instance.icon";
// 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];
export const InstanceIdentityKeys = [InstanceUrlKey, InstanceTitleKey, InstanceIconKey];
// safeWindow returns the browser window if available, else null.
const safeWindow = () => (typeof window === "undefined" ? null : window);
@ -73,9 +74,24 @@ export function instanceLabel(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.
// instancePath returns the base path of a SiteUrl (e.g. "/i/pro-1") so the
// switcher can show how same-origin peers differ without repeating the shared
// origin. Returns "/" for a root install and "" for an unparseable URL.
export function instancePath(siteUrl) {
if (!siteUrl || typeof siteUrl !== "string") {
return "";
}
try {
const path = new URL(siteUrl).pathname.replace(/\/+$/, "");
return path || "/";
} catch {
return "";
}
}
// persistInstanceIdentity records this instance's SiteUrl, display title, and app
// icon 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;
@ -88,6 +104,12 @@ export function persistInstanceIdentity(store, identity) {
} else {
store.removeItem(InstanceTitleKey);
}
if (identity.icon) {
store.setItem(InstanceIconKey, identity.icon);
} else {
store.removeItem(InstanceIconKey);
}
}
// listReachableInstances returns the instances (other than currentNamespace) that
@ -140,6 +162,7 @@ export function listReachableInstances(options) {
namespace,
url,
title: store.getItem(prefix + InstanceTitleKey) || url,
icon: store.getItem(prefix + InstanceIconKey) || "",
});
});
});

View file

@ -456,11 +456,13 @@ export default class Session {
}
}
// 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 stores this instance's SiteUrl, display title, and app
// icon 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; the icon is the root-relative app icon, which resolves on the
// shared origin from any peer. No-op when the site URL is unknown or storage is
// unavailable.
recordInstanceIdentity() {
const values = this.config?.values;
@ -471,6 +473,7 @@ export default class Session {
persistInstanceIdentity(this.storage, {
url: values.siteUrl,
title: instanceLabel(values.siteUrl) || values.siteTitle || values.name || values.siteUrl,
icon: this.config.getIcon(),
});
}

View file

@ -3,9 +3,10 @@
v-model="open"
location="top"
offset="6"
:min-width="240"
:min-width="250"
:z-index="2500"
class="nav-user-menu"
:open-on-hover="openOnHover"
class="p-action-menu action-menu nav-user-menu"
content-class="nav-user-menu__content"
@update:model-value="onToggle"
>
@ -14,37 +15,52 @@
<slot></slot>
</div>
</template>
<v-list slim nav density="compact" bg-color="navigation" class="nav-user-menu__list">
<v-list slim nav density="compact" bg-color="navigation" class="action-menu__list">
<template v-if="instances.length > 0">
<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"
:subtitle="instance.path"
:nav="true"
class="action-menu__item"
@click="onSwitch(instance)"
></v-list-item>
>
<template #prepend>
<img :src="instance.icon" :alt="instance.title" class="nav-user-menu__icon" @error="onIconError(instance)" />
</template>
</v-list-item>
<v-divider class="my-1"></v-divider>
</template>
<v-list-item prepend-icon="mdi-power" :title="$gettext('Sign Out')" base-color="danger" class="action-logout" @click="$emit('logout')"></v-list-item>
<v-list-item
v-if="$config.feature('account')"
prepend-icon="mdi-shield-account-variant"
:title="$gettext('Manage Account')"
class="action-menu__item action-account"
@click="$emit('account')"
></v-list-item>
<v-list-item prepend-icon="mdi-power" :title="$gettext('Sign Out')" class="action-menu__item action-logout" @click="$emit('logout')"></v-list-item>
</v-list>
</v-menu>
</template>
<script>
import { listReachableInstances } from "common/instances";
import { listReachableInstances, instancePath } from "common/instances";
// PAuthMenu is the navigation avatar overlay menu: Sign Out and when more than
// one instance is reachable on a shared domain a Switch Instance list. Settings
// is reached via the sidebar, so it is intentionally not duplicated here.
// defaultIcon is shown when a peer has no recorded app icon, or its icon fails to
// load; it is the bundled PhotoPrism logo served at the origin root.
const defaultIcon = "/static/icons/logo.svg";
// PAuthMenu is the navigation avatar overlay menu: Manage Account, Sign Out, and
// when more than one instance is reachable on a shared domain a Switch Instance
// list. It emits "account" and "logout" for the host navigation to handle.
export default {
name: "PAuthMenu",
emits: ["logout"],
emits: ["logout", "account"],
data() {
return {
open: false,
openOnHover: !this.$util.hasTouch(),
instances: [],
};
},
@ -52,10 +68,15 @@ export default {
this.refresh();
},
methods: {
// refresh rescans shared localStorage for reachable peer instances.
// refresh rescans shared localStorage for reachable peer instances, adding a
// display path (the SiteUrl's base path) for the menu subtitle.
refresh() {
const namespace = this.$config?.values?.storageNamespace;
this.instances = listReachableInstances({ currentNamespace: namespace });
this.instances = listReachableInstances({ currentNamespace: namespace }).map((instance) => ({
...instance,
path: instancePath(instance.url),
icon: instance.icon || defaultIcon,
}));
},
// onToggle rescans peers each time the menu opens so the switcher stays current.
onToggle(open) {
@ -74,6 +95,14 @@ export default {
navigate(url) {
window.location.assign(url);
},
// onIconError falls back to the default logo when an instance's recorded app
// icon fails to load (e.g. a removed asset or one served cross-origin). The
// guard avoids an error loop if the default logo itself fails.
onIconError(instance) {
if (instance.icon !== defaultIcon) {
instance.icon = defaultIcon;
}
},
},
};
</script>

View file

@ -641,7 +641,7 @@
<div v-show="auth && !isPublic && !disconnected" class="nav-info user-info">
<div class="nav-info__underlay"></div>
<p-auth-menu @logout="onLogout">
<p-auth-menu @account="onAccount" @logout="onLogout">
<div class="nav-user-avatar text-center my-1 mx-2">
<img :src="userAvatarURL" :alt="accountInfo" :title="accountInfo" class="rounded-circle" />
</div>
@ -666,7 +666,7 @@
<div id="mobile-menu" :class="{ active: speedDial }" @click.stop="speedDial = false">
<div class="menu-content grow-top-end">
<div class="menu-icons">
<a v-if="auth && !isPublic" href="#" :title="$gettext('Logout')" class="menu-action navigation-logout" @click.prevent="onLogout">
<a v-if="auth && !isPublic" href="#" :title="$gettext('Sign Out')" class="menu-action navigation-logout" @click.prevent="onLogout">
<v-icon>mdi-power</v-icon>
</a>
<a href="#" :title="$gettext('Reload')" class="menu-action nav-reload" @click.prevent="reloadApp">
@ -949,6 +949,11 @@ export default {
this.$router.push({ name: "about" });
}
},
// onAccount opens the account settings tab, falling back to general settings
// when the account feature is unavailable.
onAccount() {
this.$router.push({ name: this.$config.feature("account") ? "settings_account" : "settings" });
},
onLogout() {
this.$session.logout();
},

View file

@ -256,6 +256,12 @@ table td > .action-buttons {
padding: 8px;
}
.nav-user-menu__icon {
width: 24px;
height: 24px;
object-fit: contain;
}
.v-menu.action-menu .action-menu__list .action-menu__shortcut {
margin-inline-start: 0.75rem;
opacity: 0.5;

View file

@ -1,10 +1,10 @@
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";
import { persistInstanceIdentity, listReachableInstances, InstanceIdentityKeys, instanceLabel, instancePath } from "common/instances";
// seedInstance writes a peer instance's session token and identity into a shared store.
const seedInstance = (store, namespace, { token = "tok", url, title } = {}) => {
const seedInstance = (store, namespace, { token = "tok", url, title, icon } = {}) => {
const prefix = buildNamespace(namespace);
if (token) {
store.setItem(prefix + "session.token", token);
@ -15,6 +15,9 @@ const seedInstance = (store, namespace, { token = "tok", url, title } = {}) => {
if (title) {
store.setItem(prefix + "instance.title", title);
}
if (icon) {
store.setItem(prefix + "instance.icon", icon);
}
};
describe("common/instances", () => {
@ -31,22 +34,37 @@ describe("common/instances", () => {
});
});
describe("instancePath", () => {
it("returns the base path of a SiteUrl without a trailing slash", () => {
expect(instancePath("https://app.example.com/i/pro-1/")).toBe("/i/pro-1");
expect(instancePath("https://app.example.com/i/pro-2")).toBe("/i/pro-2");
});
it("returns / for a root install and '' for an unparseable url", () => {
expect(instancePath("https://app.example.com/")).toBe("/");
expect(instancePath("not a url")).toBe("");
expect(instancePath("")).toBe("");
expect(instancePath(null)).toBe("");
});
});
describe("persistInstanceIdentity", () => {
it("writes url and title under the namespace", () => {
it("writes url, title, and icon 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" });
persistInstanceIdentity(ns, { url: "https://pro-1.example.com/", title: "Pro One", icon: "/static/icons/logo.svg" });
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");
expect(store.getItem(prefix + "instance.icon")).toBe("/static/icons/logo.svg");
});
it("clears a stale title when none is provided", () => {
it("clears a stale title or icon 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/", title: "Pro One", icon: "/static/icons/logo.svg" });
persistInstanceIdentity(ns, { url: "https://pro-1.example.com/" });
const prefix = buildNamespace("ns-pro-1");
expect(store.getItem(prefix + "instance.title")).toBeNull();
expect(store.getItem(prefix + "instance.icon")).toBeNull();
});
it("is a no-op without a url", () => {
const store = new StorageShim();
@ -60,6 +78,7 @@ describe("common/instances", () => {
it("exposes the identity suffix keys for cleanup", () => {
expect(InstanceIdentityKeys).toContain("instance.url");
expect(InstanceIdentityKeys).toContain("instance.title");
expect(InstanceIdentityKeys).toContain("instance.icon");
});
});
@ -67,10 +86,10 @@ describe("common/instances", () => {
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" });
seedInstance(store, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two", icon: "/i/pro-2/static/icons/logo.svg" });
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" });
expect(result[0]).toMatchObject({ namespace: "ns-pro-2", url: "https://pro-2.example.com/", title: "Pro Two", icon: "/i/pro-2/static/icons/logo.svg" });
});
it("falls back to the url when no title is recorded", () => {
const store = new StorageShim();

View file

@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// PAuthMenu derives its Switch Instance entries from listReachableInstances; mock
// it so the menu logic can be exercised without seeding browser storage.
vi.mock("common/instances", () => ({
// it so the menu logic can be exercised without seeding browser storage, while
// keeping the real instancePath used to build the subtitle.
vi.mock("common/instances", async (importActual) => ({
...(await importActual()),
listReachableInstances: vi.fn(),
}));
@ -15,13 +17,20 @@ describe("component/auth/menu", () => {
});
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" }];
it("loads reachable peers and adds the base-path subtitle", () => {
const peers = [{ namespace: "ns-pro-2", url: "https://app.example.com/i/pro-2", title: "pro-2", icon: "/i/pro-2/static/icons/logo.svg" }];
listReachableInstances.mockReturnValue(peers);
const ctx = { $config: { values: { storageNamespace: "ns-pro-1" } }, instances: [] };
AuthMenu.methods.refresh.call(ctx);
expect(listReachableInstances).toHaveBeenCalledWith({ currentNamespace: "ns-pro-1" });
expect(ctx.instances).toEqual(peers);
expect(ctx.instances).toEqual([{ ...peers[0], path: "/i/pro-2" }]);
});
it("defaults a missing instance icon to the logo", () => {
const peers = [{ namespace: "ns-pro-3", url: "https://app.example.com/i/pro-3", title: "pro-3", icon: "" }];
listReachableInstances.mockReturnValue(peers);
const ctx = { $config: { values: { storageNamespace: "ns-pro-1" } }, instances: [] };
AuthMenu.methods.refresh.call(ctx);
expect(ctx.instances[0].icon).toBe("/static/icons/logo.svg");
});
it("yields an empty list when no peers are reachable, hiding the switcher", () => {
listReachableInstances.mockReturnValue([]);
@ -56,4 +65,17 @@ describe("component/auth/menu", () => {
expect(navigate).not.toHaveBeenCalled();
});
});
describe("onIconError", () => {
it("falls back to the default logo when a recorded icon fails to load", () => {
const instance = { namespace: "ns-pro-2", icon: "/i/pro-2/static/icons/logo.svg" };
AuthMenu.methods.onIconError.call({}, instance);
expect(instance.icon).toBe("/static/icons/logo.svg");
});
it("does not reassign when the default logo itself fails", () => {
const instance = { icon: "/static/icons/logo.svg" };
AuthMenu.methods.onIconError.call({}, instance);
expect(instance.icon).toBe("/static/icons/logo.svg");
});
});
});

View file

@ -263,6 +263,20 @@ describe("component/navigation", () => {
});
});
describe("account navigation", () => {
it("onAccount routes to account settings when the account feature is enabled", () => {
const { wrapper, push } = mountNavigation({ featureOverrides: { account: true } });
wrapper.vm.onAccount();
expect(push).toHaveBeenCalledWith({ name: "settings_account" });
});
it("onAccount falls back to general settings when the account feature is disabled", () => {
const { wrapper, push } = mountNavigation({ featureOverrides: { account: false } });
wrapper.vm.onAccount();
expect(push).toHaveBeenCalledWith({ name: "settings" });
});
});
describe("home and upload actions", () => {
it("onHome toggles drawer on small screens and does not navigate", () => {
const { wrapper, push } = mountNavigation({