PageSourceSearch

https://autobett.netlify.app/js/auth-shell.js

js autobett.netlify.app collected 2026-10-03 10:28:37 UTC 14,894 bytes, 474 lines download raw bytes

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.