openvibers/openvibe.shared account-switcher.js
account-switcher.js
| 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 | ; |
| 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 |