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.