1const AUTH_KEY = 'arena_auth_user'; 2const HIDE_SHELL_PATHS = new Set(['/welcome', '/']); 3 4let firebaseGoogleClientPromise = null; 5let legacyGoogleInitInFlight = false; 6 7const __authParams = new URL(window.location.href).searchParams; 8const __skipAuthShell = __authParams.get('test') === '1'; 9if (__skipAuthShell) { 10 document.getElementById('global-shell-nav')?.remove(); 11} 12 13function shouldHideShell(pathname) { 14 if (HIDE_SHELL_PATHS.has(pathname)) { 15 return true; 16 } 17 return pathname === '/play' || pathname === '/play/' || pathname === '/viewer' || pathname === '/viewer/' || pathname === '/dashboard' || pathname === '/dashboard/'; 18} 19 20function readUser() { 21 try { 22 return JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); 23 } catch { 24 return null; 25 } 26} 27 28function writeUser(user) { 29 if (user) { 30 localStorage.setItem(AUTH_KEY, JSON.stringify(user)); 31 } else { 32 localStorage.removeItem(AUTH_KEY); 33 } 34 window.dispatchEvent(new CustomEvent('arena-auth-updated', { detail: user })); 35} 36 37async function fetchJson(url, init = {}) { 38 const headers = new Headers(init.headers || {}); 39 let response; 40 let fetchError = null; 41 for (let attempt = 0; attempt < 2; attempt += 1) { 42 try { 43 response = await fetch(url, { 44 credentials: 'include', 45 cache: init.cache || 'no-store', 46 ...init, 47 headers 48 }); 49 fetchError = null; 50 break; 51 } catch (error) { 52 fetchError = error; 53 if (attempt === 0) { 54 await new Promise((resolve) => setTimeout(resolve, 250)); 55 } 56 } 57 } 58 if (!response) { 59 throw new Error(fetchError ? `network_unreachable:${String(fetchError.message || fetchError)}` : 'network_unreachable'); 60 } 61 if (!response.ok) { 62 const payload = await response.json().catch(() => ({})); 63 throw new Error(payload?.reason || `status_${response.status}`); 64 } 65 return response.json().catch(() => ({})); 66} 67 68function firebaseClientEnabled(config) { 69 return Boolean( 70 config?.firebaseWebApiKey 71 && config?.firebaseAuthDomain 72 ); 73} 74 75function firebaseGoogleEnabled(config) { 76 return Boolean( 77 config?.firebaseGoogleAuthEnabled 78 && firebaseClientEnabled(config) 79 ); 80} 81 82function legacyGoogleEnabled(config) { 83 return Boolean(config?.googleAuthEnabled && config?.googleClientId); 84} 85 86async function getFirebaseGoogleClient(config) { 87 if (firebaseGoogleClientPromise) { 88 return firebaseGoogleClientPromise; 89 } 90 firebaseGoogleClientPromise = (async () => { 91 const firebase = await import('./lib/firebase-browser-auth.js'); 92 const { auth, provider, authMod } = await firebase.getFirebaseBrowserClient(config); 93 return { auth, provider, signInWithPopup: authMod.signInWithPopup }; 94 })(); 95 return firebaseGoogleClientPromise; 96} 97 98async function loadLegacyGoogleScriptIfNeeded(config) { 99 if (!legacyGoogleEnabled(config) || window.google?.accounts?.id) { 100 return; 101 } 102 await new Promise((resolve, reject) => { 103 const script = document.createElement('script'); 104 script.src = 'https://accounts.google.com/gsi/client'; 105 script.async = true; 106 script.defer = true; 107 script.onload = resolve; 108 script.onerror = reject; 109 document.head.appendChild(script); 110 }).catch(() => undefined); 111} 112 113function currentPath() { 114 return window.location.pathname; 115} 116 117function ensureShell() { 118 if (shouldHideShell(window.location.pathname)) { 119 document.getElementById('global-shell-nav')?.remove(); 120 return null; 121 } 122 let shell = document.getElementById('global-shell-nav'); 123 if (shell) { 124 return shell; 125 } 126 127 shell = document.createElement('div'); 128 shell.id = 'global-shell-nav'; 129 shell.className = 'global-shell'; 130 shell.innerHTML = ` 131 <div class="global-shell__links" id="global-shell-links"></div> 132 <div class="global-shell__auth" id="global-shell-auth"></div> 133 `; 134 document.body.appendChild(shell); 135 return shell; 136} 137 138function buildNav(user) { 139 const links = [
140 { key: 'welcome', href: '/welcome', label: 'Welcome' }, 141 { key: 'dashboard', href: '/dashboard', label: 'Dashboard' }, 142 { key: 'play', href: '/play?world=mega', label: 'Play' }, 143 { key: 'viewer', href: '/viewer?world=mega', label: 'Viewer' } 144 ]; 145 146 if (user?.role === 'admin') { 147 links.push({ key: 'users', href: '/users', label: 'Users' }); 148 links.push({ key: 'admin', href: '/admin', label: 'Admin' }); 149 } 150 151 return links; 152} 153 154function markActiveLink(user) { 155 const linksWrap = document.getElementById('global-shell-links'); 156 if (!linksWrap) { 157 return; 158 } 159 160 linksWrap.innerHTML = buildNav(user) 161 .map((entry) => `<a data-link="${entry.key}" href="${entry.href}">${entry.label}</a>`) 162 .join(''); 163 164 const path = currentPath(); 165 for (const link of linksWrap.querySelectorAll('[data-link]')) { 166 if (!(link instanceof HTMLAnchorElement)) { 167 continue; 168 } 169 link.classList.remove('active'); 170 const key = link.dataset.link; 171 if ( 172 (key === 'welcome' && path === '/welcome') || 173 (key === 'dashboard' && path === '/dashboard') || 174 (key === 'play' && path === '/play') || 175 (key === 'viewer' && path === '/viewer') || 176 (key === 'users' && path === '/users') || 177 (key === 'admin' && (path === '/admin' || path === '/agents')) 178 ) { 179 link.classList.add('active'); 180 } 181 } 182} 183 184async function handleEmailAuth(mode, config) { 185 const authContainer = document.getElementById('global-shell-auth'); 186 if (!authContainer) { 187 return; 188 } 189 const emailInput = authContainer.querySelector('#shell-email'); 190 const passwordInput = authContainer.querySelector('#shell-password'); 191 const statusEl = authContainer.querySelector('#shell-auth-status'); 192 const email = String(emailInput?.value || '').trim().toLowerCase(); 193 const password = String(passwordInput?.value || '').trim(); 194 195 if (!email || !password) { 196 if (statusEl) statusEl.textContent = 'Enter email and password.'; 197 return; 198 } 199 200 if (statusEl) { 201 statusEl.textContent = mode === 'signup' ? 'Creating account...' : 'Signing in...'; 202 } 203 204 try { 205 const payload = firebaseClientEnabled(config) 206 ? await (async () => { 207 const firebase = await import('./lib/firebase-browser-auth.js'); 208 const auth = await firebase.authenticateFirebaseEmail(config, { mode, email, password }); 209 return fetchJson('/api/auth/firebase', { 210 method: 'POST', 211 headers: { 'content-type': 'application/json' }, 212 body: JSON.stringify({ idToken: auth.idToken }) 213 }); 214 })() 215 : await fetchJson('/api/auth/email', { 216 method: 'POST', 217 headers: { 'content-type': 'application/json' }, 218 body: JSON.stringify({ email, password, mode }) 219 }); 220 if (!payload?.ok || !payload?.user) { 221 throw new Error(payload?.reason || 'auth_failed'); 222 } 223 writeUser(payload.user); 224 renderAuthState(config, payload.user); 225 if (statusEl) statusEl.textContent = ''; 226 } catch (error) { 227 if (statusEl) statusEl.textContent = `Sign-in failed: ${String(error?.message || error)}`; 228 } 229} 230 231async function handleGoogleFirebaseAuth(config) { 232 const authContainer = document.getElementById('global-shell-auth'); 233 const statusEl = authContainer?.querySelector('#shell-auth-status'); 234 if (statusEl) statusEl.textContent = 'Opening Google...'; 235 236 try { 237 const { auth, provider, signInWithPopup } = await getFirebaseGoogleClient(config); 238 const credential = await signInWithPopup(auth, provider); 239 const idToken = await credential.user.getIdToken(); 240 if (!idToken) { 241 throw new Error('id_token_missing'); 242 } 243 const payload = await fetchJson('/api/auth/firebase', { 244 method: 'POST', 245 headers: { 'content-type': 'application/json' }, 246 body: JSON.stringify({ idToken }) 247 }); 248 if (!payload?.ok || !payload?.user) { 249 throw new Error(payload?.reason || 'auth_failed'); 250 } 251 writeUser(payload.user); 252 renderAuthState(config, payload.user); 253 if (statusEl) statusEl.textContent = ''; 254 } catch (error) { 255 if (statusEl) statusEl.textContent = `Sign-in failed: ${String(error?.message || error)}`; 256 } 257} 258 259async function handleGoogleCredential(credential, config) { 260 const authContainer = document.getElementById('global-shell-auth'); 261 const statusEl = authContainer?.querySelector('#shell-auth-status'); 262 if (statusEl) statusEl.textContent = 'Signing in with Google...'; 263 264 try { 265 const payload = await fetchJson('/api/auth/google', { 266 method: 'POST', 267 headers: { 'content-type': 'application/json' }, 268 body: JSON.stringify({ credential }) 269 }); 270 if (!payload?.ok || !payload?.user) { 271 throw new Error(payload?.reason || 'auth_failed'); 272 } 273 writeUser(payload.user); 274 renderAuthState(config, payload.user); 275 if (statusEl) statusEl.textContent = ''; 276 } catch (error) { 277 if (statusEl) statusEl.textContent = `Sign-in failed: ${String(error?.message || error)}`; 278 } 279} 280 281function renderLegacyGoogleButton(config) { 282 const mount = document.getElementById('google-signin-shell'); 283 if (!mount || !window.google?.accounts?.id || !legacyGoogleEnabled(config) || legacyGoogleInitInFlight) { 284 return; 285 } 286 legacyGoogleInitInFlight = true; 287 try { 288 window.google.accounts.id.initialize({ 289 client_id: config.googleClientId, 290 callback: (response) => { 291 void handleGoogleCredential(response?.credential || '', config); 292 } 293 }); 294 mount.innerHTML = ''; 295 window.google.accounts.id.renderButton(mount, { 296 theme: 'outline', 297 size: 'small', 298 type: 'standard', 299 text: 'signin_with' 300 }); 301 } finally { 302 legacyGoogleInitInFlight = false;
303 } 304} 305 306async function handleLogout(config) { 307 try { 308 await fetchJson('/api/player/presence', { 309 method: 'POST', 310 headers: { 'content-type': 'application/json' }, 311 body: JSON.stringify({ state: 'offline' }) 312 }); 313 } catch { 314 // best effort 315 } 316 try { 317 await fetchJson('/api/logout', { 318 method: 'POST', 319 headers: { 'content-type': 'application/json' }, 320 body: '{}' 321 }); 322 } catch { 323 // best effort 324 } 325 try { 326 const firebase = await import('./lib/firebase-browser-auth.js'); 327 await firebase.signOutFirebaseBrowserSession(config); 328 } catch { 329 // best effort 330 } 331 writeUser(null); 332 renderAuthState(config, null); 333 if (window.location.pathname !== '/welcome' && window.location.pathname !== '/viewer') { 334 window.location.href = '/welcome'; 335 } 336} 337 338function renderAuthState(config, user) { 339 const authContainer = document.getElementById('global-shell-auth'); 340 if (!authContainer) { 341 return; 342 } 343 344 markActiveLink(user); 345 346 if (user) { 347 authContainer.innerHTML = ` 348 <span class="global-shell__user">${user.name || user.email || 'Signed in'} · ${user.role || 'player'}</span> 349 <button id="shell-logout" type="button">Logout</button> 350 `; 351 authContainer.querySelector('#shell-logout')?.addEventListener('click', () => { 352 void handleLogout(config); 353 }); 354 return; 355 } 356 357 const emailEnabled = Boolean(config?.emailAuthEnabled); 358 const firebaseGoogle = firebaseGoogleEnabled(config); 359 const legacyGoogle = legacyGoogleEnabled(config) && !firebaseGoogle; 360 const googleEnabled = firebaseGoogle || legacyGoogle; 361 362 if (!emailEnabled && !googleEnabled) { 363 authContainer.innerHTML = '<span class="global-shell__hint">Sign-in is not configured on this environment.</span>'; 364 return; 365 } 366 367 authContainer.innerHTML = ` 368 ${emailEnabled ? ` 369 <div class="global-shell__email-auth"> 370 <input id="shell-email" class="global-shell__input" type="email" placeholder="email" autocomplete="email"> 371 <input id="shell-password" class="global-shell__input" type="password" placeholder="password" autocomplete="current-password"> 372 <button id="shell-email-login" type="button">Email Login</button> 373 <button id="shell-email-signup" type="button">Email Signup</button> 374 <span id="shell-auth-status" class="global-shell__hint"></span> 375 </div> 376 ` : ''} 377 ${firebaseGoogle ? '<button id="shell-google-login" type="button">Google Login</button>' : ''} 378 ${legacyGoogle ? '<div id="google-signin-shell"></div>' : ''} 379 `; 380 381 if (emailEnabled) { 382 authContainer.querySelector('#shell-email-login')?.addEventListener('click', () => { 383 void handleEmailAuth('login', config); 384 }); 385 authContainer.querySelector('#shell-email-signup')?.addEventListener('click', () => { 386 void handleEmailAuth('signup', config); 387 }); 388 } 389 if (firebaseGoogle) { 390 authContainer.querySelector('#shell-google-login')?.addEventListener('click', () => { 391 void handleGoogleFirebaseAuth(config); 392 }); 393 } 394 if (legacyGoogle) { 395 renderLegacyGoogleButton(config); 396 } 397} 398 399async function loadConfig() { 400 try { 401 return await fetchJson(`/api/config?t=${Date.now()}`, { cache: 'no-store' }); 402 } catch { 403 return { 404 authEnabled: false, 405 emailAuthEnabled: false, 406 googleAuthEnabled: false, 407 googleClientId: '', 408 firebaseGoogleAuthEnabled: false, 409 firebaseWebApiKey: '', 410 firebaseAuthDomain: '', 411 firebaseProjectId: '' 412 }; 413 } 414} 415 416async function getSessionUser() { 417 try { 418 const response = await fetch(`/api/player/me?optional=1&t=${Date.now()}`, { credentials: 'include', cache: 'no-store' }); 419 if (response.status === 401 || response.status === 403) { 420 return { user: null, source: 'server' }; 421 } 422 if (!response.ok) { 423 return { user: readUser(), source: 'cache' }; 424 } 425 const data = await response.json().catch(() => null); 426 return { user: data?.user ?? null, source: 'server' }; 427 } catch { 428 return { user: readUser(), source: 'cache' }; 429 } 430} 431 432async function boot() { 433 const shell = ensureShell(); 434 if (!shell) { 435 return; 436 } 437 438 const cfg = await loadConfig(); 439 const finalCfg = { 440 ...cfg, 441 emailAuthEnabled: Boolean(cfg.emailAuthEnabled), 442 googleAuthEnabled: Boolean(cfg.googleAuthEnabled), 443 googleClientId: String(cfg.googleClientId || ''), 444 firebaseGoogleAuthEnabled: firebaseGoogleEnabled(cfg), 445 firebaseWebApiKey: String(cfg.firebaseWebApiKey || ''), 446 firebaseAuthDomain: String(cfg.firebaseAuthDomain || ''), 447 firebaseProjectId: String(cfg.firebaseProjectId || ''), 448 authEnabled: Boolean(cfg.emailAuthEnabled) || firebaseGoogleEnabled(cfg) || Boolean(cfg.googleAuthEnabled && cfg.googleClientId) 449 }; 450 451 if (legacyGoogleEnabled(finalCfg)) { 452 await loadLegacyGoogleScriptIfNeeded(finalCfg); 453 } 454 455 const session = await getSessionUser(); 456 if (session.source === 'server') { 457 if (session.user) { 458 writeUser(session.user); 459 } else { 460 writeUser(null); 461 } 462 } else if (!session.user) { 463 writeUser(null); 464 } 465 466 renderAuthState(finalCfg, readUser());
467 window.addEventListener('arena-auth-updated', (event) => { 468 renderAuthState(finalCfg, event.detail ?? readUser()); 469 }); 470} 471 472if (!__skipAuthShell) { 473 void boot(); 474}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.