PageSourceSearch

https://cardcompass.app/login

html cardcompass.app collected 2026-09-24 14:30:29 UTC 43,667 bytes, 906 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3<head>
4  
4<script>
5    // Password-reset links carry their token in the URL fragment. Lift it out and
6    // clear it before analytics loads, so it never lands in history or GA.
7    var ccResetToken = null;
8    (function () {
9      var m = location.hash.match(/^#reset=([A-Za-z0-9_-]{20,100})$/);
10      if (m) { ccResetToken = m[1]; history.replaceState(null, '', location.pathname + location.search); }
11    })();
12  </script>
12
13  <!-- Google tag (gtag.js) -->
vendor: 66 bytes, lines 13-14
13
14  <script async src="https://www.googletagmanager.com/gtag/js?id=
14G-CYNSKD3EBQ
vendor: 14 bytes, lines 14-15
14"></script>
15  
15<script>
16    window.dataLayer = window.dataLayer || [];
17    function gtag(){dataLayer.push(arguments);}
18    gtag('js', new Date());
19
20    gtag('consent', 'default', { analytics_storage: (function () { try { var s = localStorage.getItem('cc_consent'); if (s) return s; return /^Europe\//.test(Intl.DateTimeFormat().resolvedOptions().timeZone) ? 'denied' : 'granted'; } catch (e) { return 'denied'; } })() });
21    gtag('config', 'G-CYNSKD3EBQ');
22  </script>
22
23  <meta charset="UTF-8">
24  <meta name="viewport" content="width=device-width, initial-scale=1.0">
25  <title>Sign In — CardCompass</title>
26  <meta name="description" content="Sign in to your CardCompass account to see your personalized credit card recommendations.">
27  <meta name="robots" content="noindex,nofollow">
28  <link rel="icon" href="images/logo-icon.svg" type="image/png">
29  <link rel="icon" href="favicon.svg" type="image/svg+xml">
30  <link rel="manifest" href="site.webmanifest">
31  <meta name="theme-color" content="#071E4A">
32  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
33  
33<script>
34  window.onGoogleLibraryLoad = function() {
35    google.accounts.id.initialize({
36      client_id: '561152908430-5md4eadoq0n9uvp20omh3j8gtll35utb.apps.googleusercontent.com',
37      callback: handleGoogleCredential,
38      use_fedcm_for_prompt: false
39    });
40    var container = document.getElementById('g-btn-container');
41    if (container) {
42      google.accounts.id.renderButton(container, {
43        theme: 'outline',
44        size: 'large',
45        // GSI clamps to [200,400]; a hardcoded 400 overflowed the ~306px card on phones
46        width: Math.max(200, Math.min(400, container.offsetWidth)),
47        text: 'continue_with',
48        shape: 'rectangular',
49        logo_alignment: 'left'
50      });
51    }
52  };
53  setTimeout(function() {
54    if (typeof google === 'undefined' || !google.accounts) {
55      var c = document.getElementById('g-btn-container');
56      if (c && !c.querySelector('iframe')) {
57        c.innerHTML = '<p style="font-size:13px;color:#DC2626;text-align:center;margin:10px 0;">Google sign-in unavailable. Please use email & password below.</p>';
58      }
59    }
60  }, 5000);
61  </script>
61
62  
62<script src="https://accounts.google.com/gsi/client" async defer></script>
62
63  
63<script>
64  // Explicit render: the migrate/forgot widgets sit in hidden views, so they render when their view opens
65  // (or here on script load, if that view opened first — e.g. arriving from an expired reset link).
66  var ccTs = { login: null, migrate: null, forgot: null };
67  function ccRenderTurnstile(key) {
68    if (ccTs[key] !== null || !window.turnstile) return;
69    ccTs[key] = window.turnstile.render('#ts-' + key, { sitekey: '0x4AAAAAADiVOynXw_ZhYDP0' });
70  }
71  function ccTurnstileReady() {
72    ccRenderTurnstile('login');
73    ['migrate', 'forgot'].forEach(function (k) {
74      var view = document.getElementById(k + '-view');
75      if (view && view.style.display === 'block') ccRenderTurnstile(k);
76    });
77  }
78  function ccTurnstileToken(key) {
79    try { return ccTs[key] !== null ? window.turnstile.getResponse(ccTs[key]) : ''; } catch (e) { return ''; }
80  }
81  function ccTurnstileReset(key) {
82    try { if (ccTs[key] !== null) window.turnstile.reset(ccTs[key]); } catch (e) {}
83  }
84  </script>
84
85  
85<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=ccTurnstileReady" async defer></script>
85
86  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer">
87  <style>
88    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
89    :root {
90      --primary: #0B2D6B; --primary-dark: #071E4A; --primary-light: #1A4D9E;
91      --accent: #1E7FD4; --accent-light: #4BA3F0; --accent-gold: #F5A623;
92      --text-dark: #1A1A2E; --text-gray: #4A5568; --text-light: #718096;
93      --bg-light: #F7F9FC; --border: #E2E8F0;
94      --success: #059669; --error: #DC2626;
95      --r-sm: 8px; --r-md: 12px;
96    }
97
98    body { font-family: 'Inter', sans-serif; color: var(--text-dark); min-height: 100vh; display: flex; }
99
100    /* ─── SKIP LINK ─── */
101    .skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #fff; padding: 10px 18px; border-radius: 0 0 var(--r-sm) var(--r-sm); font-size: 14px; font-weight: 700; text-decoration: none; z-index: 9999; }
102    .skip-link:focus { top: 0; }
103
104    /* ═══════ LEFT BRAND PANEL ═══════ */
105    .auth-left {
106      width: 50%;
107      background: var(--primary-dark);
108      display: flex;
109      flex-direction: column;
110      padding: 48px 56px;
111      position: relative;
112      overflow: hidden;
113    }
114    /* Decorative glows */
115    .auth-left::before {
116      content: '';
117      position: absolute;
118      top: -100px; right: -120px;
119      width: 500px; height: 500px;
120      background: radial-gradient(circle, rgba(30,127,212,.18) 0%, transparent 65%);
121      border-radius: 50%;
122    }
123    .auth-left::after {
124      content: '';
125      position: absolute;
126      bottom: -140px; left: -100px;
127      width: 480px; height: 480px;
128      background: radial-gradient(circle, rgba(75,163,240,.1) 0%, transparent 65%);
129      border-radius: 50%;
130    }
131
132    /* Logo in left panel */
133    .auth-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; position: relative; z-index: 1; }
134    .auth-logo-icon { width: 42px; height: 42px; border-radius: 50%; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
135    .auth-logo-icon img { width: 100%; height: 100%; object-fit: cover; display: block; }
136    .auth-logo-text { font-size: 21px; font-weight: 800; color: #fff; letter-spacing: -.5px; }
137    .auth-logo-text span { color: var(--accent-light); }
138
139    /* Center content */
140    .auth-left-body { flex: 1; display: flex; flex-direction: column; justify-content: center; position: relative; z-index: 1; padding: 40px 0; }
141    .auth-left-body h1 { font-size: 36px; font-weight: 800; color: #fff; letter-spacing: -1px; line-height: 1.2; margin-bottom: 16px; }
142    .auth-left-body h1 span { color: var(--accent-light); }
143    .auth-left-body > p { font-size: 16px; color: rgba(255,255,255,.6); line-height: 1.65; margin-bottom: 36px; }
144
145    .auth-benefits { list-style: none; display: flex; flex-direction: column; gap: 16px; margin-bottom: 44px; }
146    .auth-benefits li { display: flex; align-items: flex-start; gap: 12px; }
147    .auth-benefits li i { color: var(--accent-light); font-size: 16px; margin-top: 2px; flex-shrink: 0; }
148    .auth-benefits li span { font-size: 15px; color: rgba(255,255,255,.82); line-height: 1.5; }
149
150    /* Quote card */
151    .auth-quote { background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.1); border-left: 3px solid var(--accent-gold); border-radius: 0 var(--r-md) var(--r-md) 0; padding: 18px 20px; }
152    .auth-quote blockquote { font-size: 14px; color: rgba(255,255,255,.75); line-height: 1.7; font-style: italic; margin-bottom: 12px; }
153    .auth-quote-author { display: flex; align-items: center; gap: 10px; }
154    .auth-quote-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--primary-light); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
155    .auth-quote-info .name { font-size: 13px; font-weight: 700; color: rgba(255,255,255,.9); }
156    .auth-quote-info .card { font-size: 11px; color: rgba(255,255,255,.45);
156 margin-top: 1px; }
157
158    /* Stats row */
159    .auth-stats { display: flex; gap: 28px; margin-bottom: 44px; flex-wrap: wrap; }
160    .auth-stat { }
161    .auth-stat .val { font-size: 26px; font-weight: 800; color: #fff; letter-spacing: -1px; display: block; }
162    .auth-stat .lbl { font-size: 12px; color: rgba(255,255,255,.45); margin-top: 2px; line-height: 1.4; }
163
164    /* Left footer */
165    .auth-left-foot { position: relative; z-index: 1; font-size: 12px; color: rgba(255,255,255,.25); display: flex; gap: 16px; flex-wrap: wrap; }
166    .auth-left-foot a { color: rgba(255,255,255,.25); text-decoration: none; transition: color .2s; }
167    .auth-left-foot a:hover { color: rgba(255,255,255,.6); }
168
169    /* ═══════ RIGHT FORM PANEL ═══════ */
170    .auth-right {
171      flex: 1;
172      background: #fff;
173      display: flex;
174      flex-direction: column;
175      align-items: center;
176      justify-content: center;
177      padding: 48px 40px;
178      overflow-y: auto;
179    }
180
181    .auth-form-wrap { width: 100%; max-width: 400px; }
182
183    /* Mobile logo (only shown on mobile) */
184    .mobile-logo { display: none; align-items: center; gap: 10px; text-decoration: none; margin-bottom: 36px; }
185    .mobile-logo-icon { width: 40px; height: 40px; border-radius: 50%; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
186    .mobile-logo-icon img { width: 100%; height: 100%; object-fit: cover; display: block; }
187    .mobile-logo-text { font-size: 20px; font-weight: 800; color: var(--text-dark); letter-spacing: -.5px; }
188    .mobile-logo-text span { color: var(--accent); }
189
190    .auth-form-heading { font-size: 28px; font-weight: 800; letter-spacing: -.6px; margin-bottom: 6px; }
191    .auth-form-sub { font-size: 14px; color: var(--text-gray); margin-bottom: 32px; line-height: 1.55; }
192    .auth-form-sub a { color: var(--accent); text-decoration: none; font-weight: 600; }
193    .auth-form-sub a:hover { text-decoration: underline; }
194
195    /* Form elements */
196    .form-group { margin-bottom: 18px; }
197    .form-group label { display: flex; align-items: center; justify-content: space-between; font-size: 13px; font-weight: 600; margin-bottom: 7px; color: var(--text-dark); }
198    .forgot-link { font-size: 13px; font-weight: 500; color: var(--accent); text-decoration: none; }
199    .forgot-link:hover { text-decoration: underline; }
200    .form-group input {
201      width: 100%; padding: 13px 16px; border: 1.5px solid var(--border); border-radius: var(--r-sm);
202      font-size: 15px; color: var(--text-dark); background: #fff; outline: none;
203      transition: border-color .2s, box-shadow .2s; font-family: inherit;
204    }
205    .form-group input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(30,127,212,.12); }
206    .form-group input.err { border-color: var(--error); }
207
208    .pass-wrap { position: relative; }
209    .pass-wrap input { padding-right: 50px; }
210    .pass-toggle { position: absolute; right: 14px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-light); font-size: 15px; padding: 4px; line-height: 1; transition: color .2s; }
211    .pass-toggle:hover { color: var(--text-dark); }
212
213    /* Remember me row */
214    .remember-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
215    .form-check-inline { display: flex; align-items: center; gap: 8px; cursor: pointer; }
216    .form-check-inline input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
217    .form-check-inline span { font-size: 14px; color: var(--text-gray); }
218
219    /* Error/success alerts */
220    .alert { display: none; align-items: flex-start; gap: 10px; padding: 14px 16px; border-radius: var(--r-sm); font-size: 14px; margin-bottom: 18px; line-height: 1.5; }
221    .alert.show { display: flex; }
222    .alert-error { background: #FEF2F2; border: 1px solid #FCA5A5; color: #991B1B; }
223    .alert-success { background: #ECFDF5; border: 1px solid #6EE7B7; color: #065F46; }
224    .alert i { flex-shrink: 0;
224 margin-top: 1px; }
225
226    /* Forgot password state */
227    .forgot-form { display: none; }
228    .forgot-form.show { display: block; }
229    .forgot-back { display: inline-flex; align-items: center; gap: 7px; background: none; border: none; color: var(--text-gray); font-size: 14px; font-weight: 500; cursor: pointer; padding: 0; margin-bottom: 20px; font-family: inherit; transition: color .2s; }
230    .forgot-back:hover { color: var(--primary); }
231
232    /* Primary button */
233    .btn-signin {
234      width: 100%; padding: 15px; background: var(--primary); color: #fff; border: none;
235      border-radius: var(--r-sm); font-size: 16px; font-weight: 700; cursor: pointer;
236      transition: all .2s; font-family: inherit; display: flex; align-items: center;
237      justify-content: center; gap: 8px;
238    }
239    .btn-signin:hover { background: var(--primary-light); transform: translateY(-1px); box-shadow: 0 6px 22px rgba(11,45,107,.28); }
240    .btn-signin:active { transform: translateY(0); }
241    .btn-signin .spinner { display: none; width: 17px; height: 17px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
242    .btn-signin.loading .btn-text { display: none; }
243    .btn-signin.loading .spinner { display: block; }
244    @keyframes spin { to { transform: rotate(360deg); } }
245
246    /* Divider */
247    .auth-divider { display: flex; align-items: center; gap: 14px; margin: 22px 0; }
248    .auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
249    .auth-divider span { font-size: 13px; color: var(--text-light); font-weight: 500; white-space: nowrap; }
250
251    /* Google button */
252    .btn-google {
253      width: 100%; padding: 13px 16px; background: #fff; color: var(--text-dark);
254      border: 1.5px solid var(--border); border-radius: var(--r-sm); font-size: 15px;
255      font-weight: 600; cursor: pointer; transition: all .2s; font-family: inherit;
256      display: flex; align-items: center; justify-content: center; gap: 10px;
257    }
258    .btn-google:hover { background: var(--bg-light); border-color: #aaa; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
259    .btn-google svg { flex-shrink: 0; }
260
261    /* Form footer */
262    .auth-form-footer { text-align: center; margin-top: 28px; font-size: 14px; color: var(--text-gray); }
263    .auth-form-footer a { color: var(--accent); text-decoration: none; font-weight: 600; }
264    .auth-form-footer a:hover { text-decoration: underline; }
265
266    /* Toast notification */
267    .toast { position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%) translateY(80px); background: var(--text-dark); color: #fff; padding: 13px 22px; border-radius: var(--r-md); font-size: 14px; font-weight: 500; z-index: 9999; opacity: 0; transition: all .35s cubic-bezier(.4,0,.2,1); white-space: nowrap; box-shadow: 0 8px 28px rgba(0,0,0,.25); }
268    .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
269
270    /* ─── RESPONSIVE ─── */
271    @media (max-width: 900px) {
272      .auth-left { width: 45%; padding: 40px 36px; }
273      .auth-left-body h1 { font-size: 28px; }
274      .auth-stats { gap: 20px; }
275      .auth-stat .val { font-size: 22px; }
276    }
277    @media (max-width: 680px) {
278      body { flex-direction: column; }
279      .auth-left { display: none; }
280      .auth-right { flex: none; width: 100%; min-height: 100vh; align-items: center; justify-content: flex-start; padding-top: 48px; }
281      .mobile-logo { display: flex; }
282    }
283    @media (max-width: 420px) {
284      .auth-right { padding: 40px 24px; }
285      .auth-form-heading { font-size: 24px; }
286    }
287    /* iOS Safari zooms the viewport on focus for any field under 16px and never zooms back. */
288    @media (max-width: 1024px) { input, select, textarea { font-size: 16px !important; } }
289  </style>
290</head>
291<body>
292<a href="#login-form" class="skip-link">Skip to sign-in form</a>
293
294<div class="auth-left" aria-hidden="true">
295  <!-- Logo -->
296  <a href="/" class="auth-logo">
297    <div class="auth-logo-icon"><img src="images/logo-icon.svg" alt="CardCompass compass logo" width="42" height="42"></div>
298    <span class="auth-logo-text">Card<span>Compass</span></span>
299  </a>
300
301  <!-- Main content -->
302  <div class="auth-left-body">
303    <h1>Welcome back to <span>smarter</span> spending.</h1>
304    <p>Your personalized card recommendations and rewards potential are waiting for you.</p>
305
306    <!-- Stats -->
307    <div class="auth-stats">
308      <div class="auth-stat">
309        <span class="val">114</span>
310        <span class="lbl">Cards compared<br>and ranked</span>
311      </div>
312      <div class="auth-stat">
313        <span class="val">$0</span>
314        <span class="lbl">Free<br>forever</span>
315      </div>
316    </div>
317
318    <!-- Benefits -->
319    <ul class="auth-benefits">
320      <li>
321        <i class="fas fa-compass"></i>
322        <span>See your personalized card matches, ranked by how much you'd actually earn</span>
323      </li>
324      <li>
325        <i class="fas fa-chart-line"></i>
326        <span>Track your rewards earning potential across all your cards</span>
327      </li>
328      <li>
329        <i class="fas fa-bell"></i>
330        <span>Get notified when better sign-up bonuses go live for your top cards</span>
331      </li>
332      <li>
333        <i class="fas fa-shield-alt"></i>
334        <span>No credit card needed — CardCompass is free, forever</span>
335      </li>
336    </ul>
337
338    <!-- Quote -->
339    <div class="auth-quote">
340      <blockquote>"I never knew which card actually fit my spending. CardCompass ranked everything by what I'd actually earn — I switched cards and I'm up over $800 this year."</blockquote>
341      <div class="auth-quote-author">
342        <div class="auth-quote-avatar">JR</div>
343        <div class="auth-quote-info">
344          <div class="name">James R.</div>
345          <div class="card">Chase Sapphire Preferred® cardholder</div>
346        </div>
347      </div>
348    </div>
349  </div>
350
351  <!-- Left footer -->
352  <div class="auth-left-foot">
353    <span>© 2026 CardCompass, Inc.</span>
354    <a href="/privacy-policy">Privacy</a>
355    <a href="/terms">Terms</a>
356    <a href="/advertiser-disclosure">Disclosure</a>
357  </div>
358</div>
359
360<!-- ═══════════ RIGHT FORM PANEL ═══════════ -->
361<div class="auth-right">
362  <div class="auth-form-wrap" id="login-form">
363
364    <!-- Mobile-only logo -->
365    <a href="/" class="mobile-logo">
366      <div class="mobile-logo-icon"><img src="images/logo-icon.svg" alt="CardCompass compass logo" width="40" height="40"></div>
367      <span class="mobile-logo-text">Card<span>Compass</span></span>
368    </a>
369
370    <!-- ─── SIGN IN FORM ─── -->
371    <div id="login-view">
372      <h2 class="auth-form-heading">Sign in to your account</h2>
373      <p class="auth-form-sub">
374        New to CardCompass? <a href="/get-started">Create a free account →</a>
375      </p>
376
377      <div class="alert alert-error" id="login-error" role="alert">
378        <i class="fas fa-exclamation-circle"></i>
379        <span id="login-error-msg">Incorrect email or password. Please try again.</span>
380      </div>
381
382      <div class="form-group">
383        <label for="l-email">Email address</label>
384        <input type="email" id="l-email" placeholder="[email protected]" autocomplete="email">
385      </div>
386
387      <div class="form-group">
388        <label for="l-pass">
389          Password
390          <a href="#" class="forgot-link" onclick="showForgot(event)">Forgot password?</a>
391        </label>
392        <div class="pass-wrap">
393          <input type="password" id="l-pass" placeholder="Your password" autocomplete="current-password">
394          <button type="button" class="pass-toggle" onclick="togglePass('l-pass', this)" aria-label="Show password">
395            <i class="fas fa-eye"></i>
396          </button>
397        </div>
398      </div>
399
400      <div class="remember-row">
401        <label class="form-check-inline">
402          <input type="checkbox" id="l-remember">
403          <span>Remember me</span>
404        </label>
405      </div>
406
407      <div id="ts-login" style="margin:0 0 14px;"></div>
408
409      <button class="btn-signin" id="signin-btn" onclick="signIn()">
410        <div class="spinner"></div>
411        <span class="btn-text">Sign In &nbsp;<i class="fas fa-arrow-right"></i></span>
412      </button>
413
414      <div class="auth-divider"><span>or continue with</span></div>
415
416      <div id="g-btn-container" style="margin-bottom:0;min-height:44px;display:flex;justify-content:center;"></div>
417
418      <div class="auth-form-footer">
419        Don't have an account? <a href="/get-started">Get Started Free →</a>
420      </div>
421    </div>
422
423    <!-- ─── ACCOUNT MIGRATION VIEW ─── -->
424    <div id="migrate-view" style="display:none;">
425      <button class="forgot-back" onclick="hideMigrate()">
426        <i class="fas fa-arrow-left"></i> Back to Sign In
427      </button>
428      <h2 class="auth-form-heading">We've upgraded CardCompass accounts</h2>
429      <p class="auth-form-sub" style="margin-bottom:28px;">
430        We found a saved profile for <strong id="migrate-email-display"></strong> on this device. Set a new password to move it to the upgraded, more secure account system — your name, preferences, and saved cards will carry over.
431      </p>
432
433      <div class="alert alert-error" id="migrate-error" role="alert">
434        <i class="fas fa-exclamation-circle"></i>
435        <span id="migrate-error-msg"></span>
436      </div>
437
438      <div class="form-group">
439        <label for="m-pass">New password</label>
440        <div class="pass-wrap">
441          <input type="password" id="m-pass" placeholder="Create a password (8+ characters)" autocomplete="new-password">
442          <button type="button" class="pass-toggle" onclick="togglePass('m-pass', this)" aria-label="Show password">
443            <i class="fas fa-eye"></i>
444          </button>
445        </div>
446      </div>
447
448      <div id="ts-migrate" style="margin:0 0 14px;"></div>
449
450      <button class="btn-signin" id="migrate-btn" onclick="submitMigration()">
451        <div class="spinner"></div>
452        <span class="btn-text">Continue &nbsp;<i class="fas fa-arrow-right"></i></span>
453      </button>
454    </div>
455
456    <!-- ─── FORGOT PASSWORD VIEW ─── -->
457    <div id="forgot-view" style="display:none;">
458      <button class="forgot-back" onclick="hideForgot()">
459        <i class="fas fa-arrow-left"></i> Back to Sign In
460      </button>
461      <h2 class="auth-form-heading">Reset your password</h2>
462      <p class="auth-form-sub" style="margin-bottom:28px;">Enter your email and we'll send you a link to reset your password.</p>
463
464      <div class="alert alert-success" id="reset-success" role="status">
465        <i class="fas fa-check-circle"></i>
466        <span>If an account exists for that email, you'll receive a reset link shortly. Check your inbox.</span>
467      </div>
468
469      <div class="alert alert-error" id="reset-error" role="alert">
470        <i class="fas fa-exclamation-circle"></i>
471        <span id="reset-error-msg">Please enter a valid email address.</span>
472      </div>
473
474      <div class="form-group" id="reset-email-group">
475        <label for="r-email">Email address</label>
476        <input type="email" id="r-email" placeholder="[email protected]" autocomplete="email">
477      </div>
478
479      <div id="ts-forgot" style="margin:0 0 14px;"></div>
480
481      <button class="btn-signin" id="reset-btn" onclick="sendReset()">
482        <div class="spinner"></div>
483        <span class="btn-text">Send Reset Link &nbsp;<i class="fas fa-paper-plane"></i></span>
484      </button>
485    </div>
486
487    <!-- ─── SET NEW PASSWORD VIEW (from the emailed reset link) ─── -->
488    <div id="newpass-view" style="display:none;">
489      <h2 class="auth-form-heading">Choose a new password</h2>
490      <p class="auth-form-sub" style="margin-bottom:28px;">
490This signs you out everywhere else and signs you in here.</p>
491
492      <div class="alert alert-error" id="newpass-error" role="alert">
493        <i class="fas fa-exclamation-circle"></i>
494        <span id="newpass-error-msg"></span>
495      </div>
496
497      <div class="form-group">
498        <label for="np-pass">New password</label>
499        <div class="pass-wrap">
500          <input type="password" id="np-pass" placeholder="At least 8 characters" autocomplete="new-password">
501          <button type="button" class="pass-toggle" onclick="togglePass('np-pass', this)" aria-label="Show password">
502            <i class="fas fa-eye"></i>
503          </button>
504        </div>
505      </div>
506
507      <div class="form-group">
508        <label for="np-confirm">Confirm new password</label>
509        <div class="pass-wrap">
510          <input type="password" id="np-confirm" placeholder="Repeat new password" autocomplete="new-password">
511          <button type="button" class="pass-toggle" onclick="togglePass('np-confirm', this)" aria-label="Show password">
512            <i class="fas fa-eye"></i>
513          </button>
514        </div>
515      </div>
516
517      <button class="btn-signin" id="newpass-btn" onclick="submitNewPassword()">
518        <div class="spinner"></div>
519        <span class="btn-text">Set Password &amp; Sign In &nbsp;<i class="fas fa-arrow-right"></i></span>
520      </button>
521    </div>
522
523  </div><!-- /.auth-form-wrap -->
524</div><!-- /.auth-right -->
525
526<!-- Toast notification -->
527<div class="toast" id="toast"></div>
528
529<script>
530/* ═══════════════════════════════════════════
531   SIGN IN
532═══════════════════════════════════════════ */
533let pendingMigrationEmail = null;
534
535async function signIn() {
536  const email = document.getElementById('l-email').value.trim();
537  const pass  = document.getElementById('l-pass').value;
538  const btn   = document.getElementById('signin-btn');
539  const errEl = document.getElementById('login-error');
540  const remember = document.getElementById('l-remember').checked;
541
542  errEl.classList.remove('show');
543  document.getElementById('l-email').classList.remove('err');
544  document.getElementById('l-pass').classList.remove('err');
545
546  // Basic validation
547  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
548    document.getElementById('login-error-msg').textContent = 'Please enter a valid email address.';
549    errEl.classList.add('show');
550    document.getElementById('l-email').classList.add('err');
551    document.getElementById('l-email').focus();
552    return;
553  }
554  if (pass.length < 8) {
555    document.getElementById('login-error-msg').textContent = 'Password must be at least 8 characters.';
556    errEl.classList.add('show');
557    document.getElementById('l-pass').classList.add('err');
558    document.getElementById('l-pass').focus();
559    return;
560  }
561
562  const cfToken = ccTurnstileToken('login');
563  if (!cfToken) {
564    document.getElementById('login-error-msg').textContent = ccTs.login === null
565      ? 'Our security check could not load. Allow challenges.cloudflare.com (ad blockers often block it) and reload, or use Google sign-in below.'
566      : 'Please complete the verification check above the Sign In button.';
567    errEl.classList.add('show');
568    return;
569  }
570
571  // Loading state
572  btn.classList.add('loading');
573  btn.disabled = true;
574
575  try {
576    const res = await fetch('/api/auth/login', {
577      method: 'POST',
578      headers: { 'Content-Type': 'application/json' },
579      body: JSON.stringify({ email, password: pass, cfToken })
580    });
581    const data = await res.json().catch(() => ({}));
582    // Turnstile tokens are single-use; any response other than a redirect needs a fresh one.
583    if (!res.ok) ccTurnstileReset('login');
584
585    if (res.ok) {
586      localStorage.setItem('cc_token', data.token);
587      localStorage.setItem('cc_user', JSON.stringify(data.user));
588      try { sessionStorage.setItem('cc_token', data.token); } catch(e) {}
589      if (remember) { try { localStorage.setItem('cc_remember_email', email); } catch(e) {} }
590      else { try { localStorage.removeItem('cc_remember_email'); } catch(e) {} }
591
592      sessionStorage.setItem('cc_welcome', `Welcome back, ${data.user.fname || 'there'}!`);
593      window.location.href = '/';
594      return;
595    }
596
597    if (res.status === 401) {
598      // The server no longer says whether an email is registered. A legacy
599      // localStorage-only profile (no server `id`) for this email on this device
600      // is the signal to offer migration; migrate.js 409s if the account exists.
601      let localUser = null;
602      try { localUser = JSON.parse(localStorage.getItem('cc_user') || 'null'); } catch(e) {}
603      if (localUser && !localUser.id && localUser.email && localUser.email.toLowerCase() === email.toLowerCase()) {
604        pendingMigrationEmail = email;
605        showMigrate(localUser);
606        return;
607      }
608      document.getElementById('login-error-msg').textContent = 'Incorrect email or password. If you signed up with Google, use the Google button below.';
609      errEl.classList.add('show');
610      return;
611    }
612
613    document.getElementById('login-error-msg').textContent = data.error || 'Incorrect email or password. Please try again.';
614    errEl.classList.add('show');
615  } catch (e) {
616    ccTurnstileReset('login');
617    document.getElementById('login-error-msg').textContent = 'Network error — please check your connection and try again.';
618    errEl.classList.add('show');
619  } finally {
620    btn.classList.remove('loading');
621    btn.disabled = false;
622  }
623}
624
625/* ═══════════════════════════════════════════
626   GOOGLE SIGN IN
627═══════════════════════════════════════════ */
628async function handleGoogleCredential(response) {
629  try {
630    const res = await fetch('/api/auth/google', {
631      method: 'POST',
632      headers: { 'Content-Type': 'application/json' },
633      body: JSON.stringify({ credential: response.credential })
634    });
635    const data = await res.json().catch(() => ({}));
636    if (!res.ok) throw new Error(data.error || 'Google sign-in failed');
637
638    localStorage.setItem('cc_token', data.token);
639    localStorage.setItem('cc_user', JSON.stringify(data.user));
640    try { sessionStorage.setItem('cc_token', data.token); } catch(e) {}
641    sessionStorage.setItem('cc_welcome', data.passwordCleared
642      ? `Welcome, ${data.user.fname}! Your account now signs in with Google. Set a password in My Account → Settings if you want one too.`
643      : `Welcome, ${data.user.fname}!`);
644    window.location.href = '/';
645  } catch(err) {
646    showToast('Google sign-in failed. Please try email & password.');
647    console.error('[CC Google]', err);
648  }
649}
650
651/* ═══════════════════════════════════════════
652   ACCOUNT MIGRATION (legacy localStorage-only users)
653═══════════════════════════════════════════ */
654function showMigrate(localUser) {
655  document.getElementById('login-view').style.display = 'none';
656  document.getElementById('migrate-view').style.display = 'block';
657  document.getElementById('migrate-email-display').textContent = localUser.email;
658  ccRenderTurnstile('migrate');
659  document.getElementById('m-pass').focus();
660}
661function hideMigrate() {
662  document.getElementById('migrate-view').style.display = 'none';
663  document.getElementById('login-view').style.display = 'block';
664  document.getElementById('migrate-error').classList.remove('show');
665  pendingMigrationEmail = null;
666}
667async function submitMigration() {
668  const pass = document.getElementById('m-pass').value;
669  const errEl = document.getElementById('migrate-error');
670  errEl.classList.remove('show');
671
672  if (!pass || pass.length < 8) {
673    document.getElementById('migrate-error-msg').textContent = 'Password must be at least 8 characters.';
674    errEl.classList.add('show');
675    return;
676  }
677
678  let localUser = null;
679  try { localUser = JSON.parse(localStorage.getItem('cc_user') || 'null'); } catch(e) {}
680  if (!localUser || !pendingMigrationEmail) { hideMigrate(); return; }
681
682  const cfToken = ccTurnstileToken('migrate');
683  if (!cfToken) {
684    document.getElementById('migrate-error-msg').textContent = ccTs.migrate === null
685      ? 'Our security check could not load. Allow challenges.cloudflare.com (ad blockers often block it) and reload.'
686      : 'Please complete the verification check above the Continue button.';
687    errEl.classList.add('show');
688    return;
689  }
690
691  const btn = document.getElementById('migrate-btn');
692  btn.classList.add('loading');
693  btn.disabled = true;
694
695  try {
696    const res = await fetch('/api/auth/migrate', {
697      method: 'POST',
698      headers: { 'Content-Type': 'application/json' },
699      body: JSON.stringify({ localUser, password: pass, cfToken })
700    });
701    const data = await res.json().catch(() => ({}));
702    if (!res.ok) {
703      ccTurnstileReset('migrate');
704      document.getElementById('migrate-error-msg').textContent = data.error || 'Could not migrate your account. Please try again.';
705      errEl.classList.add('show');
706      return;
707    }
708    localStorage.setItem('cc_token', data.token);
709    localStorage.setItem('cc_user', JSON.stringify(data.user));
710    try { sessionStorage.setItem('cc_token', data.token); } catch(e) {}
711    sessionStorage.setItem('cc_welcome', `Welcome back, ${data.user.fname || 'there'}! Your account is now upgraded.`);
712    window.location.href = '/';
713  } catch (e) {
714    ccTurnstileReset('migrate');
715    document.getElementById('migrate-error-msg').textContent = 'Network error — please check your connection and try again.';
716    errEl.classList.add('show');
717  } finally {
718    btn.classList.remove('loading');
719    btn.disabled = false;
720  }
721}
722
723
724/* ═══════════════════════════════════════════
725   FORGOT PASSWORD FLOW
726═══════════════════════════════════════════ */
727function showForgot(e) {
728  e && e.preventDefault();
729  document.getElementById('login-view').style.display = 'none';
730  document.getElementById('forgot-view').style.display = 'block';
731  ccRenderTurnstile('forgot');
732  // Pre-fill email if already entered
733  const email = document.getElementById('l-email').value.trim();
734  if (email) document.getElementById('r-email').value = email;
735  document.getElementById('r-email').focus();
736}
737
738function hideForgot() {
739  document.getElementById('forgot-view').style.display = 'none';
740  document.getElementById('login-view').style.display = 'block';
741  // Reset forgot form state
742  document.getElementById('reset-success').classList.remove('show');
743  document.getElementById('reset-error').classList.remove('show');
744  document.getElementById('reset-email-group').style.display = 'block';
745  document.getElementById('ts-forgot').style.display = 'block';
746  ccTurnstileReset('forgot');
747  const btn = document.getElementById('reset-btn');
748  btn.disabled = false;
749  btn.classList.remove('loading');
750}
751
752async function sendReset() {
753  const email = document.getElementById('r-email').value.trim();
754  const btn = document.getElementById('reset-btn');
755  document.getElementById('reset-error').classList.remove('show');
756  document.getElementById('reset-success').classList.remove('show');
757
758  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
759    document.getElementById('reset-error').classList.add('show');
760    document.getElementById('r-email').focus();
761    return;
762  }
763
764  const cfToken = ccTurnstileToken('forgot');
765  if (!cfToken) {
766    document.getElementById('reset-error-msg').textContent = ccTs.forgot === null
767      ? 'Our security check could not load. Allow challenges.cloudflare.com (ad blockers often block it) and reload.'
768      : 'Please complete the verification check above the button.';
769    document.getElementById('reset-error').classList.add('show');
770    return;
771  }
772
773  btn.classList.add('loading');
774  btn.disabled = true;
775
776  try {
777    const res = await fetch('/api/auth/request-reset', {
778      method: 'POST',
779      headers: { 'Content-Type': 'application/json' },
780      body: JSON.stringify({ email, cfToken })
781    });
782    const data = await res.json().catch(() => ({}));
783    if (!res.ok) {
784      ccTurnstileReset('forgot');
785      document.getElementById('reset-error-msg').textContent = data.error || 'Could not send the reset link. Please try again.';
786      document.getElementById('reset-error').classList.add('show');
787      btn.disabled = false;
788      return;
789    }
790    // The server answers the same whether or not the account exists.
791    document.getElementById('reset-email-group').style.display = 'none';
792    document.getElementById('ts-forgot').style.display = 'none';
793    document.getElementById('reset-success').classList.add('show');
794  } catch (e) {
795    ccTurnstileReset('forgot');
796    document.getElementById('reset-error-msg').textContent = 'Network error — please check your connection and try again.';
797    document.getElementById('reset-error').classList.add('show');
798    btn.disabled = false;
799  } finally {
800    btn.classList.remove('loading');
801  }
802}
803
804/* ═══════════════════════════════════════════
805   SET NEW PASSWORD (from emailed reset link)
806═══════════════════════════════════════════ */
807async function submitNewPassword() {
808  const pass = document.getElementById('np-pass').value;
809  const confirm = document.getElementById('np-confirm').value;
810  const errEl = document.getElementById('newpass-error');
811  const msgEl = document.getElementById('newpass-error-msg');
812  errEl.classList.remove('show');
813
814  if (pass.length < 8) { msgEl.textContent = 'Password must be at least 8 characters.'; errEl.classList.add('show'); return; }
815  if (pass !== confirm) { msgEl.textContent = 'Passwords do not match.'; errEl.classList.add('show'); return; }
816
817  const btn = document.getElementById('newpass-btn');
818  btn.classList.add('loading');
819  btn.disabled = true;
820
821  try {
822    const res = await fetch('/api/auth/reset-password', {
823      method: 'POST',
824      headers: { 'Content-Type': 'application/json' },
825      body: JSON.stringify({ token: ccResetToken, newPassword: pass })
826    });
827    const data = await res.json().catch(() => ({}));
828    if (!res.ok) {
829      if (/expired/.test(data.error || '')) {
830        // Dead link: send them straight to requesting a new one.
831        ccResetToken = null;
832        document.getElementById('newpass-view').style.display = 'none';
833        showForgot();
834        document.getElementById('reset-error-msg').textContent = 'That reset link is invali
834d or has expired. Enter your email to get a new one.';
835        document.getElementById('reset-error').classList.add('show');
836      } else {
837        msgEl.textContent = data.error || 'Could not reset your password. Please try again.';
838        errEl.classList.add('show');
839      }
840      return;
841    }
842    localStorage.setItem('cc_token', data.token);
843    localStorage.setItem('cc_user', JSON.stringify(data.user));
844    try { sessionStorage.setItem('cc_token', data.token); } catch(e) {}
845    sessionStorage.setItem('cc_welcome', `Password updated. Welcome back, ${data.user.fname || 'there'}!`);
846    window.location.href = '/';
847  } catch (e) {
848    msgEl.textContent = 'Network error — please check your connection and try again.';
849    errEl.classList.add('show');
850  } finally {
851    btn.classList.remove('loading');
852    btn.disabled = false;
853  }
854}
855
856if (ccResetToken) {
857  document.getElementById('login-view').style.display = 'none';
858  document.getElementById('newpass-view').style.display = 'block';
859}
860
861/* ═══════════════════════════════════════════
862   PASSWORD SHOW/HIDE
863═══════════════════════════════════════════ */
864function togglePass(inputId, btn) {
865  const input = document.getElementById(inputId);
866  const isPass = input.type === 'password';
867  input.type = isPass ? 'text' : 'password';
868  btn.querySelector('i').className = isPass ? 'fas fa-eye-slash' : 'fas fa-eye';
869  btn.setAttribute('aria-label', isPass ? 'Hide password' : 'Show password');
870}
871
872/* ═══════════════════════════════════════════
873   TOAST
874═══════════════════════════════════════════ */
875function showToast(msg, duration = 4000) {
876  const t = document.getElementById('toast');
877  t.textContent = msg;
878  t.classList.add('show');
879  setTimeout(() => t.classList.remove('show'), duration);
880}
881
882/* ═══════════════════════════════════════════
883   PRE-FILL remembered email on load
884═══════════════════════════════════════════ */
885(function () {
886  const saved = localStorage.getItem('cc_remember_email');
887  if (saved) {
888    document.getElementById('l-email').value = saved;
889    document.getElementById('l-remember').checked = true;
890  }
891})();
892
893/* ═══════════════════════════════════════════
894   KEYBOARD — Enter submits visible form
895═══════════════════════════════════════════ */
896document.addEventListener('keydown', e => {
897  if (e.key !== 'Enter') return;
898  if (document.getElementById('forgot-view').style.display !== 'none') sendReset();
899  else if (document.getElementById('newpass-view').style.display !== 'none') submitNewPassword();
900  else if (document.getElementById('migrate-view').style.display !== 'none') submitMigration();
901  else signIn();
902});
903</script>
vendor: 1 bytes, line 903
903
904<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"e79d8b0449fb411087aba9b1640e3938","r":1,"spa":2}' crossorigin="anonymous"></script>
vendor: 1 bytes, line 904
904
905<script>(function(){function c(){var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'a4027cc20ff536b2',t:'MTc5MDI2MDIyOQ=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script>
905</body>
906</html>

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.