1<!DOCTYPE html> 2<html lang="en"> 3 4<head> 5 <link rel="icon" type="image/png" href="/static/images/favicon_cadientai.png"> 6 <meta charset="UTF-8"> 7 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 8 <title>Cadient - Login</title> 9 10 <!-- Tailwind CSS --> 11 12 <link rel="stylesheet" href="/static/css/tailwind-auth.css?v=c3e3d9026a"> 13 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> 14 15 <!-- Lucide Icons (New Standard) --> 16
16<script src="https://unpkg.com/[email protected]"></script>
16 17 18 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> 19 20 <style> 21 body { 22 font-family: 'Inter', sans-serif; 23 } 24 25 .fade-in { 26 animation: fadeIn 0.5s ease-in-out; 27 } 28 29 @keyframes fadeIn { 30 from { 31 opacity: 0; 32 transform: translateY(10px); 33 } 34 35 to { 36 opacity: 1; 37 transform: translateY(0); 38 } 39 } 40 41 input::-ms-reveal, 42 input::-ms-clear { 43 display: none; 44 } 45 </style> 46 47 48 49 <link rel="canonical" href="https://cadient.ai/auth/login"> 50 51 52<meta name="robots" content="noindex, nofollow"> 53 54 55
56<script> 57 window.dataLayer = window.dataLayer || []; 58 function gtag() { dataLayer.push(arguments); } 59 (function () { 60 // Single source: app.py parses GTM_CONTAINER_IDS once into the GTM_IDS 61 // Jinja global, which _gtm_noscript.html reads too â see the note there. 62 // Fails closed (no containers) if it is somehow undefined. 63 var GTM_IDS = ["GTM-WWP5NR36", "GTM-PDRS4M23"]; 64 // Configured, not hardcoded, and each vendor is skipped below when its 65 // ID is blank. ANALYTICS_ENABLED stays the master switch for the whole 66 // stack; these decide WHICH vendors that switch turns on. 67 // 68 // That distinction is what makes a non-prod environment testable. These 69 // two used to be literals, so the only way to exercise any vendor on dev 70 // was ANALYTICS_ENABLED=true, which also pointed dev traffic at the 71 // PRODUCTION GA4 property and Clarity project â quietly polluting the 72 // baseline the SEO programme is measured against. Now dev can enable the 73 // stack with these blank and run a single vendor in isolation. 74 // 75 // The defaults are the live prod IDs, so an environment that sets 76 // neither behaves exactly as before. 77 var GA_ID = "G-1T86EMJJXX"; 78 var CLARITY_ID = "vfab1wc1af"; 79 var started = false; 80 81 // ââ Embedded (iframe) awareness ââââââââââââââââââââââââââââââââââââââââ 82 // Set by pages that are normally rendered inside an iframe on one of our 83 // own pages â the scheduler at /lp/schedule. Such a page is a WIDGET, not 84 // a destination: the visitor never navigated to /lp/schedule, so a 85 // page_view against that URL is a phantom hit that inflates its views and 86 // distorts engagement for the property (LEAD_AUDIT.md F19). 87 // 88 // The old fix was to load NOTHING when embedded. That killed the phantom 89 // page_view but also blinded us on the same page's direct loads â and 90 // /lp/schedule has 19 real hrefs pointing at it, plus cross-origin 91 // WordPress embeds where the parent may carry no tags of ours at all. So 92 // it traded a known over-count for an unknown blind spot on the surface 93 // that books demos. 94 // 95 // What is actually unwanted is the automatic page_view, not the stack. So 96 // load everything either way and decide the page_view at RUNTIME, which 97 // is also the only place the answer is knowable: whether we are framed is 98 // not a fact the server has. 99 // 100 // IMPORTANT â this only covers the page_view that OUR gtag config sends. 101 // If a GTM container holds a GA4 tag firing on All Pages, that tag sends 102 // its own and no template change can stop it. `embedded` is pushed to the 103 // dataLayer BEFORE gtm.js loads so the container can carry a trigger 104 // exception on it; until someone adds that exception in the GTM UI, the 105 // container's own page_view still fires here. See LEAD_AUDIT.md F19 and 106 // the unresolved two-container question above. 107 var embedded = false && (function () { 108 try { return window.self !== window.top; } catch (e) { return true; } 109 })(); 110 111 function loadAnalytics() { 112 if (started) return; started = true; 113 // Google Tag Manager 114 dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); 115 if (embedded) { dataLayer.push({ embedded: true }); } 116 GTM_IDS.forEach(function (id) { 117 var s = document.createElement('script'); 118 s.async = true; 119 s.src = 'https://www.googletagmanager.com/gtm.js?id=' + id; 120 document.head.appendChild(s); 121 }); 122 // Google Analytics (gtag.js). Skipped entirely without an ID â 123 // loading gtag.js with an empty id still opens a connection to 124 // Google and registers a property-less client, so the guard has to 125 // be here rather than relying on GA to ignore the config call. 126 if (GA_ID) { 127 var g = document.createElement('script'); 128 g.async = true; 129 g.src = 'https://www.googletagmanager.com/gtag/js?i
129d=' + GA_ID; 130 document.head.appendChild(g); 131 gtag('js', new Date()); 132 // send_page_view:false keeps gtag() live for event hits (a booking is 133 // still measurable) while omitting the phantom navigation hit. 134 gtag('config', GA_ID, { send_page_view: !embedded }); 135 } 136 // Microsoft Clarity 137 if (CLARITY_ID) { 138 (function (c, l, a, r, i, t, y) { 139 c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments) }; 140 t = l.createElement(r); t.async = 1; t.src = 'https://www.clarity.ms/tag/' + i; 141 y = l.getElementsByTagName(r)[0]; y.parentNode.insertBefore(t, y); 142 })(window, document, 'clarity', 'script', CLARITY_ID); 143 } 144 } 145 var evts = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click']; 146 function onFirst() { 147 evts.forEach(function (e) { window.removeEventListener(e, onFirst); }); 148 loadAnalytics(); 149 } 150 evts.forEach(function (e) { window.addEventListener(e, onFirst, { passive: true }); }); 151 window.addEventListener('load', function () { 152 if ('requestIdleCallback' in window) { requestIdleCallback(loadAnalytics, { timeout: 4000 }); } 153 else { setTimeout(loadAnalytics, 3000); } 154 }); 155 })(); 156</script>
156 157 158 159
160<script> 161 (function () { 162 // Queue stub first and synchronously: it is the whole point of the 163 // vendor's snippet shape. Anything measured before the SDK lands is 164 // held here and replayed, so deferring the network load cannot lose an 165 // event. 166 window.oaiq = window.oaiq || function () { 167 (window.oaiq.q = window.oaiq.q || []).push(arguments); 168 }; 169 170 var PIXEL_ID = "QnvACeEzLpeAcz9ok3djRz"; 171 172 // Same question the page_view asks: are we the scheduler running 173 // inside one of our own pages? If so the parent owns measurement. 174 var embedded = (function () { 175 try { return window.self !== window.top; } catch (e) { return true; } 176 })(); 177 if (embedded) return; 178 179 oaiq('init', { pixelId: PIXEL_ID }); 180 181 var loaded = false; 182 function loadPixel() { 183 if (loaded) return; loaded = true; 184 var s = document.createElement('script'); 185 s.async = true; 186 s.src = 'https://bzrcdn.openai.com/sdk/oaiq.min.js'; 187 document.head.appendChild(s); 188 } 189 var evts = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click']; 190 function onFirst() { 191 evts.forEach(function (e) { window.removeEventListener(e, onFirst); }); 192 loadPixel(); 193 } 194 evts.forEach(function (e) { window.addEventListener(e, onFirst, { passive: true }); }); 195 window.addEventListener('load', function () { 196 if ('requestIdleCallback' in window) { requestIdleCallback(loadPixel, { timeout: 4000 }); } 197 else { setTimeout(loadPixel, 3000); } 198 }); 199 200 // Our event names -> OpenAI's taxonomy. Both are `customer_action`; 201 // neither carries an amount, because a booked demo has no transaction 202 // value we can state honestly. Adding an invented one would flow 203 // straight into their bidding. 204 var EVENTS = { 205 demo_booking_success: 'appointment_scheduled', 206 playbook_request_success: 'lead_created' 207 }; 208 209 // Fired at most once per event name per page: the two channels below 210 // can both be live on one page, and a booking must not be reported 211 // twice because it arrived by two routes. 212 var sent = {}; 213 214 function measureConversion(name, detail) { 215 try { 216 var mapped = EVENTS[name]; 217 if (!mapped || sent[mapped]) return; 218 sent[mapped] = true; 219 var options = {}; 220 // event_id is what lets a future Conversions API call dedupe 221 // against this one. The playbook flows have a real lead id to 222 // key on; the iframe route has none on the parent, so it goes 223 // without and cannot be deduped server-side until the slot is 224 // threaded back. Sharing one id across the two event NAMES is 225 // fine â the dedup key is (pixel, event name, event_id). 226 if (detail && detail.eventId) options.event_id = String(detail.eventId); 227 oaiq('measure', mapped, { type: 'customer_action' }, options); 228 } catch (e) { /* measurement must never break the page */ } 229 } 230 window.cadientMeasureConversion = measureConversion; 231 232 // Route 1: the scheduler iframe announcing a confirmed booking. Same 233 // message the ad consultants' own listener consumes. Origin-checked â 234 // the frame posts to '*' because it is embedded cross-origin by the 235 // WordPress estate, so the check belongs here, on the receiving end. 236 window.addEventListener('message', function (e) { 237 if (e.origin !== window.location.origin && 238 e.origin !== 'https://cadient.ai' && 239 e.origin !== 'https://dev.cadient.ai') return; 240 if (!e.data || e.data.event !== 'demo_booking_success') return; 241 measureConversion('demo_booking_success', e.data); 242 }); 243 })(); 244</script>
244 245 246 247<!-- Warmly â visitor de-anonymisation, i.e. analytics, so it rides the same 248 gate as everything above it including suppress_analytics. --> 249 250
251<script id="warmly-script-loader" src="https://opps-widget.getwarmly.com/warmly.js?clientId=5a34a1084b3b8a03e3901742e03c280b" defer></script>
251 252 253 254 255 256</head> 257 258<body class="bg-slate-50 min-h-screen flex flex-col overflow-x-hidden">
vendor: 75 bytes, lines 258-262
258 259 260 261 262<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=
262GTM-WWP5NR36
vendor: 151 bytes, lines 262-265
262" 263height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript> 264 265<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=
265GTM-PDRS4M23
vendor: 90 bytes, lines 265-269
265" 266height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript> 267 268 269
270<header class="w-full bg-white border-b border-slate-200"> 271 <div class="max-w-4xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between"> 272 <a href="/" class="inline-flex items-center" aria-label="Cadient home"> 273 <img src="/static/img/logo.webp" class="h-8 w-auto" alt="Cadient"> 274 </a> 275 <a href="/" 276 class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-500 hover:text-slate-900 transition-colors"> 277 <i data-lucide="arrow-left" class="w-4 h-4"></i> 278 Back to home 279 </a> 280 </div> 281</header> 282 283 <main class="flex-1 flex items-center justify-center p-4"> 284 285 <div class="bg-white rounded-2xl shadow-xl overflow-hidden flex max-w-md md:max-w-4xl w-full fade-in"> 286 287 <!-- Left Side: Cadient mark + product lockup + value props --> 288 289<div 290 class="hidden md:flex w-1/2 bg-gradient-to-br from-slate-900 to-slate-800 p-12 flex-col text-white relative overflow-hidden"> 291 292 <!-- Zone 1 (top): product lockup â mirrors the landing-page hero tile --> 293 294 295 296 <!-- Zone 2 (centered in remaining space): message --> 297 <div class="z-10 flex-1 flex flex-col justify-center py-10"> 298 <h2 class="text-3xl font-bold mb-4 leading-tight">Welcome</h2> 299 <p class="text-slate-300 mb-8">Access your Precision Hiring Solutions.</p> 300 301 302 <ul class="space-y-3"> 303 304 <li class="flex items-start gap-3 text-sm text-slate-200"> 305 <i data-lucide="check-circle-2" class="w-4 h-4 mt-0.5 shrink-0 opacity-80"></i> 306 <span>One account for the full hiring suite</span> 307 </li> 308 309 <li class="flex items-start gap-3 text-sm text-slate-200"> 310 <i data-lucide="check-circle-2" class="w-4 h-4 mt-0.5 shrink-0 opacity-80"></i> 311 <span>Free trial on every product</span> 312 </li> 313 314 <li class="flex items-start gap-3 text-sm text-slate-200"> 315 <i data-lucide="check-circle-2" class="w-4 h-4 mt-0.5 shrink-0 opacity-80"></i> 316 <span>No credit card required</span> 317 </li> 318 319 </ul> 320 321 </div> 322 323 <!-- Zone 3 (bottom): quiet anchor, balancing the form column's footer link --> 324 <p class="z-10 text-xs text-slate-500">Part of the Cadient hiring suite</p> 325 326 <!-- Abstract Shapes Pattern --> 327 <div class="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 rounded-full bg-brand-600 opacity-20 blur-3xl"></div> 328 <div class="absolute bottom-0 left-0 -ml-16 -mb-16 w-64 h-64 rounded-full bg-orange-500 opacity-20 blur-3xl"></div> 329</div> 330 331 <!-- Right Side: Form --> 332 <div class="w-full md:w-1/2 p-8 md:p-12 flex flex-col justify-center relative"> 333 334 335 336 337 338 339 340 341 342 <h1 class="text-2xl font-bold text-slate-900 mb-1.5">Sign In</h1> 343 <p id="loginSubtext" class="text-sm text-slate-500 mb-7" 344 data-otp="Enter your work email — we’ll send you a six-digit sign-in code." 345 data-password="For accounts that have set a password.">Enter your work email — 346 we’ll send you a six-digit sign-in code.</p> 347 348 349 <!-- OTP Login Form (default) --> 350 <div id="otp-login-form"> 351 <form action="/auth/login-otp-init?next=/?" method="POST"> 352 353 <div class="mb-6"> 354 <label for="otpLoginEmailInput" class="block text-sm font-medium text-slate-700 mb-1">Work Email</label> 355 <input type="email" name="email" id="otpLoginEmailInput" required autofocus autocomplete="username" 356 value="" 357 class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-600 outline-none"> 358 </div> 359 360 361 <button type="submit" 362 class="group w-full bg-brand-600 hover:bg-brand-700 text-white font-semibold py-3 rounded-lg shadow-lg transition-all flex items-center justify-center gap-2"> 363 Continue 364 <i data-lucide="arrow-right" class="w-4 h-4 transition-transform group-hover:translate-x-0.5"></i> 365 </button> 366 367 </form> 368 </div> 369 370 <!-- Password Login Form (hidden by default) --> 371 <div id="password-login-form" class="hidden"> 372 <form action="/auth/login?next=/?" method="POST"> 373 374 <div class="mb-4"> 375 <label for="passwordLoginEmailInput" class="block text-sm font-medium text-slate-700 mb-1">Work Email</label> 376 <input type="email" name="email" id="passwordLoginEmailInput" required autocomplete="username" 377 class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-600 outline-none"> 378 </div> 379 380 <div class="mb-2"> 381 <label for="loginPasswordInput" class="block text-sm font-medium text-slate-700 mb-1">Password</label> 382 <div class="relative"> 383 <input type="password" name="password" id="loginPasswordInput" required autocomplete="current-password" 384 class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-600 outline-none pr-10"> 385 <button type="button" id="toggleLoginPassword" aria-label="Show password" class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 focus:outline-none"> 386 <i data-lucide="eye" class="w-5 h-5"></i> 387 </button> 388 </div> 389 </div> 390 391 <div class="mb-6 text-right"> 392 <a href="/auth/forgot-password" 393 class="text-sm text-brand-600 hover:underline"> 394 Forgot password? 395 </a> 396 </div> 397 398 <button type="submit" 399 class="w-full bg-brand-600 hover:bg-brand-700 text-white font-semibold py-3 rounded-lg shadow-lg transition-all flex items-center justify-center gap-2"> 400 Sign In <i data-lucide="arrow-right" class="w-4 h-4"></i> 401 </button> 402 403 </form> 404 </div> 405 406 407 408 409 410 411<div id="googleSignInDivider" class="flex items-center gap-3 mt-6 mb-4"> 412 <div class="flex-1 h-px bg-slate-200"></div> 413 <span class="text-[11px] font-medium text-slate-400 uppercase tracking-wider whitespace-nowrap"> 414 or sign in with 415 </span> 416 <div class="flex-1 h-px bg-slate-200"></div> 417</div> 418 419 420<div id="googleSignInError" 421 class="hidden bg-red-50 text-red-600 border border-red-100 p-3 rounded-lg mb-3 text-sm flex items-center gap-2"> 422 <i data-lucide="alert-circle" class="w-4 h-4 shrink-0"></i> 423 <span id="googleSignInErrorText" class="flex-1"></span> 424</div> 425 426<div id="googleSignInDiv" class="flex justify-center"></div> 427
428<script src="https://accounts.google.com/gsi/client" async defer></script>
vendor: 1 bytes, line 428
428
429<script> 430 // SmartShield's two acknowledgements (register.html) are a compliance 431 // requirement that must precede account creation â same gate the manual 432 // form's `required` checkboxes enforce, but those never fire for this 433 // out-of-band Google flow, so it's re-checked here before ever calling 434 // Google, and the checked state is forwarded so the server can enforce 435 // it too (auth_routes.google_signup). A no-op (returns true) on any page 436 // without these checkboxes, e.g. login.html or a non-SmartShield register. 437 function isChecked(id) { 438 const el = document.getElementById(id); 439 return !!(el && el.checked); 440 } 441 442 function ssAttestationChecked() { 443 const att = document.getElementById('ssAttestationCheckbox'); 444 const pol = document.getElementById('ssPoliciesCheckbox'); 445 if (!att && !pol) return true; 446 return isChecked('ssAttestationCheckbox') && isChecked('ssPoliciesCheckbox'); 447 } 448 449 function handleGoogleCredential(response) { 450 const errorBox = document.getElementById('googleSignInError'); 451 const errorText = document.getElementById('googleSignInErrorText'); 452 errorBox.classList.add('hidden'); 453 454 if (!ssAttestationChecked()) { 455 // "above" again. This flipped twice: the banner said "above" while 456 // the checkboxes were below it, was corrected to "below" when this 457 // partial sat at the top of the card, and is now back to "above" 458 // because the partial moved beneath the form the checkboxes live 459 // in. Verify the direction against the rendered page if this block 460 // is ever moved again â it is not guessable from this file alone. 461 errorText.textContent = 'Please acknowledge the statements above before continuing.'; 462 errorBox.classList.remove('hidden'); 463 return; 464 } 465 466 fetch("/auth/google-signup", { 467 method: 'POST', 468 headers: { 'Content-Type': 'application/json' }, 469 body: JSON.stringify({ 470 credential: response.credential, 471 tool: "", 472 smart_shield_attestation: isChecked('ssAttestationCheckbox'), 473 smart_shield_policies: isChecked('ssPoliciesCheckbox'), 474 // Whether THIS page can even collect the attestation. The 475 // checkboxes exist only on the SmartShield register page, so on 476 // the login page the two flags above can only ever be false â 477 // and the server refuses to create a SmartShield account 478 // without them. Without this flag the server cannot tell 479 // "user didn't tick the box" from "the box isn't on this page", 480 // and a new user signing in with Google from the login page got 481 // "Please confirm the research-assist statement" pointing at a 482 // statement that wasn't there. See auth_routes.google_signup. 483 attestation_available: !!document.getElementById('ssAttestationCheckbox') 484 }) 485 }) 486 .then(res => res.json().then(data => ({ ok: res.ok, data }))) 487 .then(({ ok, data }) => { 488 if (ok && data.redirect) { 489 window.location.href = data.redirect; 490 } else { 491 errorText.textContent = data.error || 'Google sign-in failed. Please try again.'; 492 errorBox.classList.remove('hidden'); 493 } 494 }) 495 .catch(() => { 496 errorText.textContent = 'Google sign-in failed. Please try again.'; 497 errorBox.classList.remove('hidden'); 498 }); 499 } 500 501 // Render the button as soon as the GIS library is available. 502 // 503 // Two problems with waiting on window 'load' and bailing when 504 // window.google is missing, which is what this used to do: 505 // 506 // 1. RACE â the GIS script is `async defer`, so on a slow connection it 507 // can resolve AFTER 'load' has already fired. The one-shot check then 508 // bails and nothing ever renders, with no retry. Never reproduces on 509 // fast local dev; does reproduce in the field. 510 // 2. SILENT FAILURE â when accounts.google.com is unreachable at all 511 // (ad blocker, corporate proxy, extension, offline), the user just 512 // sees an empty gap where the button should be and no reason to look 513 // at the email field instead. Recruiters behind corporate IT are 514 // exactly the population that hits this. 515 // 516 // So: poll briefly for the library, then give up with a VISIBLE message 517 // that points at the working path. The "or sign in with" divider is hidden 518 // too â a group label introducing an empty space reads as a broken page â 519 // so what remains is an ordinary email form with nothing missing. 520 (function () { 521 var POLL_MS = 100; 522 var TIMEOUT_MS = 8000; // generous; GIS normally lands in <1s 523 var waited = 0; 524 525 // One Tap is offered only to anonymous visitors. Prompting someone who 526 // already has a session would hand us a credential for an account 527 // they're signed into, which at best does nothing and at worst swaps 528 // the session mid-visit. `one_tap` lets a page opt out entirely. 529 var ONE_TAP = true 530 && true; 531 532 function giveUp() { 533 var box = document.getElementById('googleSignInError'); 534 var text = document.getElementById('googleSignInErrorText'); 535 var holder = document.getElementById('googleSignInDiv'); 536 var divider = document.getElementById('googleSignInDivider'); 537 if (holder) holder.classList.add('hidden'); 538 if (divider) divider.classList.add('hidden'); 539 if (box && text) { 540 // "above", not "below": the email form now precedes this block. 541 text.textContent = 'Google sign-in is unavailable right now â ' + 542 'continue with your email above instead.'; 543 box.classList.remove('hidden'); 544 } 545 if (window.console) { 546 console.warn('[auth] GIS library never loaded; Google button hidden.'); 547 } 548 } 549 550 // GIS needs an explicit pixel width â it has no "100%". A hardcoded 551 // 320 left the button visibly narrower than the email field and CTA 552 // (352px in the desktop card), inset ~16px on each side, and would 553 // OVERFLOW a card narrower than 320. Measure the holder instead so the 554 // button's edges line up with the form at any viewport. GIS clamps to 555 // 400; floor at 200 so a mid-layout measurement can't produce a stub. 556 function buttonWidth(holder) { 557 var w = Math.round(holder.getBoundingClientRect().width); 558 if (!w) return 320; // holder not laid out yet 559 return Math.max(200, Math.min(400, w)); 560 } 561 562 function tryRender() { 563 if (window.google && window.google.accounts && window.google.accounts.id) { 564 try { 565 var holder = document.getElementById('googleSignInDiv'); 566 google.accounts.id.initialize({ 567 client_id: "116942318144-m5d5dsqfdj2cqvqm3rmo2kr8s30935vb.apps.googleusercontent.com", 568 callback: handleGoogleCredential, 569 // 'signin' / 'signup' asks Google to word the One Tap 570 // prompt to match the page. Under FedCM the BROWSER 571 // renders that dialog, so treat this as a hint, not a 572 // guarantee â Chrome shows the top-level domain rather 573 // than the app name regardless. 574 context: "signin" 575 }); 576 // logo_alignment 'center' groups the G with the label and 577 // centres the pair. The default ('left') pins the G to the 578 // left edge while centring the text independently, which at 579 // our 352px width left a ~90px gap between them and was the 580 // main reason the button read as foreign to the card. 581 // 582 // Compared against the alternatives at the real card width:
583 // 'pill' gives a 20px radius against our 8px inputs (worse), 584 // and theme 'filled_black' forces a white tile behind the G 585 // (guidelines require the mark on white) which looks broken 586 // and competes with the orange CTA. 'rectangular' is 4px â 587 // not our 8px, but the closest GIS offers. 588 // 589 // Height is stuck at 40px vs the CTA's 48px: GIS exposes no 590 // height parameter and 'large' is its maximum. Matching that 591 // needs a custom button, which means giving up renderButton 592 // and the ID token it returns â a backend change, not a CSS 593 // one. Google does permit custom buttons, within limits: 594 // https://developers.google.com/identity/branding-guidelines 595 google.accounts.id.renderButton( 596 holder, 597 { theme: 'outline', size: 'large', shape: 'rectangular', 598 logo_alignment: 'center', 599 width: buttonWidth(holder), text: 'continue_with' } 600 ); 601 602 // One Tap: the unprompted account dialog. Independent of 603 // renderButton â prompt() opens it whether or not a button 604 // was drawn â and it delivers a credential to the SAME 605 // callback, so no new server path is needed. 606 // 607 // Suppressed when an unsatisfied compliance gate is on the 608 // page. ssAttestationChecked() returns true where there are 609 // no checkboxes, so this only bites on SmartShield's 610 // register page: One Tap appears before the user can tick 611 // the attestation, so tapping it would be rejected by 612 // handleGoogleCredential and the user would get an error 613 // for accepting an offer we made. Better not to offer. 614 // 615 // Not enabling auto_select (zero-click sign-in): it needs 616 // google.accounts.id.disableAutoSelect() wired into logout 617 // first, or a signed-out user gets silently signed back in. 618 // Called with NO moment-listener callback on purpose. Passing 619 // one makes GIS log a deprecation warning, because most of 620 // PromptMomentNotification (the display and skip reasons) 621 // was removed for privacy under FedCM. We only ever used it 622 // for a console line, so there is nothing to trade away. 623 if (ONE_TAP && ssAttestationChecked()) { 624 google.accounts.id.prompt(); 625 } 626 } catch (e) { 627 // initialize() throws on a malformed/missing client_id. 628 if (window.console) console.warn('[auth] GIS init failed:', e); 629 giveUp(); 630 } 631 return; 632 } 633 waited += POLL_MS; 634 if (waited >= TIMEOUT_MS) { giveUp(); return; } 635 setTimeout(tryRender, POLL_MS); 636 } 637 638 tryRender(); // no 'load' dependency â start immediately 639 })(); 640</script>
640 641 642 643 644 645 <div class="mt-5 text-center"> 646 <button type="button" id="loginMethodToggle" onclick="toggleLoginMethod()" 647 data-otp="Sign in with a password instead" 648 data-password="Email me a sign-in code instead" 649 class="text-sm text-brand-600 hover:underline font-medium"> 650 Sign in with a password instead 651 </button> 652 </div> 653 654 <div class="mt-6 text-center text-sm text-slate-500"> 655 Don't have an account? 656 657 <a href="/auth/register?next=/?" rel="nofollow" 658 class="text-brand-600 font-semibold hover:underline">Create Account</a> 659 </div> 660 </div> 661 </div> 662 </main> 663 664
664<script> 665 // Initialize Lucide Icons 666 lucide.createIcons(); 667 668 const loginPasswordInput = document.getElementById('loginPasswordInput'); 669 const toggleLoginPassword = document.getElementById('toggleLoginPassword'); 670 toggleLoginPassword.addEventListener('click', function () { 671 const isPassword = loginPasswordInput.getAttribute('type') === 'password'; 672 loginPasswordInput.setAttribute('type', isPassword ? 'text' : 'password'); 673 this.setAttribute('aria-label', isPassword ? 'Hide password' : 'Show password'); 674 this.innerHTML = isPassword 675 ? '<i data-lucide="eye-off" class="w-5 h-5"></i>' 676 : '<i data-lucide="eye" class="w-5 h-5"></i>'; 677 lucide.createIcons(); 678 }); 679 680 function toggleLoginMethod() { 681 const passwordForm = document.getElementById('password-login-form'); 682 const otpForm = document.getElementById('otp-login-form'); 683 684 const showPassword = passwordForm.classList.contains('hidden'); 685 passwordForm.classList.toggle('hidden', !showPassword); 686 otpForm.classList.toggle('hidden', showPassword); 687 688 // The heading and subtext now live above the shared Google button, 689 // outside both panes, so the subtext has to follow the toggle. 690 const subtext = document.getElementById('loginSubtext'); 691 if (subtext) { 692 subtext.textContent = showPassword 693 ? subtext.dataset.password 694 : subtext.dataset.otp; 695 } 696 697 // Same reason: the toggle now lives below the shared Google block 698 // rather than inside the pane, so its own label has to follow. 699 const toggle = document.getElementById('loginMethodToggle'); 700 if (toggle) { 701 toggle.textContent = showPassword 702 ? toggle.dataset.password 703 : toggle.dataset.otp; 704 } 705 } 706 707 // The OTP form is the default pane. Open the password form instead 708 // when the context is explicitly about passwords: 709 // · ?method=password deep link 710 // · ?reset_success=1 â the banner says "sign in with your new 711 // password", so the password form must be showing 712 // · server-rendered method='password' â a failed password attempt 713 // re-renders this page and must not dump the user back on the 714 // code form mid-task 715 // ?method=otp (welcome emails, pwless/finish redirects) is now a 716 // harmless no-op since OTP is already the default. 717 const params = new URLSearchParams(window.location.search); 718 const initialMethod = "" || params.get('method') || ''; 719 if (initialMethod === 'password' || params.get('reset_success')) { 720 toggleLoginMethod(); 721 } 722 </script>
722 723 724</body> 725 726</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.