1 2// Init Supabase from config.js (loaded via env-generated file). 3// The SDK is used only for auth flows (signInWithOAuth, signOut, 4// onAuthStateChange). Data queries go through `db` below, which calls 5// PostgREST directly â this is the same HTTP API the SDK wraps. 6const SUPABASE_STORAGE_KEY = "sb-eyjmtwlqscbkjpgvkdnt-auth-token"; 7 8const sb = supabase.createClient(SUPABASE_URL, SUPABASE_KEY, { 9 auth: { 10 detectSessionInUrl: true, 11 flowType: "pkce", 12 persistSession: true, 13 storage: window.localStorage, 14 }, 15}); 16 17function readSession() { 18 try { 19 const raw = localStorage.getItem(SUPABASE_STORAGE_KEY); 20 return raw ? JSON.parse(raw) : null; 21 } catch { return null; } 22} 23function authToken() { return readSession()?.access_token || null; } 24 25// A 401 from PostgREST means the access token expired, not that the user is 26// gone. It expires every hour; supabase-js refreshes it on a timer, but that 27// timer does not run while the tab is closed â so the FIRST query after 28// reopening a tab routinely carries a stale token, and every call site would 29// otherwise see a hard failure and decide the user is signed out. 30// 31// Refresh once, retry once, transparently. Single-flighted, because a page 32// load fires a dozen queries at once and they would each try to refresh â 33// and Supabase rotates refresh tokens, so the second one to arrive spends a 34// token the first already used and kills the session outright. 35let _refreshInFlight = null; 36async function refreshAuthOnce() { 37 if (_refreshInFlight) return _refreshInFlight; 38 _refreshInFlight = (async () => { 39 try { 40 const { data, error } = await sb.auth.refreshSession(); 41 if (error) { console.warn("[auth] refresh failed:", error.message); return null; } 42 return data?.session?.access_token || null; 43 } catch (e) { 44 console.warn("[auth] refresh threw:", e?.message || e); 45 return null; 46 } finally { 47 // Cleared on the next tick so concurrent callers share this result, 48 // but a genuinely later 401 can refresh again. 49 setTimeout(() => { _refreshInFlight = null; }, 0); 50 } 51 })(); 52 return _refreshInFlight; 53} 54 55// Fluent PostgREST query builder. Mirrors sb.from() shape so call sites read 56// the same. Chain methods build up state; awaiting the builder runs the query. 57function pgQuery(table) { 58 let method = "GET"; 59 let body = null; 60 let selectCols = "*"; 61 let selectExplicit = false; 62 const filters = []; 63 let order = null; 64 let limit = null; 65 let range = null; 66 let single = false; 67 let maybeSingle = false; 68 let upsertOnConflict = null; 69 70 const builder = { 71 select(cols) { selectCols = cols || "*"; selectExplicit = true; return builder; }, 72 insert(payload) { method = "POST"; body = JSON.stringify(payload); return builder; }, 73 // Was missing entirely, and its absence was silent: db.from(x).upsert(...) 74 // threw "upsert is not a function", the exception escaped the async click 75 // handler that called it, and the optimistic UI stayed flipped while 76 // nothing was written. A control that lies about having saved. 77 // 78 // PostgREST spells upsert as POST + Prefer: resolution=merge-duplicates, 79 // with on_conflict naming the unique constraint to merge on. 80 upsert(payload, opts) { 81 method = "POST"; 82 body = JSON.stringify(payload); 83 upsertOnConflict = (opts && opts.onConflict) || null; 84 return builder; 85 }, 86 update(payload) { method = "PATCH"; body = JSON.stringify(payload); return builder; }, 87 delete() { method = "DELETE"; return builder; }, 88 eq(col, val) { filters.push([col, `eq.${val}`]); return builder; }, 89 neq(col, val) { filters.push([col, `neq.${val}`]); return builder; }, 90 in(col, vals) { 91 const list = vals.map(v => `"${String(v).replace(/"/g, '\\"')}"`).join(","); 92 filters.push([col, `in.(${list})`]); 93 return builder; 94 }, 95 is(col, val) { filters.push([col, `is.${val}`]); return builder; }, 96 not(col, op, val) { filters.push([col, `not.${op}.${val}`]); return builder; }, 97 ilike(col, pattern) { filters.push([col, `ilike.${pattern}`]); return builder; }, 98 or(exprs) { filters.push(["or", `(${exprs.join(",")})`]); return builder; }, 99 order(col, opts = {}) { order = `${col}.${opts.ascending === false ? "desc" : "asc"}`; return builder; }, 100 limit(n) { limit = n; return builder; }, 101 range(from, to) { range = [from, to]; return builder; }, 102 single() { single = true; return builder; }, 103 maybeSingle() { maybeSingle = true; return builder; }, 104 then(onFulfilled, onRejected) { return execute().then(onFulfilled, onRejected); }, 105 }; 106 107 async function execute() { 108 const params = new URLSearchParams(); 109 if (method === "GET" || selectExplicit) params.set("select", selectCols); 110 for (const [col, expr] of filters) params.append(col, expr); 111 if (order) params.set("order", order); 112 if (limit != null) params.set("limit", String(limit)); 113 // Names the unique constraint PostgREST merges on. Without it the POST is 114 // a plain insert and a second write raises a duplicate-key error. 115 if (upsertOnConflict) params.set("on_conflict", upsertOnConflict); 116 117 const headers = { "apikey": SUPABASE_KEY }; 118 const tok = authToken(); 119 if (tok) headers["Authorization"] = `Bearer ${tok}`;
120 if (body != null) headers["Content-Type"] = "application/json"; 121 if (method !== "GET") { 122 const prefer = [selectExplicit ? "return=representation" : "return=minimal"]; 123 if (upsertOnConflict !== null) prefer.push("resolution=merge-duplicates"); 124 headers["Prefer"] = prefer.join(","); 125 } 126 if (range) { headers["Range-Unit"] = "items"; headers["Range"] = `${range[0]}-${range[1]}`; } 127 if (single) headers["Accept"] = "application/vnd.pgrst.object+json"; 128 129 const url = `${SUPABASE_URL}/rest/v1/${table}?${params.toString()}`; 130 let res; 131 try { 132 res = await fetch(url, { method, headers, body }); 133 // Expired token: refresh and replay once. Without this the caller sees 134 // a failure it cannot distinguish from "no data", and the app decides 135 // the user is signed out. 136 if (res.status === 401 && authToken()) { 137 const fresh = await refreshAuthOnce(); 138 if (fresh) { 139 headers["Authorization"] = `Bearer ${fresh}`; 140 res = await fetch(url, { method, headers, body }); 141 } 142 } 143 } catch (e) { 144 return { data: null, error: { message: e.message || "Network error" } }; 145 } 146 if (res.status === 204) return { data: null, error: null }; 147 148 const text = await res.text(); 149 let parsed = null; 150 if (text) { 151 try { parsed = JSON.parse(text); } catch { parsed = text; } 152 } 153 if (!res.ok) { 154 const msg = parsed?.message || parsed?.error || res.statusText || `HTTP ${res.status}`; 155 return { data: null, error: { message: msg, status: res.status, details: parsed } }; 156 } 157 if (single) return { data: parsed, error: null }; 158 if (maybeSingle) { 159 const rows = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []); 160 return { data: rows[0] ?? null, error: null }; 161 } 162 return { data: parsed, error: null }; 163 } 164 165 return builder; 166} 167 168const db = { 169 from(table) { return pgQuery(table); }, 170 async rpc(name, params) { 171 const headers = { "apikey": SUPABASE_KEY, "Content-Type": "application/json" }; 172 const tok = authToken(); 173 if (tok) headers["Authorization"] = `Bearer ${tok}`; 174 let res; 175 try { 176 const call = () => fetch(`${SUPABASE_URL}/rest/v1/rpc/${name}`, { 177 method: "POST", headers, body: JSON.stringify(params || {}), 178 }); 179 res = await call(); 180 if (res.status === 401 && authToken()) { 181 const fresh = await refreshAuthOnce(); 182 if (fresh) { headers["Authorization"] = `Bearer ${fresh}`; res = await call(); } 183 } 184 } catch (e) { 185 return { data: null, error: { message: e.message || "Network error" } }; 186 } 187 if (res.status === 204) return { data: null, error: null }; 188 const text = await res.text(); 189 let parsed = null; 190 if (text) { 191 try { parsed = JSON.parse(text); } catch { parsed = text; } 192 } 193 if (!res.ok) { 194 const msg = parsed?.message || parsed?.error || `HTTP ${res.status}`; 195 return { data: null, error: { message: msg, status: res.status, details: parsed } }; 196 } 197 return { data: parsed, error: null }; 198 }, 199}; 200 201let currentUser = null; 202let currentProfile = null; 203// URL routing: every page maps to a real URL so links are shareable, 204// bookmarkable, and browser back/forward actually works. The routing is 205// best-effort â state still lives in module-level vars (currentTeamId, 206// currentProfile_*). URL is just a reflection we keep in sync here. 207 208const PUBLIC_PAGES = new Set(["landing", "install", "terms", "privacy", "privacy-profile-data"]); 209let pendingPostAuthPath = null; // stashed when a logged-out visitor hits a private URL 210let pendingJoinCode = null; // stashed from legacy #join/<code> during boot 211 212function pathForPage(page, extra) { 213 if (page === "dashboard") return "/home"; 214 if (page === "team") { 215 const tid = typeof extra === "string" ? extra : extra?.teamId; 216 if (!tid) return "/home"; 217 // Each workspace view is its own address, the way a list is â People 218 // included, so the three read alike in the bar. Bare /team/<id> still 219 // resolves to People, for old links and for landing on the workspace. 220 const view = (typeof extra === "object" && extra) ? extra.view : null; 221 const seg = view === "integrations" ? "configure" 222 : view === "team" ? "team" 223 : view === "people" ? "people" 224 : view === "notifications" ? "notifications" 225 : null; 226 return seg ? `/team/${encodeURIComponent(tid)}/${seg}` : `/team/${encodeURIComponent(tid)}`; 227 } 228 if (page === "profile") { 229 const tid = extra?.teamId || currentProfile_TeamId; 230 const pid = extra?.profileId || currentProfile_Id; 231 if (!tid || !pid) return "/home"; 232 return `/team/${encodeURIComponent(tid)}/profile/${encodeURIComponent(pid)}`; 233 } 234 if (page === "review") { 235 const tid = extra?.teamId; 236 const iid = extra?.integrationId; 237 const eid = extra?.eventId; 238 if (!tid || !iid) return "/home"; 239 const base = `/team/${encodeURIComponent(tid)}/review/${encodeURIComponent(iid)}`; 240 return eid ? `${base}/event/${encodeURIComponent(eid)}` : base; 241 } 242 if (page === "entries") { 243 const tid = extra?.teamId || currentTeamId; 244 if (!tid) return "/home"; 245 let p = `/team/${encodeURIComponent(tid)}/entries`; 246 if (extra?.listId) { 247 p += `/${encodeURIComponent(extra.listId)}`; 248 if (extra.view === "outreach") p += "/outreach"; // pipeline is the default sub-view 249 } 250 return p; 251 } 252 if (page === "settings") return "/account"; 253 if (page === "oauth-consent") return "/oauth/consent"; 254 if (page === "install") return "/install"; 255 if (page === "terms") return "/terms"; 256 if (page === "privacy") return "/privacy"; 257 if (page === "privacy-profile-data") return "/privacy/profile-data"; 258 return "/"; 259} 260 261function pageFromPath(rawPath) { 262 const path = rawPath.replace(/\/+$/, "") || "/"; 263 if (path === "/" || path === "") return { page: "landing" }; 264 if (path === "/home") return { page: "dashboard" }; 265 // /dashboard was this page's address until it was renamed. Old links and 266 // bookmarks still land, and navigate() rewrites the bar to /home. 267 if (path === "/dashboard") return { page: "dashboard" }; 268 if (path === "/account") return { page: "settings" }; 269 // /settings was this page's address until it was renamed â and the team's 270 // own Configure view now owns the word "settings" in this app. Old links 271 // still land, and navigate() rewrites the bar to /account. 272 if (path === "/settings") return { page: "settings" }; 273 if (path === "/oauth/consent") return { page: "oauth-consent" }; 274 if (path === "/install") return { page: "install" }; 275 if (path === "/terms") return { page: "terms" }; 276 if (path === "/privacy") return { page: "privacy" }; 277 if (path === "/privacy/profile-data") return { page: "privacy-profile-data" }; 278 let m = path.match(/^\/team\/([^/]+)\/profile\/([^/]+)$/);
279 if (m) return { page: "profile", teamId: decodeURIComponent(m[1]), profileId: decodeURIComponent(m[2]) }; 280 m = path.match(/^\/team\/([^/]+)\/review\/([^/]+)\/event\/([^/]+)$/); 281 if (m) return { page: "review", teamId: decodeURIComponent(m[1]), integrationId: decodeURIComponent(m[2]), eventId: decodeURIComponent(m[3]) }; 282 m = path.match(/^\/team\/([^/]+)\/review\/([^/]+)$/); 283 if (m) return { page: "review", teamId: decodeURIComponent(m[1]), integrationId: decodeURIComponent(m[2]) }; 284 m = path.match(/^\/team\/([^/]+)\/entries(?:\/([^/]+)(\/outreach)?)?$/); 285 if (m) return { 286 page: "entries", 287 teamId: decodeURIComponent(m[1]), 288 listId: m[2] ? decodeURIComponent(m[2]) : null, 289 view: m[3] ? "outreach" : (m[2] ? "pipeline" : null), 290 }; 291 m = path.match(/^\/team\/([^/]+)\/(people|configure|team|notifications)$/); 292 if (m) return { 293 page: "team", 294 teamId: decodeURIComponent(m[1]), 295 view: m[2] === "configure" ? "integrations" : m[2], 296 }; 297 m = path.match(/^\/team\/([^/]+)$/); 298 if (m) return { page: "team", teamId: decodeURIComponent(m[1]) }; 299 return { page: "landing" }; 300} 301 302// Legacy hash deep-links (#privacy, #terms, #profile/TID/PID, #join/CODE, #login) 303// shipped in the first version of the site â translate to paths so the rest of 304// the routing layer doesn't have to think about hashes. Runs synchronously before 305// auth resolves, which is fine because we only rewrite the URL; actual navigation 306// happens inside renderRoute / onAuth. 307function migrateLegacyHash() { 308 const h = window.location.hash; 309 if (!h) return; 310 if (h === "#privacy") { history.replaceState(null, "", "/privacy"); return; } 311 if (h === "#terms") { history.replaceState(null, "", "/terms"); return; } 312 if (h.startsWith("#profile/")) { 313 const parts = h.slice("#profile/".length).split("/"); 314 const tid = decodeURIComponent(parts[0] || ""); 315 const pid = decodeURIComponent(parts[1] || ""); 316 if (tid && pid) { 317 history.replaceState(null, "", `/team/${encodeURIComponent(tid)}/profile/${encodeURIComponent(pid)}`); 318 } 319 return; 320 } 321 if (h.startsWith("#join/")) { 322 pendingJoinCode = h.slice("#join/".length); 323 history.replaceState(null, "", window.location.pathname); 324 return; 325 } 326 // #login and OAuth hash params (#access_token=...) are left for the existing 327 // handlers at onAuthStateChange / line 5717 to consume. 328} 329 330let _settingsReturn = { page: "dashboard" }; 331function goBackFromSettings() { 332 if (_settingsReturn && _settingsReturn.page === "team" && _settingsReturn.teamId) navigate("team", _settingsReturn.teamId); 333 else navigate("dashboard"); 334} 335 336function navigate(page, extra, opts = {}) { 337 // Reveal the body â the inline script in <head> hid it while we 338 // waited for Supabase to resolve the stored session. Once we've 339 // picked a page to show, no more flicker risk. 340 document.documentElement.classList.remove("auth-pending"); 341 // Remember where Settings was opened from so its back link returns there: 342 // a team/workspace page â that team; anywhere else â the dashboard (home). 343 if (page === "settings") { 344 const prevId = document.querySelector(".page.active")?.id || ""; 345 _settingsReturn = ((prevId === "page-team" || prevId === "page-entries" || prevId === "page-profile") && typeof currentTeamId !== "undefined" && currentTeamId) 346 ? { page: "team", teamId: currentTeamId } 347 : { page: "dashboard" }; 348 } 349 document.querySelectorAll(".page").forEach(p => p.classList.remove("active")); 350 const target = document.getElementById(`page-${page}`); 351 if (target) target.classList.add("active"); 352 353 document.body.classList.toggle("on-profile", page === "profile"); 354 // Clear CSV import status when leaving the team page 355 const csvStatus = document.getElementById("csv-status"); 356 if (csvStatus) csvStatus.innerHTML = ""; 357 358 // Sync the URL to reflect the page we just rendered. For profile we synthesize 359 // the extra from the globals openProfile already set (keeps every existing 360 // caller of navigate("profile") working unchanged). 361 if (!opts.skipHistory) { 362 let extraForPath = extra; 363 if (page === "profile" && !extra) {
364 extraForPath = { teamId: currentProfile_TeamId, profileId: currentProfile_Id }; 365 } 366 const targetPath = pathForPage(page, extraForPath); 367 const currentPath = window.location.pathname + window.location.search; 368 if (targetPath !== currentPath) { 369 if (opts.replace) history.replaceState({ page }, "", targetPath); 370 else history.pushState({ page }, "", targetPath); 371 } 372 } 373 374 if (page === "dashboard") loadDashboard(); 375 if (page === "team" && extra) { 376 if (typeof extra === "object" && extra.view && typeof _pendingTeamView !== "undefined") { 377 _pendingTeamView = extra.view; 378 // Switch the view NOW, not when the team data lands. loadTeamDetail is 379 // several round trips long and only set the view at the end of it, so 380 // #page-team came up showing whichever view you were last on â press 381 // People from Configure and you watched Configure for a second first. 382 if (typeof setTeamView === "function") setTeamView(extra.view, { syncUrl: false }); 383 } 384 loadTeamDetail(typeof extra === "string" ? extra : extra.teamId); 385 } 386 if (page === "settings") loadSettings(); 387 if (page === "oauth-consent") loadOAuthConsent(); 388 if (page === "profile") loadProfile(); 389 if (page === "review") { 390 if (extra && typeof extra === "object") { 391 if (extra.teamId) currentReview_TeamId = extra.teamId; 392 if (extra.integrationId) currentReview_IntegrationId = extra.integrationId; 393 currentReview_EventId = extra.eventId || null; 394 } 395 loadReview(); 396 } 397 if (page === "entries" && !opts.skipLoad) { 398 const tid = (extra && typeof extra === "object" ? extra.teamId : extra) || currentTeamId; 399 const lid = (extra && typeof extra === "object") ? extra.listId : null; 400 const view = (extra && typeof extra === "object") ? extra.view : null; 401 loadEntriesPage(tid, lid, view); 402 } 403 // Show/hide the workspace sidebar (team + pipeline pages) and offset content. 404 if (typeof updateWorkspaceChrome === "function") updateWorkspaceChrome(page); 405 window.scrollTo(0, 0); 406} 407 408// Resolve the current URL into a rendered page. Called on boot, on popstate, 409// and after auth completes (so a logged-in deep-link lands where it should). 410function renderRoute() { 411 const parsed = pageFromPath(window.location.pathname); 412 if (PUBLIC_PAGES.has(parsed.page)) { 413 navigate(parsed.page, undefined, { skipHistory: true }); 414 return; 415 } 416 if (!currentUser) { 417 // Stash intended destination so onAuth can restore it after sign-in. 418 // Skip `/` â that just means the user was on the home/landing page. 419 // Include the query string: extension deep-links carry ?name=<...> and 420 // losing it would blank out the profile page's name-based matching. 421 if (window.location.pathname !== "/") pendingPostAuthPath = window.location.pathname + window.location.search; 422 // Land the user on `/` (the canonical sign-in URL) â the login page 423 // shouldn't show under a /home or /team/X URL. Drop skipHistory 424 // so navigate() updates the address bar via replaceState. 425 navigate("landing", undefined, { replace: true }); 426 return; 427 } 428 if (parsed.page === "team") { 429 if (parsed.view && typeof _pendingTeamView !== "undefined") _pendingTeamView = parsed.view; 430 navigate("team", { teamId: parsed.teamId, view: parsed.view || null }, { skipHistory: true }); 431 // Returning from Stripe Checkout lands on /team/<id>?billing=⦠432 // skipHistory above leaves the query string intact for this to read. 433 if (typeof handleBillingReturn === "function") handleBillingReturn(parsed.teamId); 434 return; 435 } 436 if (parsed.page === "profile") { 437 currentProfile_TeamId = parsed.teamId; 438 currentProfile_Id = parsed.profileId; 439 // Deep-links from the extension carry ?name=<rendered LinkedIn name> so the 440 // gsheet RPC and Luma name-matching have a display name to work with. 441 // Without it those sections render empty even when matches exist. 442 const nameParam = new URLSearchParams(window.location.search).get("name"); 443 currentProfile_Name = nameParam ? nameParam.trim() || null : null; 444 // No people-list row context on a direct URL â clear so a stale 445 // preview from a previous click doesn't leak onto this profile. 446 currentProfile_ListRow = null; 447 navigate("profile", undefined, { skipHistory: true }); 448 return; 449 } 450 if (parsed.page === "review") { 451 currentReview_TeamId = parsed.teamId; 452 currentReview_IntegrationId = parsed.integrationId; 453 currentReview_EventId = parsed.eventId || null; 454 navigate("review", undefined, { skipHistory: true }); 455 return; 456 } 457 if (parsed.page === "entries") { 458 navigate("entries", { teamId: parsed.teamId, listId: parsed.listId, view: parsed.view }, { skipHistory: true }); 459 return; 460 } 461 navigate(parsed.page, undefined, { skipHistory: true }); 462} 463 464window.addEventListener("popstate", () => { renderRoute(); }); 465 466// The name that gets written into `who` and shown to teammates on every 467// outreach, view, comment and marking. 468//
469// NEVER the email address. That was the last fallback everywhere â a dozen 470// copies of `full_name || linkedin_name || currentUser.email` â so anyone 471// whose profile had no name broadcast their address to the whole team on 472// every profile they opened. An email is not a display name; it is contact 473// details, and the person it belongs to did not choose to publish it here. 474// 475// user_metadata is worth trying before giving up: Google and LinkedIn both 476// put a real name there at sign-up even when the profile row has not caught 477// up yet. 478function myWhoName() { 479 const meta = (typeof currentUser !== "undefined" && currentUser && currentUser.user_metadata) || {}; 480 const prof = (typeof currentProfile !== "undefined" && currentProfile) || {}; 481 return ( 482 (prof.full_name || "").trim() || 483 (prof.linkedin_name || "").trim() || 484 (meta.full_name || "").trim() || 485 (meta.name || "").trim() || 486 "Unknown" 487 ); 488}
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.