openvibers/openvibe.shared Commits
Navbar: switching accounts works on every site (through the Network's chooser and back through the site's sign-in); Add another account signs the site in (#119)
Changes
@@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## 4.13.0 — 2026-10-26++- **Switching accounts works on every site** (`navbar.js`; the owner, 2026-10-26: "the thing that lets you switch …++ between different accounts doesn't work"). On a product site the account menu's other accounts dispatched an event only++ openvibe.network's account switcher listens to, so a click did nothing, and "Add another account" went to the Network++ and back to the page without signing the site in. Each other account is now a link through OpenVibe.Network's chooser++ (`/login?switch_to=<id>&login_hint=<username>`) that comes back through the site's own sign-in (its `loginUrl`, made++ absolute), so the site ends up signed in as the chosen account whatever its session; "Add another account"++ (`add_account=1`) does the same for a new one. The list is the accounts this browser used on the site (the account++ switcher's plus the ones the navbar saw signed in, `openvibe_seen_accounts`: names and pictures, at most ten, never a++ token). openvibe.network's own pages still switch in place; "Switch to Anonymous" shows only there. Account names in++ the menu are escaped as text. Needs OpenVibe.Network's chooser of the same night (switch_to, add_account).++ `test/navbar-switch-account.test.js`.- **A person's stuff on any site** (`profile-panel.js`, `profile-panel.css`, new). One tag (`data-ov-profile="name"`)draws someone's worn items, their counts of items, badges, achievements and games, their latest unlock per game andlinks to their profile and inventory, from OpenVibe.Network's public profile (which gathers Inventory, Quest and@@@ -1010,8 +1010,37 @@],};++ // Accounts seen here (no tokens).++ const SEEN_KEY = 'openvibe_seen_accounts';++ function readList(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }function getAccounts() {-- try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }++ const ids = new Set();++ return readList('openvibe_accounts').concat(readList(SEEN_KEY)).filter((a) => {++ if (!a || a.id == null || ids.has(String(a.id))) return false;++ ids.add(String(a.id));++ return true;++ });++ }++ function rememberAccount(u) {++ if (!u || u.is_anon || u.id == null || !u.username) return;++ try {++ const seen = readList(SEEN_KEY).filter((a) => a && String(a.id) !== String(u.id));++ seen.unshift({ id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null });++ localStorage.setItem(SEEN_KEY, JSON.stringify(seen.slice(0, 10)));++ } catch { /* storage unavailable */ }++ }++ function signInBack() {++ if (onNetworkDomain()) return window.location.href;++ try { return new URL(resolveLoginHref(currentHost(), window.location.href), window.location.href).toString(); }++ catch { return window.location.href; }++ }++ function switchHref(account) {++ const q = new URLSearchParams({ switch_to: String(account.id), return: signInBack() });++ if (account.username) q.set('login_hint', String(account.username));++ return `${_config.apiBase}/login?${q.toString()}`;++ }++ function addAccountHref() {++ return `${_config.apiBase}/login?${new URLSearchParams({ add_account: '1', return: signInBack() }).toString()}`;}// ─── SSO auth resolution ───────────────────────────────────@@@ -1336,6 +1365,9 @@function escapeAttr(value) {return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');}++ function escapeHtml(value) {++ return String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++ }function getAvatarInitial(user) {const source = user?.display_name || user?.username || 'O';@@@ -1430,10 +1462,11 @@const links = currentLinks();const u = _config.user;++ rememberAccount(u);const accounts = getAccounts();const isAnon = u && u.is_anon;const loginHref = resolveLoginHref(currentHost(), window.location.href);-- const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;++ const switcher = !!root.OpenVibeAccountSwitcher; // in place on openvibe.network// The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag.if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {@@@ -1481,24 +1514,23 @@<div class="openvibe-navbar-dropdown-header"><a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a><div class="info">-- <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeAttr(u.display_name || u.username)}</a>-- <div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>++ <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeHtml(u.display_name || u.username)}</a>++ <div class="email">${isAnon ? '' : '@' + escapeHtml(u.username || '')}</div>${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}</div></div><div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>-- ${otherAccounts.map(a => `-- <div class="account-item" data-account-id="${a.id}">++ ${otherAccounts.filter(a => switcher || !a.is_anon).map(a => { const t = switcher ? 'div' : 'a'; return `++ <${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}"` : `href="${escapeAttr(switchHref(a))}"`}>${avatarImg(a, 48, '', '')}-- <span>${a.display_name || a.username}${a.is_anon ? ' (anon)' : ''}</span>-- </div>-- `).join('')}-- <div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">++ <span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span>++ </${t}>`; }).join('')}++ ${switcher ? `<div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}"><span style="width:18px;text-align:center">${navIcon('fa-user-secret')}</span><span>Switch to Anonymous</span>-- </div>-- <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">++ </div>` : ''}++ <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref())}"><span style="width:18px;text-align:center">${navIcon('fa-plus')}</span><span>Add another account</span></a>@@@ -1554,7 +1586,6 @@if (!nav.contains(e.target)) dropdown.classList.remove('open');}, onDocument());-- // Account switchingdropdown.querySelectorAll('[data-account-id]').forEach(el => {el.addEventListener('click', () => {const id = el.dataset.accountId;@@@ -73,6 +73,7 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };const out = html(created);for (const name of own) {if (name === 'fa-coins') continue; // wallet chip, drawn when the menu first opens (below)++ if (name === 'fa-user-secret') continue; // "Switch to Anonymous": only where openvibe.network's account switcher runsassert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);}assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++// Switching accounts from any site's menu (owner, 2026-10-26: "the thing that lets you switch … between different++// accounts doesn't work"). On a product site the menu's accounts dispatched an event only openvibe.network's account++// switcher listened to, so a click did nothing; "Add another account" went to the Network and back to the page without++// ever signing this site in. Now:++// - the menu lists the accounts this browser used here (the switcher's and the ones the navbar saw signed in, no++// tokens), and each is a link: the Network makes it the active account (switch_to, login_hint) and comes back++// through this site's own sign-in (its loginUrl, made absolute), which signs the site in as it;++// - "Add another account" goes the same way (add_account), so the site ends up signed in as the new account;++// - on openvibe.network's own pages, where the account switcher is loaded, accounts still switch in place;++// - "Switch to Anonymous" shows only where something handles it, and names are escaped.++const assert = require('assert');++const path = require('path');++++const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');++++function stubElement(made) {++ const el = {++ style: {}, textContent: '', className: '', id: '', innerHTML: '', dataset: {},++ classList: { add() {}, remove() {}, toggle() {}, contains: () => false },++ appendChild() {}, append() {}, prepend() {}, remove() {}, setAttribute() {}, addEventListener() {},++ querySelector() { return stubElement(); }, querySelectorAll() { return []; },++ };++ if (made) made.push(el);++ return el;++}++++function loadNavbar({ hostname, href, storage = {}, switcher = false }) {++ const made = [];++ global.location = { hostname, href, protocol: 'https:', origin: `https://${hostname}`, search: '' };++ global.window = { location: global.location };++ // The navbar's root is the global object (window in a browser).++ if (switcher) { global.window.OpenVibeAccountSwitcher = {}; global.OpenVibeAccountSwitcher = {}; } else delete global.OpenVibeAccountSwitcher;++ global.document = {++ cookie: '', head: stubElement(), body: stubElement(), getElementById: () => null, querySelector: () => null,++ createElement: () => stubElement(made), addEventListener() {}, removeEventListener() {}, dispatchEvent() {},++ };++ global.CustomEvent = class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } };++ const store = { ...storage };++ global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } };++ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };++ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });++ delete require.cache[require.resolve(NAVBAR_PATH)];++ const navbar = require(NAVBAR_PATH);++ return { navbar, made, store };++}++++const ME = { id: 7, username: 'goosely', display_name: 'goosely', avatar_url: null, role: 'user' };++const ALT = { id: 8, username: 'alt', display_name: 'Alt <img src=x onerror=alert(1)>', avatar_url: null };++const unescape = (s) => s.replace(/&/g, '&');++const hrefs = (html) => [...html.matchAll(/href="([^"]+)"/g)].map((m) => unescape(m[1]));++++(async () => {++ // ── A product site: links through the Network and back through the site's own sign-in ──++ {++ const { navbar, made, store } = loadNavbar({++ hostname: 'openvibe.video', href: 'https://openvibe.video/v/12?t=30',++ storage: { openvibe_seen_accounts: JSON.stringify([ALT]) },++ });++ navbar.init({ service: 'video', apiBase: 'https://openvibe.network', user: ME, loginUrl: '/auth/login?next={url}' });++ await new Promise((r) => setImmediate(r));++ const menu = made.find((e) => e.className === 'openvibe-navbar-dropdown');++ assert.ok(menu, 'the account menu renders');++ const links = hrefs(menu.innerHTML);++++ const sw = links.find((h) => h.includes('switch_to='));++ assert.ok(sw, 'the other account is a link');++ const su = new URL(sw);++ assert.strictEqual(su.origin + su.pathname, 'https://openvibe.network/login');++ assert.strictEqual(su.searchParams.get('switch_to'), '8');++ assert.strictEqual(su.searchParams.get('login_hint'), 'alt');++ const back = new URL(su.searchParams.get('return'));++ assert.strictEqual(back.origin + back.pathname, 'https://openvibe.video/auth/login', "it comes back through the site's sign-in");++ assert.strictEqual(back.searchParams.get('next'), 'https://openvibe.video/v/12?t=30', 'and then to this page');++++ const add = new URL(links.find((h) => h.includes('add_account=')));++ assert.strictEqual(add.searchParams.get('add_account'), '1');++ assert.strictEqual(new URL(add.searchParams.get('return')).pathname, '/auth/login', 'a new account signs this site in too');++++ assert.ok(!/data-account-id/.test(menu.innerHTML), 'no in-place switch items without the account switcher');++ assert.ok(!/Switch to Anonymous/.test(menu.innerHTML), 'nothing handles anonymous here');++ assert.ok(/<span>Alt <img src=x onerror=alert\(1\)><\/span>/.test(menu.innerHTML), 'names are text, never markup');++ assert.ok(!links.some((h) => h.includes('switch_to=7')), 'the current account is not offered');++++ const seen = JSON.parse(store.openvibe_seen_accounts);++ assert.deepStrictEqual(seen.map((a) => a.id), [7, 8], 'the account signed in here is remembered first');++ assert.ok(seen.every((a) => !('token' in a)), 'never a token');++ }++++ // ── Many seen accounts: at most ten, and anonymous visitors are never remembered ──++ {++ const many = Array.from({ length: 12 }, (_, i) => ({ id: 100 + i, username: `u${i}` }));++ const { navbar, store } = loadNavbar({ hostname: 'openvibe.food', href: 'https://openvibe.food/', storage: { openvibe_seen_accounts: JSON.stringify(many) } });++ navbar.init({ service: 'food', apiBase: 'https://openvibe.network', user: ME, loginUrl: '/auth/login?next={path}' });++ await new Promise((r) => setImmediate(r));++ assert.strictEqual(JSON.parse(store.openvibe_seen_accounts).length, 10);++ const anon = loadNavbar({ hostname: 'openvibe.food', href: 'https://openvibe.food/' });++ anon.navbar.init({ service: 'food', apiBase: 'https://openvibe.network', user: { id: 'anon_3', is_anon: true, anon_number: 3 }, loginUrl: '/auth/login?next={path}' });++ await new Promise((r) => setImmediate(r));++ assert.strictEqual(anon.store.openvibe_seen_accounts, undefined);++ }++++ // ── openvibe.network's own pages: the account switcher switches in place ──++ {++ const { navbar, made } = loadNavbar({++ hostname: 'openvibe.network', href: 'https://openvibe.network/my', switcher: true,++ storage: { openvibe_accounts: JSON.stringify([{ ...ALT, token: 't' }]) },++ });++ navbar.init({ service: 'network', apiBase: 'https://openvibe.network', user: ME });++ await new Promise((r) => setImmediate(r));++ const menu = made.find((e) => e.className === 'openvibe-navbar-dropdown');++ assert.ok(/data-account-id="8"/.test(menu.innerHTML), 'in place');++ assert.ok(/Switch to Anonymous/.test(menu.innerHTML));++ const add = new URL(hrefs(menu.innerHTML).find((h) => h.includes('add_account=')));++ assert.strictEqual(add.searchParams.get('return'), 'https://openvibe.network/my', 'on the Network the page itself is the sign-in');++ }++++ console.log('navbar-switch-account: all tests passed');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## 4.13.0 — 2026-10-26
## 4.13.0 — 2026-10-26
+- **Switching accounts works on every site** (`navbar.js`; the owner, 2026-10-26: "the thing that lets you switch …
+ between different accounts doesn't work"). On a product site the account menu's other accounts dispatched an event only
+ openvibe.network's account switcher listens to, so a click did nothing, and "Add another account" went to the Network
+ and back to the page without signing the site in. Each other account is now a link through OpenVibe.Network's chooser
+ (`/login?switch_to=<id>&login_hint=<username>`) that comes back through the site's own sign-in (its `loginUrl`, made
+ absolute), so the site ends up signed in as the chosen account whatever its session; "Add another account"
+ (`add_account=1`) does the same for a new one. The list is the accounts this browser used on the site (the account
+ switcher's plus the ones the navbar saw signed in, `openvibe_seen_accounts`: names and pictures, at most ten, never a
+ token). openvibe.network's own pages still switch in place; "Switch to Anonymous" shows only there. Account names in
+ the menu are escaped as text. Needs OpenVibe.Network's chooser of the same night (switch_to, add_account).
+ `test/navbar-switch-account.test.js`.
- **A person's stuff on any site** (`profile-panel.js`, `profile-panel.css`, new). One tag (`data-ov-profile="name"`)
- **A person's stuff on any site** (`profile-panel.js`, `profile-panel.css`, new). One tag (`data-ov-profile="name"`)
draws someone's worn items, their counts of items, badges, achievements and games, their latest unlock per game and
draws someone's worn items, their counts of items, badges, achievements and games, their latest unlock per game and
links to their profile and inventory, from OpenVibe.Network's public profile (which gathers Inventory, Quest and
links to their profile and inventory, from OpenVibe.Network's public profile (which gathers Inventory, Quest and
@@ -1010,8 +1010,37 @@
@@ -1010,8 +1010,37 @@
],
],
};
};
+ // Accounts seen here (no tokens).
+ const SEEN_KEY = 'openvibe_seen_accounts';
+ function readList(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }
function getAccounts() {
function getAccounts() {
- try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
+ const ids = new Set();
+ return readList('openvibe_accounts').concat(readList(SEEN_KEY)).filter((a) => {
+ if (!a || a.id == null || ids.has(String(a.id))) return false;
+ ids.add(String(a.id));
+ return true;
+ });
+ }
+ function rememberAccount(u) {
+ if (!u || u.is_anon || u.id == null || !u.username) return;
+ try {
+ const seen = readList(SEEN_KEY).filter((a) => a && String(a.id) !== String(u.id));
+ seen.unshift({ id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null });
+ localStorage.setItem(SEEN_KEY, JSON.stringify(seen.slice(0, 10)));
+ } catch { /* storage unavailable */ }
+ }
+ function signInBack() {
+ if (onNetworkDomain()) return window.location.href;
+ try { return new URL(resolveLoginHref(currentHost(), window.location.href), window.location.href).toString(); }
+ catch { return window.location.href; }
+ }
+ function switchHref(account) {
+ const q = new URLSearchParams({ switch_to: String(account.id), return: signInBack() });
+ if (account.username) q.set('login_hint', String(account.username));
+ return `${_config.apiBase}/login?${q.toString()}`;
+ }
+ function addAccountHref() {
+ return `${_config.apiBase}/login?${new URLSearchParams({ add_account: '1', return: signInBack() }).toString()}`;
}
}
// ─── SSO auth resolution ───────────────────────────────────
// ─── SSO auth resolution ───────────────────────────────────
@@ -1336,6 +1365,9 @@
@@ -1336,6 +1365,9 @@
function escapeAttr(value) {
function escapeAttr(value) {
return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
}
}
+ function escapeHtml(value) {
+ return String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+ }
function getAvatarInitial(user) {
function getAvatarInitial(user) {
const source = user?.display_name || user?.username || 'O';
const source = user?.display_name || user?.username || 'O';
@@ -1430,10 +1462,11 @@
@@ -1430,10 +1462,11 @@
const links = currentLinks();
const links = currentLinks();
const u = _config.user;
const u = _config.user;
+ rememberAccount(u);
const accounts = getAccounts();
const accounts = getAccounts();
const isAnon = u && u.is_anon;
const isAnon = u && u.is_anon;
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const loginHref = resolveLoginHref(currentHost(), window.location.href);
- const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
+ const switcher = !!root.OpenVibeAccountSwitcher; // in place on openvibe.network
// The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag.
// The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag.
if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {
if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {
@@ -1481,24 +1514,23 @@
@@ -1481,24 +1514,23 @@
<div class="openvibe-navbar-dropdown-header">
<div class="openvibe-navbar-dropdown-header">
<a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a>
<a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a>
<div class="info">
<div class="info">
- <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeAttr(u.display_name || u.username)}</a>
- <div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>
+ <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeHtml(u.display_name || u.username)}</a>
+ <div class="email">${isAnon ? '' : '@' + escapeHtml(u.username || '')}</div>
${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
</div>
</div>
</div>
</div>
<div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>
<div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>
- ${otherAccounts.map(a => `
- <div class="account-item" data-account-id="${a.id}">
+ ${otherAccounts.filter(a => switcher || !a.is_anon).map(a => { const t = switcher ? 'div' : 'a'; return `
+ <${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}"` : `href="${escapeAttr(switchHref(a))}"`}>
${avatarImg(a, 48, '', '')}
${avatarImg(a, 48, '', '')}
- <span>${a.display_name || a.username}${a.is_anon ? ' (anon)' : ''}</span>
- </div>
- `).join('')}
- <div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
+ <span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span>
+ </${t}>`; }).join('')}
+ ${switcher ? `<div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
<span style="width:18px;text-align:center">${navIcon('fa-user-secret')}</span>
<span style="width:18px;text-align:center">${navIcon('fa-user-secret')}</span>
<span>Switch to Anonymous</span>
<span>Switch to Anonymous</span>
- </div>
- <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">
+ </div>` : ''}
+ <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref())}">
<span style="width:18px;text-align:center">${navIcon('fa-plus')}</span>
<span style="width:18px;text-align:center">${navIcon('fa-plus')}</span>
<span>Add another account</span>
<span>Add another account</span>
</a>
</a>
@@ -1554,7 +1586,6 @@
@@ -1554,7 +1586,6 @@
if (!nav.contains(e.target)) dropdown.classList.remove('open');
if (!nav.contains(e.target)) dropdown.classList.remove('open');
}, onDocument());
}, onDocument());
- // Account switching
dropdown.querySelectorAll('[data-account-id]').forEach(el => {
dropdown.querySelectorAll('[data-account-id]').forEach(el => {
el.addEventListener('click', () => {
el.addEventListener('click', () => {
const id = el.dataset.accountId;
const id = el.dataset.accountId;
@@ -73,6 +73,7 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };
@@ -73,6 +73,7 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };
const out = html(created);
const out = html(created);
for (const name of own) {
for (const name of own) {
if (name === 'fa-coins') continue; // wallet chip, drawn when the menu first opens (below)
if (name === 'fa-coins') continue; // wallet chip, drawn when the menu first opens (below)
+ if (name === 'fa-user-secret') continue; // "Switch to Anonymous": only where openvibe.network's account switcher runs
assert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);
assert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);
}
}
assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');
assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+// Switching accounts from any site's menu (owner, 2026-10-26: "the thing that lets you switch … between different
+// accounts doesn't work"). On a product site the menu's accounts dispatched an event only openvibe.network's account
+// switcher listened to, so a click did nothing; "Add another account" went to the Network and back to the page without
+// ever signing this site in. Now:
+// - the menu lists the accounts this browser used here (the switcher's and the ones the navbar saw signed in, no
+// tokens), and each is a link: the Network makes it the active account (switch_to, login_hint) and comes back
+// through this site's own sign-in (its loginUrl, made absolute), which signs the site in as it;
+// - "Add another account" goes the same way (add_account), so the site ends up signed in as the new account;
+// - on openvibe.network's own pages, where the account switcher is loaded, accounts still switch in place;
+// - "Switch to Anonymous" shows only where something handles it, and names are escaped.
+const assert = require('assert');
+const path = require('path');
+
+const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');
+
+function stubElement(made) {
+ const el = {
+ style: {}, textContent: '', className: '', id: '', innerHTML: '', dataset: {},
+ classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
+ appendChild() {}, append() {}, prepend() {}, remove() {}, setAttribute() {}, addEventListener() {},
+ querySelector() { return stubElement(); }, querySelectorAll() { return []; },
+ };
+ if (made) made.push(el);
+ return el;
+}
+
+function loadNavbar({ hostname, href, storage = {}, switcher = false }) {
+ const made = [];
+ global.location = { hostname, href, protocol: 'https:', origin: `https://${hostname}`, search: '' };
+ global.window = { location: global.location };
+ // The navbar's root is the global object (window in a browser).
+ if (switcher) { global.window.OpenVibeAccountSwitcher = {}; global.OpenVibeAccountSwitcher = {}; } else delete global.OpenVibeAccountSwitcher;
+ global.document = {
+ cookie: '', head: stubElement(), body: stubElement(), getElementById: () => null, querySelector: () => null,
+ createElement: () => stubElement(made), addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
+ };
+ global.CustomEvent = class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } };
+ const store = { ...storage };
+ global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } };
+ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };
+ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
+ delete require.cache[require.resolve(NAVBAR_PATH)];
+ const navbar = require(NAVBAR_PATH);
+ return { navbar, made, store };
+}
+
+const ME = { id: 7, username: 'goosely', display_name: 'goosely', avatar_url: null, role: 'user' };
+const ALT = { id: 8, username: 'alt', display_name: 'Alt <img src=x onerror=alert(1)>', avatar_url: null };
+const unescape = (s) => s.replace(/&/g, '&');
+const hrefs = (html) => [...html.matchAll(/href="([^"]+)"/g)].map((m) => unescape(m[1]));
+
+(async () => {
+ // ── A product site: links through the Network and back through the site's own sign-in ──
+ {
+ const { navbar, made, store } = loadNavbar({
+ hostname: 'openvibe.video', href: 'https://openvibe.video/v/12?t=30',
+ storage: { openvibe_seen_accounts: JSON.stringify([ALT]) },
+ });
+ navbar.init({ service: 'video', apiBase: 'https://openvibe.network', user: ME, loginUrl: '/auth/login?next={url}' });
+ await new Promise((r) => setImmediate(r));
+ const menu = made.find((e) => e.className === 'openvibe-navbar-dropdown');
+ assert.ok(menu, 'the account menu renders');
+ const links = hrefs(menu.innerHTML);
+
+ const sw = links.find((h) => h.includes('switch_to='));
+ assert.ok(sw, 'the other account is a link');
+ const su = new URL(sw);
+ assert.strictEqual(su.origin + su.pathname, 'https://openvibe.network/login');
+ assert.strictEqual(su.searchParams.get('switch_to'), '8');
+ assert.strictEqual(su.searchParams.get('login_hint'), 'alt');
+ const back = new URL(su.searchParams.get('return'));
+ assert.strictEqual(back.origin + back.pathname, 'https://openvibe.video/auth/login', "it comes back through the site's sign-in");
+ assert.strictEqual(back.searchParams.get('next'), 'https://openvibe.video/v/12?t=30', 'and then to this page');
+
+ const add = new URL(links.find((h) => h.includes('add_account=')));
+ assert.strictEqual(add.searchParams.get('add_account'), '1');
+ assert.strictEqual(new URL(add.searchParams.get('return')).pathname, '/auth/login', 'a new account signs this site in too');
+
+ assert.ok(!/data-account-id/.test(menu.innerHTML), 'no in-place switch items without the account switcher');
+ assert.ok(!/Switch to Anonymous/.test(menu.innerHTML), 'nothing handles anonymous here');
+ assert.ok(/<span>Alt <img src=x onerror=alert\(1\)><\/span>/.test(menu.innerHTML), 'names are text, never markup');
+ assert.ok(!links.some((h) => h.includes('switch_to=7')), 'the current account is not offered');
+
+ const seen = JSON.parse(store.openvibe_seen_accounts);
+ assert.deepStrictEqual(seen.map((a) => a.id), [7, 8], 'the account signed in here is remembered first');
+ assert.ok(seen.every((a) => !('token' in a)), 'never a token');
+ }
+
+ // ── Many seen accounts: at most ten, and anonymous visitors are never remembered ──
+ {
+ const many = Array.from({ length: 12 }, (_, i) => ({ id: 100 + i, username: `u${i}` }));
+ const { navbar, store } = loadNavbar({ hostname: 'openvibe.food', href: 'https://openvibe.food/', storage: { openvibe_seen_accounts: JSON.stringify(many) } });
+ navbar.init({ service: 'food', apiBase: 'https://openvibe.network', user: ME, loginUrl: '/auth/login?next={path}' });
+ await new Promise((r) => setImmediate(r));
+ assert.strictEqual(JSON.parse(store.openvibe_seen_accounts).length, 10);
+ const anon = loadNavbar({ hostname: 'openvibe.food', href: 'https://openvibe.food/' });
+ anon.navbar.init({ service: 'food', apiBase: 'https://openvibe.network', user: { id: 'anon_3', is_anon: true, anon_number: 3 }, loginUrl: '/auth/login?next={path}' });
+ await new Promise((r) => setImmediate(r));
+ assert.strictEqual(anon.store.openvibe_seen_accounts, undefined);
+ }
+
+ // ── openvibe.network's own pages: the account switcher switches in place ──
+ {
+ const { navbar, made } = loadNavbar({
+ hostname: 'openvibe.network', href: 'https://openvibe.network/my', switcher: true,
+ storage: { openvibe_accounts: JSON.stringify([{ ...ALT, token: 't' }]) },
+ });
+ navbar.init({ service: 'network', apiBase: 'https://openvibe.network', user: ME });
+ await new Promise((r) => setImmediate(r));
+ const menu = made.find((e) => e.className === 'openvibe-navbar-dropdown');
+ assert.ok(/data-account-id="8"/.test(menu.innerHTML), 'in place');
+ assert.ok(/Switch to Anonymous/.test(menu.innerHTML));
+ const add = new URL(hrefs(menu.innerHTML).find((h) => h.includes('add_account=')));
+ assert.strictEqual(add.searchParams.get('return'), 'https://openvibe.network/my', 'on the Network the page itself is the sign-in');
+ }
+
+ console.log('navbar-switch-account: all tests passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });