// Cookie-backed API client. Replaces the old Basic-auth flow. // // Every authenticated route uses the `pulse_session` httpOnly cookie, // which the browser attaches automatically on same-origin requests // (including ). All `apiFetch` needs to // do is set `credentials: 'same-origin'` and surface 401s as a // `pulse:session-lost` window event so `app.jsx` can drop to the // login screen. // // Legacy note: `adminFetch` is kept as an alias so the many existing // call-sites (admin.jsx, notifications.jsx, admin_editor.jsx, // node_detail.jsx, app.jsx pause toggle) don't need an immediate edit. // One-time migration: wipe any leftover sessionStorage token from the // old Basic-auth implementation so it can't confuse later state. try { sessionStorage.removeItem('pulse.adminAuth'); } catch (_) {} async function apiFetch(path, init = {}) { const headers = new Headers(init.headers || {}); if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); const res = await fetch(path, { credentials: 'same-origin', ...init, headers }); if (res.status === 401) { window.dispatchEvent(new CustomEvent('pulse:session-lost')); throw new Error('unauthorized'); } if (!res.ok) { let msg = `http ${res.status}`; try { const j = await res.json(); if (j.detail) msg = j.detail; } catch (_) {} throw new Error(msg); } if (res.status === 204) return null; const ctype = res.headers.get('Content-Type') || ''; if (!ctype.includes('json')) return null; return res.json(); } async function signOut() { try { await apiFetch('/api/auth/logout', { method: 'POST' }); } catch (_) { /* even on failure we want the UI to drop out */ } window.dispatchEvent(new CustomEvent('pulse:session-lost')); } async function login(username, password) { const r = await fetch('/api/auth/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); if (r.status === 401) throw new Error('invalid credentials'); if (r.status === 429) throw new Error('too many attempts — wait a minute'); if (!r.ok) { let msg = `http ${r.status}`; try { const j = await r.json(); if (j.detail) msg = j.detail; } catch (_) {} throw new Error(msg); } return r.json(); } // Back-compat — do NOT remove; many components import under the old name. // Since the app-level login gate runs before any admin view mounts, the // `auth.authed` branches in admin.jsx / notifications.jsx are always // true here. The stub prompt is a defence against any stale fallback. const adminFetch = apiFetch; const useAdminAuth = () => ({ authed: true, signIn: () => {}, signOut }); function AdminAuthPrompt() { // If something ever renders this, the login gate has been bypassed // in error. Bounce to the login screen immediately. React.useEffect(() => { window.dispatchEvent(new CustomEvent('pulse:session-lost')); }, []); return null; } Object.assign(window, { apiFetch, adminFetch, signOut, login, useAdminAuth, AdminAuthPrompt });