openvibers/openvibe.shared account-switcher.js

account-switcher.js

main 30.1 KB · 705 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — Account Switcher
3// Google-style multi-account management with anonymous mode.
4// Persists sessions in localStorage, coordinates with server
5// for token swapping. Integrates with OpenVibeNavbar dropdown.
6// Usage: OpenVibeAccountSwitcher.init({ apiBase, onSwitch })
7// ═══════════════════════════════════════════════════════════════
8
9(function (root) {
10 'use strict';
11
12 const STORAGE_KEY = 'openvibe_accounts';
13 const TOKEN_KEY = 'ov_token';
14 const ACTIVE_KEY = 'openvibe_active_account';
15 const ANON_KEY = 'openvibe_anon_token';
16 const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>
17 const MAX_ACCOUNTS = 10;
18
19 let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
20 let _panelEl = null;
21
22 // The account hub is a redirect when signed out: it loads this file, opts into cross-document view transitions
23 // (kit.css) and then navigates to /login, which does not include the kit and so does not opt in. Chrome aborts
24 // the transition and leaves an uncaught InvalidStateError on the sign-in page ("ViewTransition opt-in disabled",
25 // 11-01). A replace navigation is that redirect, not a page move: skip its transition before it starts, and keep
26 // the transition's promises handled so a transition that cannot complete is silent either way.
27 function quietViewTransition(event) {
28 const transition = event && event.viewTransition;
29 if (!transition) return;
30 for (const promise of [transition.finished, transition.ready, transition.updateCallbackDone]) {
31 try { if (promise && typeof promise.catch === 'function') promise.catch(() => {}); } catch { /* not a promise */ }
32 }
33 const activation = event.activation;
34 if (!activation || activation.navigationType !== 'replace') return;
35 try { if (typeof transition.skipTransition === 'function') transition.skipTransition(); } catch { /* already gone */ }
36 }
37 if (typeof document !== 'undefined' && typeof root.addEventListener === 'function') {
38 root.addEventListener('pageswap', quietViewTransition);
39 root.addEventListener('pagereveal', quietViewTransition);
40 }
41
42 function isAnonId(id) {
43 return String(id) === 'anon' || String(id).startsWith('anon_');
44 }
45
46 /** Store of session tokens per anon identity */
47 function getAnonTokens() {
48 try { return JSON.parse(localStorage.getItem(ANON_TOKENS_KEY) || '{}'); } catch { return {}; }
49 }
50 function saveAnonTokens(tokens) {
51 localStorage.setItem(ANON_TOKENS_KEY, JSON.stringify(tokens));
52 }
53 function setAnonToken(anonId, token) {
54 const tokens = getAnonTokens();
55 tokens[anonId] = token;
56 saveAnonTokens(tokens);
57 }
58 function getAnonToken(anonId) {
59 return getAnonTokens()[anonId] || null;
60 }
61 function removeAnonToken(anonId) {
62 const tokens = getAnonTokens();
63 delete tokens[anonId];
64 saveAnonTokens(tokens);
65 }
66
67 // Cookie policy (CONTRACTS.md): ov_token is host-only everywhere EXCEPT on
68 // openvibe.tools + its subdomains, where a Domain=.openvibe.tools cookie is
69 // shared across the tool satellites.
70 function onToolsDomain() {
71 const host = (typeof location !== 'undefined' && location.hostname) || '';
72 return host === 'openvibe.tools' || host.endsWith('.openvibe.tools');
73 }
74
75 function clearAuthCookie() {
76 document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
77 if (onToolsDomain()) {
78 document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools;SameSite=Lax';
79 }
80 }
81
82 function setAuthCookie(token) {
83 const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
84 if (onToolsDomain()) {
85 document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};domain=.openvibe.tools;SameSite=Lax;Secure`;
86 } else {
87 document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};SameSite=Lax${secure}`;
88 }
89 }
90
91 function normalizeAccount(user, token) {
92 const isAnon = !!user?.is_anon || isAnonId(user?.id);
93 const anonNum = user.anon_number || null;
94 // Each anon gets a unique ID based on its anon_number
95 const id = isAnon ? (anonNum ? `anon_${anonNum}` : 'anon') : user.id;
96 return {
97 id,
98 username: user.username,
99 display_name: user.display_name || user.username,
100 avatar_url: user.avatar_url || null,
101 email: user.email || null,
102 is_anon: isAnon,
103 anon_number: anonNum,
104 token,
105 added_at: Date.now(),
106 };
107 }
108
109 function getAnonAccount(id) {
110 if (id) return getAccounts().find(a => a && a.is_anon && String(a.id) === String(id)) || null;
111 return getAccounts().find(a => a && a.is_anon) || null;
112 }
113
114 function getAnonAccounts() {
115 return getAccounts().filter(a => a && a.is_anon);
116 }
117
118 // ─── Account Store ─────────────────────────────────────────
119 function getAccounts() {
120 try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
121 }
122
123 function saveAccounts(accounts) {
124 localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts));
125 }
126
127 function getActiveId() {
128 return localStorage.getItem(ACTIVE_KEY);
129 }
130
131 function setActiveId(id) {
132 localStorage.setItem(ACTIVE_KEY, id);
133 }
134
135 function addAccount(user, token) {
136 const accounts = getAccounts();
137 const entry = normalizeAccount(user, token);
138 const existing = accounts.findIndex(a => String(a.id) === String(entry.id));
139 if (existing !== -1) {
140 accounts[existing] = entry;
141 } else {
142 if (accounts.length >= MAX_ACCOUNTS) {
143 accounts.shift(); // Remove oldest
144 }
145 accounts.push(entry);
146 }
147 saveAccounts(accounts);
148 return accounts;
149 }
150
151 function removeAccount(id) {
152 const targetId = String(id);
153 const accounts = getAccounts().filter(a => String(a.id) !== targetId);
154 saveAccounts(accounts);
155 // Clean up anon token if removing an anon identity
156 if (isAnonId(targetId)) removeAnonToken(targetId);
157 if (getActiveId() === targetId) {
158 const fallback = accounts.find(a => !a.is_anon) || accounts[0];
159 if (fallback) switchTo(fallback.id);
160 else logout();
161 }
162 return accounts;
163 }
164
165 /** Read ov_token from the cookie (host-only; Domain=.openvibe.tools on tools hosts) */
166 function getCookieToken() {
167 const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
168 return m ? decodeURIComponent(m[1]) : null;
169 }
170
171 // ─── API ───────────────────────────────────────────────────
172 async function apiFetch(path, opts = {}) {
173 const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
174 const headers = { 'Content-Type': 'application/json', ...opts.headers };
175 if (token) headers.Authorization = `Bearer ${token}`;
176 const res = await fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: 'include' });
177 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
178 return res.json();
179 }
180
181 async function switchTo(accountId) {
182 if (isAnonId(accountId)) return switchToAnon(accountId);
183
184 const accounts = getAccounts();
185 const target = accounts.find(a => String(a.id) === String(accountId));
186 if (target?.is_anon) return switchToAnon(accountId);
187 if (!target || !target.token) {
188 // Token expired — need re-login
189 window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
190 return;
191 }
192
193 // Validate token is still good
194 try {
195 const headers = { Authorization: `Bearer ${target.token}` };
196 const res = await fetch(`${_config.apiBase}/api/auth/me`, { headers });
197 if (!res.ok) throw new Error('Token invalid');
198 } catch {
199 // Token expired
200 removeAccount(target.id);
201 window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
202 return;
203 }
204
205 localStorage.setItem(TOKEN_KEY, target.token);
206 setActiveId(target.id);
207 setAuthCookie(target.token);
208
209 if (_config.onSwitch) _config.onSwitch(target);
210 else window.location.reload();
211 }
212
213 /**
214 * Switch to an anonymous identity. If anonId is provided, switch to that
215 * specific anon. Otherwise use the most recently active or create a new one.
216 */
217 async function switchToAnon(anonId) {
218 async function fetchAnonUser(token) {
219 const res = await fetch(`${_config.apiBase}/api/auth/anon/${encodeURIComponent(token)}`, { credentials: 'include' });
220 if (!res.ok) throw new Error('Anon session invalid');
221 const data = await res.json();
222 return data.user || null;
223 }
224
225 let anonToken = null;
226 let anonUser = null;
227
228 // If switching to a specific anon identity, look up its token
229 if (anonId && anonId !== 'anon') {
230 anonToken = getAnonToken(anonId);
231 }
232
233 // Fallback to legacy single-anon token
234 if (!anonToken) {
235 anonToken = localStorage.getItem(ANON_KEY);
236 }
237
238 // Try to validate the existing token
239 if (anonToken) {
240 try {
241 anonUser = await fetchAnonUser(anonToken);
242 } catch {
243 // Token invalid — clear it
244 if (anonId) removeAnonToken(anonId);
245 localStorage.removeItem(ANON_KEY);
246 anonToken = null;
247 }
248 }
249
250 // Create new anon session if we don't have a valid one
251 if (!anonToken) {
252 try {
253 const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
254 method: 'POST',
255 headers: { 'Content-Type': 'application/json' },
256 body: '{}',
257 credentials: 'include',
258 }).then(async (res) => {
259 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
260 return res.json();
261 });
262 anonToken = data.token;
263 anonUser = data.user || null;
264 } catch (err) {
265 console.error('[OpenVibeAccountSwitcher] Failed to create anon session:', err);
266 return;
267 }
268 }
269
270 if (!anonUser) {
271 try {
272 anonUser = await fetchAnonUser(anonToken);
273 } catch (err) {
274 console.error('[OpenVibeAccountSwitcher] Failed to load anon session:', err);
275 return;
276 }
277 }
278
279 // Store the anon account and its token
280 if (anonUser) {
281 const entry = addAccount(anonUser, anonToken);
282 const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
283 setAnonToken(realId, anonToken);
284 // Also keep legacy key for backward compat
285 localStorage.setItem(ANON_KEY, anonToken);
286
287 localStorage.removeItem(TOKEN_KEY);
288 setActiveId(realId);
289 clearAuthCookie();
290
291 if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number || null });
292 else window.location.reload();
293 }
294 }
295
296 /**
297 * Create a brand-new anonymous identity (even if one already exists).
298 * Useful for the "New Anon Identity" button.
299 */
300 async function createNewAnon() {
301 try {
302 const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
303 method: 'POST',
304 headers: { 'Content-Type': 'application/json' },
305 body: JSON.stringify({ force_new: true }),
306 credentials: 'include',
307 }).then(async (res) => {
308 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
309 return res.json();
310 });
311 const anonToken = data.token;
312 const anonUser = data.user;
313 if (!anonUser) throw new Error('No user returned');
314
315 const entry = addAccount(anonUser, anonToken);
316 const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
317 setAnonToken(realId, anonToken);
318
319 localStorage.removeItem(TOKEN_KEY);
320 localStorage.setItem(ANON_KEY, anonToken);
321 setActiveId(realId);
322 clearAuthCookie();
323
324 if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number });
325 else window.location.reload();
326 } catch (err) {
327 console.error('[OpenVibeAccountSwitcher] Failed to create new anon:', err);
328 }
329 }
330
331 /** Drop the server-held cookies (ov_sso is httpOnly, so only the server can). Best effort. */
332 function serverLogout() {
333 try { fetch(`${_config.apiBase}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch { /* */ }
334 try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
335 }
336
337 function logout() {
338 serverLogout();
339 const activeId = getActiveId();
340 if (activeId) removeAccount(activeId);
341 localStorage.removeItem(TOKEN_KEY);
342 localStorage.removeItem(ACTIVE_KEY);
343 clearAuthCookie();
344 if (_config.onSwitch) _config.onSwitch(null);
345 else window.location.reload();
346 }
347
348 function logoutAll() {
349 serverLogout();
350 localStorage.removeItem(STORAGE_KEY);
351 localStorage.removeItem(TOKEN_KEY);
352 localStorage.removeItem(ACTIVE_KEY);
353 localStorage.removeItem(ANON_KEY);
354 localStorage.removeItem(ANON_TOKENS_KEY);
355 clearAuthCookie();
356 if (_config.onSwitch) _config.onSwitch(null);
357 else window.location.reload();
358 }
359
360 // ─── Switcher Panel UI ─────────────────────────────────────
361 function injectStyles() {
362 if (document.getElementById('openvibe-switcher-styles')) return;
363 const s = document.createElement('style');
364 s.id = 'openvibe-switcher-styles';
365 s.textContent = `
366 .openvibe-switcher-overlay {
367 position: fixed; inset: 0; z-index: 20000;
368 background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
369 display: flex; align-items: center; justify-content: center;
370 animation: openvibe-sw-fadein .2s ease;
371 }
372 @keyframes openvibe-sw-fadein { from { opacity: 0; } to { opacity: 1; } }
373
374 .openvibe-switcher-panel {
375 width: 380px; max-height: 520px;
376 background: var(--bg-card, #22222c);
377 border: 1px solid var(--border, #333340); border-radius: 16px;
378 box-shadow: 0 20px 60px rgba(0,0,0,0.6);
379 overflow: hidden; animation: openvibe-sw-pop .25s cubic-bezier(.34,1.56,.64,1);
380 }
381 @keyframes openvibe-sw-pop { from { transform: scale(.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
382
383 .openvibe-switcher-header {
384 text-align: center; padding: 20px 16px 12px;
385 border-bottom: 1px solid var(--border, #333340);
386 }
387 .openvibe-switcher-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; color: var(--text-primary, #e0e0e0); }
388 .openvibe-switcher-header p { margin: 0; font-size: 12px; color: var(--text-muted, #707080); }
389
390 .openvibe-switcher-list { padding: 8px; max-height: 300px; overflow-y: auto; }
391
392 .openvibe-switcher-item {
393 display: flex; align-items: center; gap: 12px; padding: 10px 12px;
394 border-radius: 10px; cursor: pointer; transition: background .15s;
395 position: relative;
396 }
397 .openvibe-switcher-item:hover { background: var(--bg-hover, #2f2f3d); }
398 .openvibe-switcher-item.active { background: rgba(192,150,92,0.08); }
399 .openvibe-switcher-item.active::after {
400 content: '✓'; position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
401 color: var(--accent, #3b82f6); font-weight: 700; font-size: 16px;
402 }
403 .openvibe-switcher-item img { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
404 .openvibe-switcher-item .info { flex: 1; line-height: 1.3; }
405 .openvibe-switcher-item .info .name { font-size: 14px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
406 .openvibe-switcher-item .info .sub { font-size: 11px; color: var(--text-muted, #707080); }
407 .openvibe-switcher-item .remove {
408 opacity: 0; font-size: 14px; color: var(--live-red-text, var(--live-red, #e74c3c));
409 cursor: pointer; padding: 4px 6px; border-radius: 4px;
410 transition: opacity .15s, background .15s;
411 }
412 .openvibe-switcher-item:hover .remove { opacity: .6; }
413 .openvibe-switcher-item .remove:hover { opacity: 1; background: rgba(231,76,60,0.1); }
414
415 .openvibe-switcher-anon {
416 display: flex; align-items: center; gap: 12px; padding: 10px 12px;
417 margin: 0 8px 8px; border-radius: 10px; cursor: pointer;
418 border: 1px dashed var(--border, #333340);
419 transition: all .15s; color: var(--text-muted, #707080);
420 }
421 .openvibe-switcher-anon:hover { background: var(--bg-hover, #2f2f3d); border-color: var(--accent-dark, #a07840); }
422 .openvibe-switcher-anon .anon-icon { font-size: 22px; width: 40px; text-align: center; }
423
424 .openvibe-switcher-actions {
425 padding: 8px 12px; border-top: 1px solid var(--border, #333340);
426 display: flex; gap: 8px; justify-content: center;
427 }
428 .openvibe-switcher-actions button {
429 flex: 1; padding: 8px; border: none; border-radius: 8px;
430 font-size: 12px; font-weight: 600; cursor: pointer;
431 transition: all .15s;
432 }
433 .openvibe-switcher-actions .btn-add {
434 background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff));
435 }
436 .openvibe-switcher-actions .btn-add:hover { background: var(--accent-dark, #1d4ed8); }
437 .openvibe-switcher-actions .btn-signout {
438 background: rgba(231,76,60,0.1); color: var(--live-red-text, var(--live-red, #e74c3c));
439 }
440 .openvibe-switcher-actions .btn-signout:hover { background: rgba(231,76,60,0.2); }
441 `;
442 document.head.appendChild(s);
443 }
444
445 function showPanel() {
446 if (_panelEl) return;
447 injectStyles();
448
449 const accounts = getAccounts();
450 const activeId = getActiveId();
451 const isAnonActive = isAnonId(activeId);
452 const loggedAccounts = accounts.filter(a => !a.is_anon);
453 const anonAccounts = accounts.filter(a => a.is_anon);
454
455 const overlay = document.createElement('div');
456 overlay.className = 'openvibe-switcher-overlay';
457 overlay.addEventListener('click', e => {
458 if (e.target === overlay) closePanel();
459 });
460
461 const panel = document.createElement('div');
462 panel.className = 'openvibe-switcher-panel';
463
464 panel.innerHTML = `
465 <div class="openvibe-switcher-header">
466 <h3>\uD83D\uDD25 Switch Account</h3>
467 <p>Choose an account or browse anonymously</p>
468 </div>
469 <div class="openvibe-switcher-list">
470 ${loggedAccounts.map(a => `
471 <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
472 <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="" width="40" height="40" decoding="async">
473 <div class="info">
474 <div class="name">${a.display_name || a.username}</div>
475 <div class="sub">${a.email || `@${a.username}`}</div>
476 </div>
477 ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}
478 </div>
479 `).join('')}
480
481 ${anonAccounts.length > 0 ? `
482 <div style="padding:6px 12px 2px;font-size:11px;font-weight:600;color:var(--text-muted,#707080);text-transform:uppercase;letter-spacing:0.05em;">Anonymous Identities</div>
483 ${anonAccounts.map(a => `
484 <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
485 <span style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;font-size:20px;border-radius:50%;background:var(--bg-secondary,#1a1a26);flex-shrink:0;">\uD83E\uDEE5</span>
486 <div class="info">
487 <div class="name">Anonymous #${a.anon_number || '?'}</div>
488 <div class="sub">anon_${a.anon_number || '?'}</div>
489 </div>
490 ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove identity">\u2715</span>` : ''}
491 </div>
492 `).join('')}
493 ` : ''}
494 </div>
495 <div class="openvibe-switcher-anon" id="openvibe-sw-new-anon">
496 <span class="anon-icon">+</span>
497 <div class="info">
498 <div class="name" style="color:var(--text-primary,#e0e0e0)">${anonAccounts.length > 0 ? 'New Anon Identity' : 'Go Anonymous'}</div>
499 <div class="sub">${anonAccounts.length > 0 ? 'Create another anonymous identity' : 'Browse without an account \u00b7 Limited features'}</div>
500 </div>
501 </div>
502 <div class="openvibe-switcher-actions">
503 <button class="btn-add" id="openvibe-sw-add">+ Add Account</button>
504 <button class="btn-signout" id="openvibe-sw-signout">Sign Out All</button>
505 </div>
506 `;
507
508 overlay.appendChild(panel);
509 document.body.appendChild(overlay);
510 _panelEl = overlay;
511
512 // Wire events
513 panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {
514 el.addEventListener('click', e => {
515 if (e.target.closest('.remove')) return;
516 switchTo(el.dataset.id);
517 closePanel();
518 });
519 });
520
521 panel.querySelectorAll('[data-remove-id]').forEach(el => {
522 el.addEventListener('click', e => {
523 e.stopPropagation();
524 removeAccount(el.dataset.removeId);
525 closePanel();
526 showPanel(); // Re-render
527 });
528 });
529
530 panel.querySelector('#openvibe-sw-new-anon').addEventListener('click', () => {
531 if (anonAccounts.length > 0) {
532 createNewAnon();
533 } else {
534 switchToAnon();
535 }
536 closePanel();
537 });
538
539 panel.querySelector('#openvibe-sw-add').addEventListener('click', () => {
540 window.location.href = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
541 });
542
543 panel.querySelector('#openvibe-sw-signout').addEventListener('click', () => {
544 logoutAll();
545 closePanel();
546 });
547
548 // Esc to close
549 const escHandler = e => { if (e.key === 'Escape') closePanel(); };
550 document.addEventListener('keydown', escHandler);
551 overlay._escHandler = escHandler;
552 }
553
554 function closePanel() {
555 if (!_panelEl) return;
556 document.removeEventListener('keydown', _panelEl._escHandler);
557 _panelEl.remove();
558 _panelEl = null;
559 }
560
561 // ─── Token Refresh ───────────────────────────────────────────
562 // Decode JWT payload without verification (client-side can't verify RS256).
563 // Used only to check `exp` claim for proactive refresh.
564 function decodeJwtPayload(token) {
565 try {
566 const parts = token.split('.');
567 if (parts.length !== 3) return null;
568 const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
569 return JSON.parse(atob(payload));
570 } catch { return null; }
571 }
572
573 let _refreshTimer = null;
574 let _refreshInProgress = false;
575
576 /**
577 * Silently refresh the JWT if it expires within the next 2 hours,
578 * or if it has already expired (7-day grace period on the server).
579 * Updates localStorage, cookie, and account list.
580 */
581 async function silentRefresh() {
582 if (_refreshInProgress) return;
583 const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
584 if (!token) return;
585 const payload = decodeJwtPayload(token);
586 if (!payload || !payload.exp) return;
587
588 const expiresAt = payload.exp * 1000;
589 const now = Date.now();
590 const twoHours = 2 * 60 * 60 * 1000;
591
592 // Only refresh if token expires within 2 hours or is already expired
593 if (expiresAt - now > twoHours) return;
594
595 _refreshInProgress = true;
596 try {
597 const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
598 method: 'POST',
599 headers: {
600 'Content-Type': 'application/json',
601 Authorization: `Bearer ${token}`,
602 },
603 credentials: 'include',
604 });
605 if (!res.ok) {
606 // If refresh fails with 401, token is truly dead — don't retry
607 if (res.status === 401) {
608 console.warn('[OpenVibeAuth] Token refresh failed — session expired');
609 }
610 return;
611 }
612 const data = await res.json();
613 if (data.token) {
614 // Update localStorage
615 localStorage.setItem(TOKEN_KEY, data.token);
616 // Update cookie
617 setAuthCookie(data.token);
618 // Update the stored account entry
619 const activeId = getActiveId();
620 if (activeId && !isAnonId(activeId)) {
621 const accounts = getAccounts();
622 const idx = accounts.findIndex(a => String(a.id) === String(activeId));
623 if (idx !== -1) {
624 accounts[idx].token = data.token;
625 saveAccounts(accounts);
626 }
627 }
628 console.log('[OpenVibeAuth] Token refreshed silently');
629 }
630 } catch (err) {
631 console.warn('[OpenVibeAuth] Silent refresh error:', err.message);
632 } finally {
633 _refreshInProgress = false;
634 }
635 }
636
637 /**
638 * Start a periodic timer that checks token expiry and refreshes when needed.
639 * Checks every 15 minutes. Also runs an immediate check.
640 */
641 function startRefreshTimer() {
642 if (_refreshTimer) return;
643 silentRefresh(); // Immediate check
644 _refreshTimer = setInterval(silentRefresh, 15 * 60 * 1000); // Every 15 min
645 }
646
647 function stopRefreshTimer() {
648 if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; }
649 }
650
651 // ─── Listen for navbar events ──────────────────────────────
652 document.addEventListener('openvibe-switch-account', e => {
653 switchTo(e.detail.accountId);
654 });
655
656 // ─── Public API ────────────────────────────────────────────
657 const OpenVibeAccountSwitcher = {
658 init(opts = {}) {
659 Object.assign(_config, opts);
660 // Start silent token refresh for logged-in users
661 const activeId = getActiveId();
662 if (activeId && !isAnonId(activeId)) {
663 startRefreshTimer();
664 }
665 },
666
667 /** Call after login success to store this account. */
668 addAccount,
669 removeAccount,
670 getAccounts,
671 getActiveId,
672 switchTo,
673 switchToAnon,
674 createNewAnon,
675 getAnonAccounts,
676 logout,
677 logoutAll,
678 silentRefresh,
679 startRefreshTimer,
680 stopRefreshTimer,
681
682 /** Show the full-screen account switcher panel. */
683 showPanel,
684 closePanel,
685
686 /** Quick check: is user on an anonymous session? */
687 isAnonymous() {
688 const id = getActiveId();
689 return isAnonId(id);
690 },
691
692 /** Get the current active account object. */
693 getActive() {
694 const id = getActiveId();
695 if (!id) return null;
696 if (isAnonId(id)) return getAnonAccount(id) || getAnonAccount() || null;
697 return getAccounts().find(a => String(a.id) === String(id)) || null;
698 },
699 };
700
701 if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeAccountSwitcher;
702 else root.OpenVibeAccountSwitcher = OpenVibeAccountSwitcher;
703
704})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
705