PageSourceSearch

https://accounts.jimbin.com/account-assistance/js/forgot-password.js

js jimbin.com collected 2026-10-08 11:47:28 UTC 9,126 bytes, 265 lines download raw bytes

1(() => {
2  const form = document.getElementById("aa-forgot-form");
3  const emailInput = document.getElementById("aa-email");
4  const emailError = document.getElementById("aa-email-error");
5  const submitButton = document.getElementById("aa-submit");
6  const sentEmail = document.getElementById("aa-sent-email");
7  const backToSite = document.getElementById("aa-back-to-site");
8  const antiForgeryToken = form.querySelector('input[name="__RequestVerificationToken"]');
9  const submitText = submitButton.textContent.trim();
10  const successCooldownSeconds = 60;
11  const emailPattern = /^[A-Z0-9._-]+@([A-Z0-9-]+\.)+[A-Z]{2,63}$/i;
12  const maxEmailLength = 50;
13  const validateDebounceMs = 400;
14  const resendCooldownCookieName = "forgotPasswordResendUntil";
15  const resendCooldownCookiePath = "/";
16  let requestInProgress = false;
17  let ipLimited = false;
18  let countdownSeconds = 0;
19  let countdownTimer;
20  let validateTimer;
21  let emailErrorShown = false;
22
23  const isEmailValid = () => {
24    const value = emailInput.value.trim();
25    return emailPattern.test(value) && value.length < maxEmailLength;
26  };
27
28  const updateSubmitState = () => {
29    const disabled =
30      requestInProgress ||
31      ipLimited ||
32      countdownSeconds > 0 ||
33      !isEmailValid();
34    submitButton.disabled = disabled;
35    // The design system paints the disabled state from a class, not :disabled — both, so the
36    // button neither looks enabled while it is dead nor stays clickable while it looks dead.
37    submitButton.classList.toggle("btn--disabled", disabled);
38  };
39
40  const showError = message => {
41    emailErrorShown = true;
42    emailError.textContent = message;
43    emailError.style.display = "block";
44    emailInput.classList.add("_error");
45    emailInput.setAttribute("aria-invalid", "true");
46  };
47
48  const clearError = () => {
49    emailErrorShown = false;
50    emailError.textContent = "";
51    emailError.style.display = "none";
52    emailInput.classList.remove("_error");
53    emailInput.removeAttribute("aria-invalid");
54  };
55
56  // The cookie carries the moment the cooldown ends, not the seconds left in it. A count only means
57  // anything at the instant it was written, so a tab closed at 40 and reopened 20 seconds later used
58  // to resume at 40 and make the player wait twice. A deadline is read the same however long it sat.
59  //
60  // Path "/" because English is served at the bare path while every other language sits under a
61  // segment, and a cookie path is matched as a plain prefix: scoped to /account-assistance it
62  // reached the English page and no other (TWPF-2155).
63  const readCooldownSeconds = () => {
64    const deadline = Number(readCookie(resendCooldownCookieName)) || 0;
65    return Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
66  };
67
68  const writeCooldownCookie = secondsLeft =>
69    writeCookie(resendCooldownCookieName, Date.now() + secondsLeft * 1000, {
70      path: resendCooldownCookiePath,
71      maxAgeSeconds: secondsLeft,
72      sameSite: "lax",
73      secure: window.location.protocol === "https:",
74    });
75
76  const clearCooldownCookie = () =>
77    writeCookie(resendCooldownCookieName, "", {
78      path: resendCooldownCookiePath,
79      maxAgeSeconds: 0,
80      sameSite: "lax",
81    });
82
83  const updateCountdown = () => {
84    submitButton.textContent = window.aaResources.resendIn.replace("{sec}", countdownSeconds);
85    updateSubmitState();
86  };
87
88  const stopCountdown = () => {
89    window.clearInterval(countdownTimer);
90    countdownSeconds = 0;
91    clearCooldownCookie();
92    submitButton.textContent = submitText;
93    updateSubmitState();
94  };
95
96  const startCountdown = seconds => {
97    window.clearInterval(countdownTimer);
98    countdownSeconds = Math.max(1, Number(seconds) || successCooldownSeconds);
99    writeCooldownCookie(countdownSeconds);
100    updateCountdown();
101    countdownTimer = window.setInterval(() => {
102      countdownSeconds -= 1;
103      if (countdownSeconds <= 0) {
104        stopCountdown();
105        return;
106      }
107
108      updateCountdown();
109    }, 1000);
110  };
111
112  const validateEmail = () => {
113    if (isEmailValid()) {
114      clearError();
115      return true;
116    }
117
118    showError(window.aaResources.emailInvalid);
119    return false;
120  };
121
122  const sendMagicLinkRequest = () =>
123    window.fetch(window.aaConfig.sendMagicLinkEndpoint, {
124      method: "POST",
125      headers: {
126        "Content-Type": "application/json",
127        "RequestVerificationToken": antiForgeryToken.value,
128      },
129      body: JSON.stringify({
130        email: emailInput.value.trim(),
131        refId: window.aaConfig.refId,
132      }),
133    });
134
135  const readResponse = async response => {
136    const responseBody = await response.json();
137    if (response.ok) {
138      return responseBody;
139    }
140
141    // A 429 is the rate limiter, and it carries a state like any other outcome — so it is read
142    // rather than thrown, and never reaches the generic-failure toast (TWPF-2326). The remaining
143    // seconds travel on the Retry-After header instead of the body (TWPF-2325), so they are
144    // merged in here and handleResponse sees one shape either way. Absent for ipLimited, which
145    // withholds them on purpose, so null is the normal case rather than a fault.
146    if (response.status === 429) {
147      const retryAfter = Number(response.headers.get("Retry-After"));
148      return { ...responseBody, retryAfterSeconds: retryAfter > 0 ? retryAfter : null };
149    }
150
151    if (responseBody.error === "emailInvalid") {
152      showError(window.aaResources.emailInvalid);
153      return null;
154    }
155
156    throw new Error("Send-magic-link request failed.");
157  };
158
159  const handleResponse = response => {
160    if (response.state === "success") {
161      sentEmail.textContent = emailInput.value.trim();
162      toggleModal("#modalCheckEmail");
163      startCountdown(successCooldownSeconds);
164      return;
165    }
166
167    // The server's own remaining seconds, lifted off Retry-After by readResponse — authoritative
168    // where a local countdown is a guess, which is what a refresh or a second device leaves us
169    // with. startCountdown falls back to successCooldownSeconds when the header was missing.
170    if (response.state === "cooldown") {
171      showError(window.aaResources.cooldown);
172      startCountdown(response.retryAfterSeconds);
173      return;
174    }
175
176    // Disabled, but not counted down. The window ends when the oldest request from this IP ages
177    // out, so the wait is almost always shorter than the window and the page has no way to know
178    // by how much — the server withholds it on purpose, so that a response never hands out the
179    // moment the window reopens. Announcing a number would be wrong and would give away what the
180    // server withheld; the AC asks only for the state and a disabled Submit. Reload is the way
181    // back, and the server decides again.
182    if (response.state === "ipLimited") {
183      showError(window.aaResources.ipLimited);
184      ipLimited = true;
185      return;
186    }
187
188    throw new Error("Unknown forgot-password response.");
189  };
190
191  const submitForm = async event => {
192    event.preventDefault();
193    window.clearTimeout(validateTimer);
194    clearError();
195
196    if (!validateEmail()) {
197      return;
198    }
199
200    requestInProgress = true;
201    updateSubmitState();
202
203    try {
204      const response = await sendMagicLinkRequest();
205      const responseBody = await readResponse(response);
206      if (responseBody !== null) {
207        handleResponse(responseBody);
208      }
209    } catch {
210      showError(window.aaResources.requestFailed);
211    } finally {
212      requestInProgress = false;
213      updateSubmitState();
214    }
215  };
216
217  // Null when the request arrived on a host Forseti does not vouch for — no site to offer a way
218  // back to, so the button closes the modal instead of navigating somewhere unverified.
219  backToSite.addEventListener("click", () => {
220    if (window.aaConfig.landingUrl) {
221      window.location.assign(window.aaConfig.landingUrl);
222      return;
223    }
224
225    toggleModal("#modalCheckEmail", false);
226  });
227  emailInput.addEventListener("input", () => {
228    updateSubmitState();
229    window.clearTimeout(validateTimer);
230    if (emailInput.value.trim().length === 0) {
231      clearError();
232      return;
233    }
234
235    // Retract a standing error the moment it stops being true, but never raise a new one mid-word:
236    // clearing early reads as the field being fixed, while scolding early reads as impatience.
237    if (emailErrorShown && isEmailValid()) {
238      clearError();
239    }
240
241    validateTimer = window.setTimeout(validateEmail, validateDebounceMs);
242  });
243  // Leaving the field is the player saying they are done typing, so the debounce has nothing left to
244  // wait for.
245  emailInput.addEventListener("blur", () => {
246    window.clearTimeout(validateTimer);
247    if (emailInput.value.trim().length === 0) {
248      clearError();
249      return;
250    }
251
252    validateEmail();
253  });
254  form.addEventListener("submit", submitForm);
255
256  const resumeCountdownLeftByPreviousVisit = () => {
257    const secondsLeft = readCooldownSeconds();
258    if (secondsLeft > 0) {
259      startCountdown(secondsLeft);
260    }
261  };
262
263  resumeCountdownLeftByPreviousVisit();
264  updateSubmitState();
265})();

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.