PageSourceSearch

https://gex-levels.com/assets/settings.js

js gex-levels.com collected 2026-10-02 01:31:00 UTC 14,452 bytes, 357 lines download raw bytes

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("<", "&lt;")
53      .replaceAll(">", "&gt;")
54      .replaceAll('"', "&quot;")
55      .replaceAll("'", "&#039;");
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.