1 2 3 4<!DOCTYPE html> 5<html lang="en" class="h-full"> 6<head> 7 <meta charset="utf-8"> 8 <link rel="icon" type="image/png" href="/resources/images/favicon.png"> 9 <meta content="initial-scale=1, shrink-to-fit=no, width=device-width" name="viewport"> 10 <title>Sign In - Surva.ai</title> 11 <link rel="stylesheet" href="/resources/css/tailwind-output.css"> 12
12<script> 13 (function () { 14 try { 15 var t = localStorage.getItem('surva-theme'); 16 var dark = t === 'dark' || (!t && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); 17 document.documentElement.classList.toggle('dark', dark); 18 } catch (e) { /* no-op */ } 19 })(); 20 </script>
20 21 <link rel="stylesheet" href="/resources/css/surva-dark.css"> 22
22<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
22 23
23<script> 24 // Only load Google Sign-In on production domains (not localhost) 25 var isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; 26 if (!isLocalhost) { 27 var script = document.createElement('script'); 28 script.src = 'https://accounts.google.com/gsi/client'; 29 script.async = true; 30 script.defer = true; 31 document.head.appendChild(script); 32 } 33 </script>
vendor: 77 bytes, lines 33-35
33 34 35 <script src="https://www.google.com/recaptcha/enterprise.js?render=
356LfD6UAsAAAAACQHTt2gxLXDyA5uvafdG6fSs6-d
vendor: 21 bytes, lines 35-37
35"></script> 36 37
37<style> 38 /* Google Sign-In button styling */ 39 #google-signin-wrapper { 40 opacity: 0; 41 transition: opacity 0.3s ease-in-out; 42 width: 100%; 43 } 44 #google-signin-wrapper.loaded { 45 opacity: 1; 46 } 47 .g_id_signin { 48 display: flex; 49 justify-content: center; 50 width: 100%; 51 } 52 .g_id_signin > div { 53 min-height: 44px; 54 } 55 /* Increase Google button border-radius to match Sign In button */ 56 .g_id_signin iframe { 57 border-radius: 8px !important; 58 } 59 .g_id_signin > div > div { 60 border-radius: 8px !important; 61 } 62 63 /* Base light mode */ 64 body { 65 background-color: #f8fafc; 66 color: #0f172a; 67 } 68 69 /* Hide reCAPTCHA badge */ 70 .grecaptcha-badge { 71 visibility: hidden !important; 72 } 73 74 /* Large 150px grid background, fading toward the bottom */ 75 .bg-large-grid { 76 position: fixed; 77 inset: 0; 78 z-index: 0; 79 pointer-events: none; 80 } 81 82 .bg-large-grid::before { 83 content: ""; 84 position: absolute; 85 inset: 0; 86 background-image: 87 linear-gradient(to right, rgba(0,0,0,0.06) 1px, transparent 1px), 88 linear-gradient(to bottom, rgba(0,0,0,0.06) 1px, transparent 1px); 89 background-size: 150px 150px; 90 mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 100%); 91 } 92 </style> 93</head> 94<body> 95 96 97 98<a href="#main-content" class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-indigo-600 focus:text-white focus:rounded-lg focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none"> 99 Skip to main content 100</a> 101 102 103<div id="a11y-status" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div> 104<div id="a11y-alert" class="sr-only" role="alert" aria-live="assertive" aria-atomic="true"></div> 105 106<style> 107 /* Enhanced focus indicators for keyboard navigation */ 108 *:focus-visible { 109 outline: none; 110 box-shadow: 0 0 0 2px #4f46e5; 111 } 112 113 /* Ensure focus ring is visible on dark backgrounds */ 114 .bg-indigo-600 *:focus-visible, 115 .bg-gray-900 *:focus-visible { 116 box-shadow: 0 0 0 2px #ffffff; 117 } 118 119 /* Reduced motion support */ 120 @media (prefers-reduced-motion: reduce) { 121 *, *::before, *::after { 122 animation-duration: 0.01ms !important; 123 animation-iteration-count: 1 !important; 124 transition-duration: 0.01ms !important; 125 scroll-behavior: auto !important; 126 } 127 } 128 129 /* High contrast mode support */ 130 @media (forced-colors: active) { 131 .surva-pill-active, 132 .surva-pill.active { 133 border: 2px solid ButtonText; 134 } 135 } 136</style> 137
138<script> 139// ============================================ 140// ACCESSIBILITY UTILITIES 141// ============================================ 142 143/** 144 * Announce a message to screen readers via live region. 145 * @param {string} message - Text to announce 146 * @param {string} priority - 'polite' or 'assertive' 147 */ 148function announceToScreenReader(message, priority) { 149 var el = priority === 'assertive' 150 ? document.getElementById('a11y-alert') 151 : document.getElementById('a11y-status'); 152 if (el) { 153 el.textContent = ''; 154 // Small delay to ensure the change is picked up by screen readers 155 setTimeout(function() { el.textContent = message; }, 50); 156 } 157} 158 159/** 160 * Trap focus within a container (for modals/dialogs). 161 * Returns a cleanup function to remove the trap. 162 * @param {HTMLElement} container - The modal/dialog element 163 * @returns {Function} cleanup function 164 */ 165function trapFocus(container) { 166 var focusableSelectors = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; 167 168 function getFocusableElements() { 169 return Array.from(container.querySelectorAll(focusableSelectors)).filter(function(el) { 170 return el.offsetParent !== null; // visible elements only 171 }); 172 } 173 174 function handleKeyDown(e) { 175 if (e.key !== 'Tab') return; 176 177 var focusable = getFocusableElements(); 178 if (focusable.length === 0) return; 179 180 var first = focusable[0]; 181 var last = focusable[focusable.length - 1]; 182 183 if (e.shiftKey) { 184 if (document.activeElement === first) { 185 e.preventDefault(); 186 last.focus(); 187 } 188 } else { 189 if (document.activeElement === last) { 190 e.preventDefault(); 191 first.focus(); 192 } 193 } 194 } 195 196 function handleEscape(e) { 197 if (e.key === 'Escape') { 198 // Find and click the cancel/close button 199 var closeBtn = container.querySelector('[data-dismiss], [onclick*="hide"], .surva-modal-close'); 200 if (closeBtn) closeBtn.click(); 201 } 202 } 203 204 container.addEventListener('keydown', handleKeyDown); 205 container.addEventListener('keydown', handleEscape); 206 207 // Focus the first focusable element 208 var focusable = getFocusableElements(); 209 if (focusable.length > 0) { 210 focusable[0].focus(); 211 } 212 213 return function() { 214 container.removeEventListener('keydown', handleKeyDown); 215 container.removeEventListener('keydown', handleEscape); 216 }; 217} 218 219/** 220 * Enhanced modal show/hide with accessibility. 221 * Call instead of just toggling .active class. 222 */ 223var _modalFocusCleanup = {}; 224var _modalPreviousFocus = {}; 225 226function showAccessibleModal(modalId) { 227 var modal = document.getElementById(modalId); 228 if (!modal) return; 229 230 // Store the element that triggered the modal 231 _modalPreviousFocus[modalId] = document.activeElement; 232 233 // Show modal 234 modal.classList.add('active'); 235 modal.setAttribute('role', 'dialog'); 236 modal.setAttribute('aria-modal', 'true'); 237 238 // Set aria-labelledby if a heading exists 239 var heading = modal.querySelector('h3, h2, [data-modal-title]'); 240 if (heading) { 241 if (!heading.id) heading.id = modalId + '-title'; 242 modal.setAttribute('aria-labelledby', heading.id); 243 } 244 245 // Trap focus 246 _modalFocusCleanup[modalId] = trapFocus(modal); 247 248 // Prevent background scrolling 249 document.body.style.overflow = 'hidden'; 250 251 announceToScreenReader('Dialog opened: ' + (heading ? heading.textContent : ''), 'assertive'); 252} 253 254function hideAccessibleModal(modalId) { 255 var modal = document.getElementById(modalId); 256 if (!modal) return; 257 258 // Remove focus trap 259 if (_modalFocusCleanup[modalId]) { 260 _modalFocusCleanup[modalId](); 261 delete _modalFocusCleanup[modalId]; 262 } 263 264 // Hide modal 265 modal.classList.remove('active'); 266 267 // Restore background scrolling 268 document.body.style.overflow = ''; 269 270 // Return focus to trigger element 271 if (_modalPreviousFocus[modalId]) { 272 _modalPreviousFocus[modalId].focus(); 273 delete _modalPreviousFocus[modalId]; 274 } 275 276 announceToScreenReader('Dialog closed', 'polite'); 277} 278 279/** 280 * Make clickable table rows keyboard accessible. 281 * Adds Enter/Space key support and proper role. 282 */ 283function makeRowsAccessible(tableId) { 284 var table = document.getElementById(tableId); 285 if (!table) return; 286 287 var rows = table.querySelectorAll('tbody tr[onclick], tbody tr[data-href]'); 288 rows.forEach(function(row) { 289 row.setAttribute('tabindex', '0'); 290 row.setAttribute('role', 'link'); 291 row.addEventListener('keydown', function(e) { 292 if (e.key === 'Enter' || e.key === ' ') { 293 e.preventDefault(); 294 row.click(); 295 } 296 }); 297 }); 298} 299 300/** 301 * Initialize sortable table headers for keyboard accessibility. 302 */ 303function makeSortableHeadersAccessible(tableId) { 304 var table = document.getElementById(tableId); 305 if (!table) return; 306 307 var headers = table.querySelectorAll('th[onclick]');
308 headers.forEach(function(th) { 309 th.setAttribute('tabindex', '0'); 310 th.setAttribute('role', 'columnheader'); 311 th.setAttribute('aria-sort', 'none'); 312 var onclick = th.getAttribute('onclick'); 313 th.addEventListener('keydown', function(e) { 314 if (e.key === 'Enter' || e.key === ' ') { 315 e.preventDefault(); 316 th.click(); 317 } 318 }); 319 }); 320} 321 322// Auto-init on DOM ready 323document.addEventListener('DOMContentLoaded', function() { 324 // Make all clickable table rows keyboard accessible 325 document.querySelectorAll('table').forEach(function(table) { 326 if (table.id) { 327 makeRowsAccessible(table.id); 328 makeSortableHeadersAccessible(table.id); 329 } 330 }); 331 332 // Add aria-hidden to all decorative SVGs inside links/buttons that also contain text 333 document.querySelectorAll('a svg, button svg').forEach(function(svg) { 334 var parent = svg.closest('a, button'); 335 if (parent) { 336 var hasText = parent.textContent.trim().length > 0; 337 if (hasText && !svg.getAttribute('aria-label')) { 338 svg.setAttribute('aria-hidden', 'true'); 339 } 340 } 341 }); 342 343 // Add aria-label to icon-only buttons that have title but no aria-label 344 document.querySelectorAll('button[title]:not([aria-label])').forEach(function(btn) { 345 var textContent = btn.textContent.trim(); 346 if (textContent.length === 0 || textContent === '') { 347 btn.setAttribute('aria-label', btn.getAttribute('title')); 348 } 349 }); 350}); 351</script>
351 352 353 354<div class="bg-large-grid" aria-hidden="true"></div> 355 356<div class="relative z-10 flex min-h-full flex-col justify-center px-6 py-4 lg:px-8"> 357 358 359 360 <div class="sm:w-full sm:max-w-sm px-6"> 361 362 363 364 365 <a href="/" aria-label="Surva.ai home"><img class="h-8 w-auto dark:hidden" src="/resources/images/surva.png" alt="Surva.ai"><img class="h-8 w-auto hidden dark:block" src="/resources/images/surva-white.png" alt="Surva.ai"></a> 366 367 368 </div> 369 370 <div class="sm:mx-auto sm:w-full sm:max-w-sm"> 371 <h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-gray-900"> 372 373 374 Sign in to your account 375 376 </h2> 377 378 <!-- Error and Message Handling --> 379 380 381 </div> 382 383 <!-- LOGIN FORM --> 384 <main id="loginContainer" class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> 385 <div class="rounded-2xl bg-white backdrop-blur-lg shadow-xl ring-1 ring-gray-900/5 p-10"> 386 <form id="loginForm" class="space-y-6" action="/signin" method="POST"> 387 388 <input type="hidden" name="_csrf" value="eb4bzLia52QLkF-gxAUV_4uLYQF-13PU8A4OwZY0uaTqAgtjSYki_4r4hQcmqTvG_CghybO_TDlPskT5k29s-KUMgZPTNTlV"/> 389 <input type="hidden" name="g-recaptcha-response" id="recaptchaToken" value=""/> 390 <div> 391 <label for="email" class="block text-sm font-medium text-gray-900">Email address</label> 392 <div class="mt-2"> 393 <input maxlength="255" type="email" name="email" id="email" autocomplete="email" required class="block w-full rounded-lg bg-white px-3 py-2 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm"> 394 </div> 395 </div> 396 397 <div> 398 <div class="flex items-center justify-between"> 399 <label for="password" class="block text-sm font-medium text-gray-900">Password</label> 400 <div class="text-sm"> 401 <button type="button" id="forgotPasswordLink" class="font-semibold text-indigo-600 hover:text-indigo-500">Forgot password?</button> 402 </div> 403 </div> 404 <div class="mt-2"> 405 <input maxlength="255" type="password" name="password" id="password" autocomplete="current-password" required class="block w-full rounded-lg bg-white px-3 py-2 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm"> 406 </div> 407 </div> 408 409 <div> 410 <button type="submit" class="flex w-full justify-center rounded-lg bg-indigo-600 hover:bg-indigo-500 px-4 py-2.5 text-sm font-medium text-white shadow" 411 > 412 Sign in 413 </button> 414 </div> 415 416 417 418 419 <div class="relative mt-4" id="google-divider"> 420 <div class="absolute inset-0 flex items-center"> 421 <div class="w-full border-t border-gray-300"></div> 422 </div> 423 <div class="relative flex justify-center text-sm font-medium"> 424 <span class="bg-white px-4 text-gray-500">Or</span> 425 </div> 426 </div> 427 <div id="server-error" class="error-message text-red-600 text-sm font-medium hidden mb-2 ml-2" role="alert"></div> 428 429 <!-- Google Sign-In Button (hidden on localhost) --> 430 <div class="mt-4" id="google-signin-wrapper"> 431 <div id="g_id_onload" 432 data-client_id="195542994343-d8cmt54tpccg6pmlqgs50r65cg9no78p.apps.googleusercontent.com" 433 data-context="signin" 434 data-ux_mode="popup" 435 data-callback="handleCredentialResponse" 436 data-auto_prompt="false"> 437 </div> 438 439 <div class="g_id_signin" id="google-signin-btn" 440 data-type="standard" 441 data-shape="rectangular" 442 data-theme="outline" 443 data-text="signin_with" 444 data-size="large" 445 data-width="300" 446 data-logo_alignment="left"> 447 </div> 448 </div> 449 450 451 452 453 </form> 454 455 456 457 <p class="mt-8 text-center text-sm text-gray-500"> 458 Not a member? 459 <a href="/signup" class="font-semibold text-indigo-600 hover:text-indigo-500">Start free trial</a> 460 </p> 461 462 463 464 </div> 465 </main> 466 467 <!-- FORGOT PASSWORD FORM --> 468 <div id="forgotPasswordContainer" class="hidden mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> 469 <div class="rounded-2xl bg-white backdrop-blur-lg shadow-xl ring-1 ring-gray-900/5 p-10"> 470 <form id="forgotPasswordForm" class="space-y-6" action="/forgotPassword" method="POST"> 471 <div> 472 <label for="forgot-email" class="block text-sm font-medium text-gray-900">Email address</label> 473 <div class="mt-2"> 474 <input maxlength="255" type="email" name="forgot-email" id="forgot-email" autocomplete="email" required class="block w-full rounded-lg bg-white px-3 py-2 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm"> 475 </div> 476 </div> 477 478 <div> 479 <button type="submit" class="flex w-full justify-center rounded-lg bg-indigo-600 hover:bg-indigo-500 focus-visible:outline focus-visible-outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm" 480 > 481 Reset Password 482 </button> 483 </div> 484 485 <p class="mt-4 text-center text-sm text-gray-500"> 486 <button type="button" id="backToLogin" class="font-semibold text-indigo-600 hover:text-indigo-500">Back to login</button> 487 </p> 488 </form> 489 </div> 490 </div> 491</div> 492
493<script> 494 $(document).ready(function () { 495 // Check if on localhost - hide Google Sign-In if so 496 var isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; 497 if (isLocalhost) { 498 $("#google-signin-wrapper").hide(); 499 $("#google-divider").hide(); 500 } else { 501 // Set Google button width dynamically based on wrapper width (inside form padding) 502 var wrapper = document.getElementById('google-signin-wrapper'); 503 if (wrapper) { 504 var wrapperWidth = wrapper.offsetWidth; 505 var googleBtn = document.getElementById('google-signin-btn'); 506 if (googleBtn && wrapperWidth > 0) { 507 // Google button max width is 400, min is 200 508 var btnWidth = Math.min(400, Math.max(200, wrapperWidth)); 509 googleBtn.setAttribute('data-width', btnWidth); 510 } 511 } 512 } 513 514 $("#forgotPasswordLink").click(function (e) { 515 e.preventDefault(); 516 $("#loginContainer").hide(); 517 $("#forgotPasswordContainer").fadeIn(); 518 }); 519 520 $("#backToLogin").click(function (e) { 521 e.preventDefault(); 522 $("#forgotPasswordContainer").hide(); 523 $("#loginContainer").fadeIn(); 524 }); 525 526 // Wait for Google Sign-In button to fully render before showing (only on production) 527 function waitForGoogleButton() { 528 if (isLocalhost) return; // Skip on localhost 529 530 var wrapper = document.getElementById('google-signin-wrapper'); 531 var iframe = wrapper ? wrapper.querySelector('iframe') : null; 532 533 // Check if iframe exists and has reasonable dimensions (Google finished rendering) 534 if (iframe && iframe.offsetWidth > 100) { 535 // Add a small extra delay to ensure Google's internal rendering is complete 536 setTimeout(function() { 537 $("#google-signin-wrapper").addClass("loaded"); 538 }, 300); 539 } else { 540 // Keep checking every 100ms 541 setTimeout(waitForGoogleButton, 100); 542 } 543 } 544 545 // Start checking after a brief initial delay 546 setTimeout(waitForGoogleButton, 200); 547 548 // Handle login form submission with reCAPTCHA 549 $("#loginForm").on("submit", function(e) { 550 551 e.preventDefault(); 552 var form = this; 553 grecaptcha.enterprise.ready(function() { 554 grecaptcha.enterprise.execute('6LfD6UAsAAAAACQHTt2gxLXDyA5uvafdG6fSs6-d', {action: 'LOGIN'}).then(function(token) { 555 document.getElementById('recaptchaToken').value = token; 556 form.submit(); 557 }).catch(function(err) { 558 console.error('reCAPTCHA error:', err); 559 form.submit(); // Submit anyway if reCAPTCHA fails 560 }); 561 }); 562 563 }); 564 }); 565 566 function handleCredentialResponse(response) { 567 fetch("/signinGoogle", { 568 method: "POST", 569 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 570 body: new URLSearchParams({ token: response.credential }) 571 }) 572 .then(res => res.text()) // Use text first to safely handle JSON parsing 573 .then(text => { 574 let data; 575 try { 576 data = JSON.parse(text); // Safely parse JSON 577 } catch (err) { 578 throw new Error("Server response is not valid JSON: " + text); 579 } 580 581 if (data.error) { 582 showErrorMessage(data.error); 583 } else if (data.redirectUrl) { 584 window.location.href = data.redirectUrl; 585 } else { 586 showErrorMessage("Unexpected response from server."); 587 } 588 }) 589 .catch(error => { 590 console.error("Google Sign-In Error:", error); 591 showErrorMessage("An unexpected error occurred. Please try again."); 592 }); 593 } 594 595 596 function showErrorMessage(message) { 597 $("#server-error").html(message).removeClass("hidden"); 598 } 599</script>
599 600 601</body> 602</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.