1/* 2 * GEX Levels â Account Settings (frontend). 3 * 4 * Honest + functional. Every control here maps to a real, deployed Worker 5 * endpoint: 6 * - Profile -> GET /auth/me 7 * - Resend verification -> POST /auth/email-verification/request 8 * - Password reset -> POST /auth/password-reset/request 9 * - Export journal -> GET /journal/export?format=csv|json (authenticated download) 10 * - Erase journal data -> POST /journal/account/delete (journal only; never the account) 11 * 12 * Auth model mirrors journal.js: the account session token (localStorage) is 13 * sent as an Authorization: Bearer token. Pure-Bearer requests carry no 14 * gex_session cookie, so the Worker treats them as CSRF-immune; when served 15 * behind a same-origin auth proxy we fall back to the double-submit CSRF header. 16 * 17 * This page is account-gated (data-account-page): app.js redirects logged-out 18 * visitors to /login before this runs. Everything degrades gracefully if the 19 * Worker is unreachable. 20 */ 21(function () { 22 "use strict"; 23 24 if (!document.querySelector("[data-settings-page]")) return; 25 26 const apiBase = 27 window.GEX_LEVELS_API_BASE || 28 (window.GEX_LEVELS_PUBLIC_ENV && window.GEX_LEVELS_PUBLIC_ENV.apiBaseUrl) || 29 "https://api.gex-levels.com"; 30 31 // ---- session helpers (reuse app.js when present; otherwise self-contained) -- 32 function accountToken() { 33 try { 34 if (typeof getAccountToken === "function") return getAccountToken() || ""; 35 } catch (_) {} 36 try { 37 return window.localStorage.getItem("gex_levels_account_token") || ""; 38 } catch (_) { 39 return ""; 40 } 41 } 42 function csrfCookie() { 43 const entry = document.cookie 44 .split(";") 45 .map((c) => c.trim()) 46 .find((c) => c.startsWith("gex_csrf=")); 47 return entry ? entry.slice("gex_csrf=".length) : ""; 48 } 49 function esc(v) { 50 return String(v == null ? "" : v) 51 .replaceAll("&", "&") 52 .replaceAll("<", "<") 53 .replaceAll(">", ">") 54 .replaceAll('"', """) 55 .replaceAll("'", "'"); 56 } 57 58 // ---- DOM helpers ------------------------------------------------------------ 59 const $ = (sel) => document.querySelector(sel); 60 function t(key, values) { 61 const i18n = window.GexI18n; 62 return i18n && typeof i18n.t === "function" ? i18n.t(key, values) : key; 63 } 64 function setText(sel, text) { 65 const el = $(sel); 66 if (el) el.textContent = text; 67 } 68 function showStatus(sel, message, tone) { 69 const el = $(sel); 70 if (!el) return; 71 el.hidden = false; 72 el.textContent = message; 73 // .jrnl-error is the existing destructive/error status style (styles.css). 74 el.classList.toggle("jrnl-error", tone === "error"); 75 } 76 77 // Canonical CSRF token from the bootstrap endpoint (response BODY). The SPA 78 // cannot read the host-only gex_csrf cookie via document.cookie, so the body 79 // is the only reliable source; the browser still sends the matching cookie. 80 async function bootstrapCsrf() { 81 const headers = { Accept: "application/json" }; 82 const tok = accountToken(); 83 if (tok) headers["Authorization"] = `Bearer ${tok}`; 84 try { 85 const r = await fetch(`${apiBase}/auth/csrf`, { method: "GET", credentials: "include", headers }); 86 if (!r.ok) return ""; 87 const j = await r.json().catch(() => ({})); 88 return (j && j.csrf_token) || ""; 89 } catch (_) { 90 return ""; 91 } 92 } 93 94 const M0 = (typeof window !== "undefined" && window.GexAuthMutation) || null; 95 const csrfMgr = M0 && M0.createCsrfManager 96 ? M0.createCsrfManager({ bootstrap: bootstrapCsrf, getCookie: csrfCookie }) 97 : null; 98 99 // Build headers via the shared mutation helper: X-CSRF-Token is attached on 100 // every non-GET request, sourced from the /auth/csrf bootstrap (INDEPENDENT of 101 // the Bearer â the browser sends the session cookie alongside it and the Worker 102 // requires CSRF when that cookie is present). 103 async function buildHeaders(method, hasBody, forceRefresh) { 104 const M = (typeof window !== "undefined" && window.GexAuthMutation) || null; 105 const token = accountToken();
106 let csrf = ""; 107 if (M ? M.needsCsrf(method) : method !== "GET") { 108 if (csrfMgr) csrf = forceRefresh ? await csrfMgr.refresh() : await csrfMgr.get(); 109 else csrf = csrfCookie() || (await bootstrapCsrf()); 110 } 111 if (M) return M.buildHeaders({ method, hasBody, accountToken: token, csrfToken: csrf }); 112 const headers = { Accept: "application/json" }; 113 if (hasBody) headers["Content-Type"] = "application/json"; 114 if (token) headers["Authorization"] = `Bearer ${token}`; 115 if (csrf) headers["X-CSRF-Token"] = csrf; 116 return headers; 117 } 118 119 // JSON request: returns {ok,status,payload} and never throws. 120 // Mutations retry ONCE on a CSRF rejection (refresh the token); a network 121 // failure (status 0) is NEVER auto-retried (no duplicate writes). 122 async function apiJson(path, options = {}) { 123 const method = String(options.method || "GET").toUpperCase(); 124 const M = (typeof window !== "undefined" && window.GexAuthMutation) || null; 125 async function send(forceRefresh) { 126 const headers = await buildHeaders(method, !!options.body, forceRefresh); 127 let response; 128 try { 129 response = await fetch(`${apiBase}${path}`, { ...options, method, credentials: "include", headers }); 130 } catch (_) { 131 return { ok: false, status: 0, payload: {}, networkError: true }; 132 } 133 let payload = {}; 134 try { payload = await response.json(); } catch (_) { payload = {}; } 135 return { ok: response.ok, status: response.status, payload }; 136 } 137 let res = await send(false); 138 const err = res.payload && res.payload.error; 139 const retryCsrf = M ? M.isSafeToRetryCsrf(res.status, err) : res.status === 403 && /csrf/i.test(String(err || "")); 140 if (retryCsrf) res = await send(true); 141 return res; 142 } 143 144 // ---- Profile ---------------------------------------------------------------- 145 let currentEmail = ""; 146 147 function formatDateTime(iso) { 148 if (!iso) return "â"; 149 const d = new Date(iso); 150 if (Number.isNaN(d.getTime())) return String(iso); 151 try { 152 const locale = window.GexI18n && window.GexI18n.getLanguage() === "fr" ? "fr-FR" : "en-US"; 153 return d.toLocaleString(locale); 154 } catch (_) { 155 return d.toISOString(); 156 } 157 } 158 159 async function loadProfile() { 160 if (!accountToken()) { 161 showStatus("[data-settings-profile-output]", t("account.settings.profile.signIn"), "error"); 162 return; 163 } 164 const res = await apiJson("/auth/me"); 165 if (!res.ok || !res.payload || !res.payload.user) { 166 if (res.status === 401) { 167 showStatus("[data-settings-profile-output]", t("account.settings.profile.sessionExpired"), "error"); 168 } else if (res.networkError || res.status === 0) { 169 showStatus("[data-settings-profile-output]", t("account.settings.profile.serviceUnavailable"), "error"); 170 } else { 171 showStatus("[data-settings-profile-output]", t("account.settings.profile.loadFailed"), "error"); 172 } 173 return; 174 } 175 const user = res.payload.user; 176 const session = res.payload.session || {}; 177 currentEmail = user.email || ""; 178 179 setText("[data-settings-email]", user.email || "â"); 180 setText("[data-settings-name]", user.displayName || t("account.settings.profile.notSet")); 181 setText("[data-settings-status]", user.status ? t("account.status." + String(user.status).toLowerCase()) : "â"); 182 183 const verified = !!user.emailVerifiedAt; 184 setText("[data-settings-verified]", verified 185 ? t("account.settings.profile.verifiedAt", { date: formatDateTime(user.emailVerifiedAt) }) 186 : t("account.settings.profile.notVerified")); 187 const resendBtn = $("[data-settings-resend]"); 188 if (resendBtn) resendBtn.hidden = verified; 189 190 setText("[data-settings-session]", session.expiresAt ? formatDateTime(session.expiresAt) : "â"); 191 192 showStatus("[data-settings-profile-output]", t("account.settings.profile.loaded"), "ok"); 193 } 194 195 // ---- Resend verification ---------------------------------------------------- 196 async function resendVerification() { 197 if (!currentEmail) return; 198 const btn = $("[data-settings-resend]"); 199 if (btn) btn.disabled = true; 200 showStatus("[data-settings-profile-output]", t("account.settings.verification.sending")); 201 const res = await apiJson("/auth/email-verification/request", { 202 method: "POST", 203 body: JSON.stringify({ email: currentEmail }) 204 }); 205 // The endpoint is intentionally non-enumerating: it returns 202 regardless. 206 if (res.status === 202 || res.ok) { 207 showStatus( 208 "[data-settings-profile-output]", 209 t("account.settings.verification.sent"), 210 "ok" 211 ); 212 } else if (res.status === 429) { 213 showStatus("[data-settings-profile-output]", t("account.settings.request.rateLimited"), "error"); 214 } else { 215 showStatus("[data-settings-profile-output]", t("account.settings.verification.failed"), "error"); 216 } 217 if (btn) btn.disabled = false;
218 } 219 220 // ---- Password reset --------------------------------------------------------- 221 async function requestPasswordReset() { 222 const email = currentEmail; 223 if (!email) { 224 showStatus("[data-settings-password-output]", t("account.settings.password.loadFirst"), "error"); 225 return; 226 } 227 const btn = $("[data-settings-password]"); 228 if (btn) btn.disabled = true; 229 showStatus("[data-settings-password-output]", t("account.settings.password.sending")); 230 const res = await apiJson("/auth/password-reset/request", { 231 method: "POST", 232 body: JSON.stringify({ email }) 233 }); 234 if (res.status === 202 || res.ok) { 235 showStatus( 236 "[data-settings-password-output]", 237 t("account.settings.password.sent", { email: email }), 238 "ok" 239 ); 240 } else if (res.status === 429) { 241 showStatus("[data-settings-password-output]", t("account.settings.request.rateLimited"), "error"); 242 } else { 243 showStatus("[data-settings-password-output]", t("account.settings.password.failed"), "error"); 244 } 245 if (btn) btn.disabled = false; 246 } 247 248 // ---- Journal export (authenticated download) -------------------------------- 249 async function exportJournal(format) { 250 const fmt = format === "csv" ? "csv" : "json"; 251 showStatus("[data-settings-journal-output]", t("account.settings.journal.exportPreparing", { format: fmt.toUpperCase() })); 252 const headers = await buildHeaders("GET", false); 253 let response; 254 try { 255 response = await fetch(`${apiBase}/journal/export?format=${fmt}`, { 256 method: "GET", 257 credentials: "include", 258 headers 259 }); 260 } catch (_) { 261 showStatus("[data-settings-journal-output]", t("account.settings.journal.exportUnavailable"), "error"); 262 return; 263 } 264 if (!response.ok) { 265 if (response.status === 401) { 266 showStatus("[data-settings-journal-output]", t("account.settings.profile.sessionExpired"), "error"); 267 } else { 268 showStatus("[data-settings-journal-output]", t("account.settings.journal.exportHttpFailed", { status: response.status }), "error"); 269 } 270 return; 271 } 272 let blob; 273 try { 274 blob = await response.blob(); 275 } catch (_) { 276 showStatus("[data-settings-journal-output]", t("account.settings.journal.exportDownloadFailed"), "error"); 277 return; 278 } 279 const url = URL.createObjectURL(blob); 280 const a = document.createElement("a"); 281 a.href = url; 282 a.download = fmt === "csv" ? "gex-journal-export.csv" : "gex-journal-export.json"; 283 document.body.appendChild(a); 284 a.click(); 285 document.body.removeChild(a); 286 setTimeout(() => URL.revokeObjectURL(url), 2000); 287 showStatus("[data-settings-journal-output]", t("account.settings.journal.exported", { format: fmt.toUpperCase() }), "ok"); 288 } 289 290 // ---- Erase journal data ----------------------------------------------------- 291 function wireEraseGate() { 292 const checkbox = $("[data-settings-erase-confirm]"); 293 const btn = $("[data-settings-erase]"); 294 if (!checkbox || !btn) return; 295 checkbox.addEventListener("change", () => { 296 btn.disabled = !checkbox.checked; 297 }); 298 } 299 300 async function eraseJournalData() { 301 const checkbox = $("[data-settings-erase-confirm]"); 302 const btn = $("[data-settings-erase]"); 303 if (!checkbox || !checkbox.checked) return; 304 // Second, deliberate confirmation for a destructive, irreversible action. 305 const confirmed = window.confirm( 306 t("account.settings.journal.eraseConfirm") 307 ); 308 if (!confirmed) return; 309 310 if (btn) btn.disabled = true; 311 showStatus("[data-settings-journal-output]", t("account.settings.journal.erasing")); 312 const res = await apiJson("/journal/account/delete", { method: "POST", body: JSON.stringify({}) }); 313 if (res.ok && res.payload && res.payload.purged) { 314 const count = Number(res.payload.deleted_trades || 0); 315 showStatus( 316 "[data-settings-journal-output]", 317 t(count === 1 ? "account.settings.journal.erasedSingular" : "account.settings.journal.erasedPlural", { count: count }), 318 "ok" 319 ); 320 checkbox.checked = false;
321 } else if (res.status === 401) { 322 showStatus("[data-settings-journal-output]", t("account.settings.profile.sessionExpired"), "error"); 323 if (btn) btn.disabled = false; 324 } else { 325 showStatus("[data-settings-journal-output]", t("account.settings.journal.eraseFailed"), "error"); 326 if (btn) btn.disabled = false; 327 } 328 } 329 330 // ---- wire up ---------------------------------------------------------------- 331 function on(sel, event, handler) { 332 const el = $(sel); 333 if (el) el.addEventListener(event, handler); 334 } 335 336 function init() { 337 const start = () => { 338 wireEraseGate(); 339 on("[data-settings-resend]", "click", resendVerification); 340 on("[data-settings-password]", "click", requestPasswordReset); 341 on("[data-settings-erase]", "click", eraseJournalData); 342 document.querySelectorAll("[data-settings-export]").forEach((el) => { 343 el.addEventListener("click", () => exportJournal(el.getAttribute("data-settings-export"))); 344 }); 345 loadProfile(); 346 }; 347 const i18n = window.GexI18n; 348 if (i18n && typeof i18n.ready === "function") i18n.ready().then(start); 349 else start(); 350 } 351 352 if (document.readyState === "loading") { 353 document.addEventListener("DOMContentLoaded", init); 354 } else { 355 init(); 356 } 357})();
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.