馃 distributed transcription service thistle.dunkirk.sh
at main 22 kB view raw
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3import { hashPasswordClient } from "../lib/client-auth"; 4import { 5 authenticateWithPasskey, 6 isPasskeySupported, 7} from "../lib/client-passkey"; 8import type { PasswordStrength } from "./password-strength"; 9import "./password-strength"; 10import type { PasswordStrengthResult } from "./password-strength"; 11 12interface User { 13 email: string; 14 name: string | null; 15 avatar: string; 16 role?: "user" | "admin"; 17 has_subscription?: boolean; 18} 19 20@customElement("auth-component") 21export class AuthComponent extends LitElement { 22 @state() user: User | null = null; 23 @state() loading = true; 24 @state() showModal = false; 25 @state() email = ""; 26 @state() password = ""; 27 @state() name = ""; 28 @state() error = ""; 29 @state() isSubmitting = false; 30 @state() needsRegistration = false; 31 @state() passwordStrength: PasswordStrengthResult | null = null; 32 @state() passkeySupported = false; 33 @state() needsEmailVerification = false; 34 @state() verificationCode = ""; 35 @state() resendCodeTimer = 0; 36 @state() resendingCode = false; 37 private resendInterval: number | null = null; 38 private codeSentAt: number | null = null; // Unix timestamp in seconds when code was sent 39 40 static override styles = css` 41 :host { 42 display: block; 43 } 44 45 .auth-container { 46 position: relative; 47 } 48 49 .auth-button { 50 display: flex; 51 align-items: center; 52 gap: 0.5rem; 53 padding: 0.5rem 1rem; 54 background: var(--primary); 55 color: white; 56 border: 2px solid var(--primary); 57 border-radius: 8px; 58 cursor: pointer; 59 font-size: 1rem; 60 font-weight: 500; 61 transition: all 0.2s; 62 font-family: inherit; 63 } 64 65 .auth-button:hover { 66 background: transparent; 67 color: var(--primary); 68 } 69 70 .auth-button:hover .email { 71 color: var(--primary); 72 } 73 74 .auth-button img { 75 transition: all 0.2s; 76 } 77 78 .auth-button:hover img { 79 opacity: 0.8; 80 } 81 82 .user-info { 83 display: flex; 84 align-items: center; 85 gap: 0.75rem; 86 } 87 88 .email { 89 font-weight: 500; 90 color: white; 91 font-size: 0.875rem; 92 transition: all 0.2s; 93 } 94 95 .modal-overlay { 96 position: fixed; 97 top: 0; 98 left: 0; 99 right: 0; 100 bottom: 0; 101 background: rgba(0, 0, 0, 0.5); 102 display: flex; 103 align-items: center; 104 justify-content: center; 105 z-index: 2000; 106 padding: 1rem; 107 } 108 109 .modal { 110 background: var(--background); 111 border: 2px solid var(--secondary); 112 border-radius: 12px; 113 padding: 2rem; 114 max-width: 400px; 115 width: 100%; 116 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); 117 } 118 119 .modal-title { 120 margin-top: 0; 121 margin-bottom: 1rem; 122 color: var(--text); 123 } 124 125 .form-group { 126 margin-bottom: 1rem; 127 } 128 129 label { 130 display: block; 131 margin-bottom: 0.25rem; 132 font-weight: 500; 133 color: var(--text); 134 font-size: 0.875rem; 135 } 136 137 input { 138 width: 100%; 139 padding: 0.75rem; 140 border: 2px solid var(--secondary); 141 border-radius: 6px; 142 font-size: 1rem; 143 font-family: inherit; 144 background: var(--background); 145 color: var(--text); 146 transition: all 0.2s; 147 box-sizing: border-box; 148 } 149 150 input::placeholder { 151 color: var(--secondary); 152 opacity: 1; 153 } 154 155 input:focus { 156 outline: none; 157 border-color: var(--primary); 158 } 159 160 .error-message { 161 color: var(--accent); 162 font-size: 0.875rem; 163 margin-top: 1rem; 164 } 165 166 button { 167 padding: 0.75rem 1.5rem; 168 border: 2px solid var(--primary); 169 border-radius: 6px; 170 font-size: 1rem; 171 font-weight: 500; 172 cursor: pointer; 173 transition: all 0.2s; 174 font-family: inherit; 175 } 176 177 button:disabled { 178 opacity: 0.6; 179 cursor: not-allowed; 180 } 181 182 .btn-primary { 183 background: var(--primary); 184 color: white; 185 flex: 1; 186 } 187 188 .btn-primary:hover:not(:disabled) { 189 background: transparent; 190 color: var(--primary); 191 } 192 193 .btn-neutral { 194 background: transparent; 195 color: var(--text); 196 border-color: var(--secondary); 197 } 198 199 .btn-neutral:hover:not(:disabled) { 200 border-color: var(--primary); 201 color: var(--primary); 202 } 203 204 .btn-rejection { 205 background: transparent; 206 color: var(--accent); 207 border-color: var(--accent); 208 } 209 210 .btn-rejection:hover:not(:disabled) { 211 background: var(--accent); 212 color: white; 213 } 214 215 .modal-actions { 216 display: flex; 217 gap: 0.5rem; 218 margin-top: 1rem; 219 } 220 221 .user-menu { 222 position: absolute; 223 top: calc(100% + 0.5rem); 224 right: 0; 225 background: var(--background); 226 border: 2px solid var(--secondary); 227 border-radius: 8px; 228 padding: 0.5rem; 229 min-width: 200px; 230 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 231 display: flex; 232 flex-direction: column; 233 gap: 0.5rem; 234 z-index: 100; 235 } 236 237 .user-menu a, 238 .user-menu button { 239 padding: 0.75rem 1rem; 240 background: transparent; 241 color: var(--text); 242 text-decoration: none; 243 border: none; 244 border-radius: 6px; 245 font-weight: 500; 246 text-align: left; 247 transition: all 0.2s; 248 font-family: inherit; 249 font-size: 1rem; 250 cursor: pointer; 251 } 252 253 .user-menu a:hover, 254 .user-menu button:hover { 255 background: var(--secondary); 256 } 257 258 .admin-link { 259 color: #dc2626; 260 border: 2px dashed #dc2626 !important; 261 } 262 263 .admin-link:hover { 264 background: #fee2e2; 265 color: #991b1b; 266 border-color: #991b1b !important; 267 } 268 269 .loading { 270 font-size: 0.875rem; 271 color: var(--text); 272 } 273 274 .info-text { 275 color: var(--text); 276 font-size: 0.875rem; 277 margin: 0 0 1.5rem 0; 278 line-height: 1.5; 279 } 280 281 .verification-code-input { 282 text-align: center; 283 font-size: 1.5rem; 284 letter-spacing: 0.5rem; 285 font-weight: 600; 286 padding: 1rem; 287 font-family: 'Monaco', 'Courier New', monospace; 288 } 289 290 .resend-link { 291 text-align: center; 292 margin-top: 1rem; 293 font-size: 0.875rem; 294 color: var(--text); 295 } 296 297 .resend-button { 298 background: none; 299 border: none; 300 color: var(--primary); 301 cursor: pointer; 302 text-decoration: underline; 303 font-size: 0.875rem; 304 padding: 0; 305 font-family: inherit; 306 } 307 308 .resend-button:hover:not(:disabled) { 309 color: var(--accent); 310 } 311 312 .resend-button:disabled { 313 color: var(--secondary); 314 cursor: not-allowed; 315 text-decoration: none; 316 } 317 318 .btn-secondary { 319 background: transparent; 320 color: var(--text); 321 border-color: var(--secondary); 322 flex: 1; 323 } 324 325 .btn-secondary:hover:not(:disabled) { 326 border-color: var(--primary); 327 color: var(--primary); 328 } 329 330 .divider { 331 display: flex; 332 align-items: center; 333 text-align: center; 334 margin: 1.5rem 0; 335 color: var(--secondary); 336 font-size: 0.875rem; 337 } 338 339 .divider::before, 340 .divider::after { 341 content: ""; 342 flex: 1; 343 border-bottom: 1px solid var(--secondary); 344 } 345 346 .divider::before { 347 margin-right: 0.5rem; 348 } 349 350 .divider::after { 351 margin-left: 0.5rem; 352 } 353 354 .btn-passkey { 355 background: transparent; 356 color: var(--primary); 357 border-color: var(--primary); 358 width: 100%; 359 margin-bottom: 0; 360 } 361 362 .btn-passkey:hover:not(:disabled) { 363 background: var(--primary); 364 color: white; 365 } 366 `; 367 368 override async connectedCallback() { 369 super.connectedCallback(); 370 this.passkeySupported = isPasskeySupported(); 371 await this.checkAuth(); 372 } 373 374 async checkAuth() { 375 try { 376 const response = await fetch("/api/auth/me"); 377 378 if (response.ok) { 379 this.user = await response.json(); 380 } else if (window.location.pathname !== "/") { 381 window.location.href = "/"; 382 } 383 } finally { 384 this.loading = false; 385 } 386 } 387 388 public isAuthenticated(): boolean { 389 return this.user !== null; 390 } 391 392 public openAuthModal() { 393 this.openModal(); 394 } 395 396 private openModal() { 397 this.showModal = true; 398 this.needsRegistration = false; 399 this.email = ""; 400 this.password = ""; 401 this.name = ""; 402 this.error = ""; 403 } 404 405 private closeModal() { 406 this.showModal = false; 407 this.needsRegistration = false; 408 this.email = ""; 409 this.password = ""; 410 this.name = ""; 411 this.error = ""; 412 } 413 414 private async handleSubmit(e: Event) { 415 e.preventDefault(); 416 this.error = ""; 417 this.isSubmitting = true; 418 419 try { 420 // Hash password client-side with expensive PBKDF2 421 const passwordHash = await hashPasswordClient(this.password, this.email); 422 423 if (this.needsRegistration) { 424 const response = await fetch("/api/auth/register", { 425 method: "POST", 426 headers: { 427 "Content-Type": "application/json", 428 }, 429 body: JSON.stringify({ 430 email: this.email, 431 password: passwordHash, 432 name: this.name || null, 433 }), 434 }); 435 436 if (!response.ok) { 437 const data = await response.json(); 438 this.error = data.error || "Registration failed"; 439 return; 440 } 441 442 const data = await response.json(); 443 444 if (data.email_verification_required) { 445 this.needsEmailVerification = true; 446 this.password = ""; 447 this.error = ""; 448 this.startResendTimer(data.verification_code_sent_at); 449 return; 450 } 451 452 this.user = data; 453 this.closeModal(); 454 await this.checkAuth(); 455 window.dispatchEvent(new CustomEvent("auth-changed")); 456 window.location.href = "/classes"; 457 } else { 458 const response = await fetch("/api/auth/login", { 459 method: "POST", 460 headers: { 461 "Content-Type": "application/json", 462 }, 463 body: JSON.stringify({ 464 email: this.email, 465 password: passwordHash, 466 }), 467 }); 468 469 if (!response.ok) { 470 const data = await response.json(); 471 if (response.status === 401) { 472 this.needsRegistration = true; 473 this.error = ""; 474 return; 475 } 476 this.error = data.error || "Login failed"; 477 return; 478 } 479 480 const data = await response.json(); 481 482 if (data.email_verification_required) { 483 this.needsEmailVerification = true; 484 this.password = ""; 485 this.error = ""; 486 this.startResendTimer(data.verification_code_sent_at); 487 return; 488 } 489 490 this.user = data; 491 this.closeModal(); 492 await this.checkAuth(); 493 window.dispatchEvent(new CustomEvent("auth-changed")); 494 window.location.href = "/classes"; 495 } 496 } catch (error) { 497 // Catch crypto.subtle errors and other exceptions 498 this.error = error instanceof Error ? error.message : "An error occurred"; 499 } finally { 500 this.isSubmitting = false; 501 } 502 } 503 504 private async handleLogout() { 505 this.showModal = false; 506 try { 507 await fetch("/api/auth/logout", { method: "POST" }); 508 this.user = null; 509 window.dispatchEvent(new CustomEvent("auth-changed")); 510 window.location.href = "/"; 511 } catch { 512 // Silent fail 513 } 514 } 515 516 private toggleMenu() { 517 this.showModal = !this.showModal; 518 } 519 520 private handleEmailInput(e: Event) { 521 this.email = (e.target as HTMLInputElement).value; 522 } 523 524 private handleNameInput(e: Event) { 525 this.name = (e.target as HTMLInputElement).value; 526 } 527 528 private handlePasswordInput(e: Event) { 529 this.password = (e.target as HTMLInputElement).value; 530 } 531 532 private handleVerificationCodeInput(e: Event) { 533 this.verificationCode = (e.target as HTMLInputElement).value; 534 } 535 536 private async handleVerifyEmail(e: Event) { 537 e.preventDefault(); 538 this.error = ""; 539 this.isSubmitting = true; 540 541 try { 542 const response = await fetch("/api/auth/verify-email", { 543 method: "POST", 544 headers: { 545 "Content-Type": "application/json", 546 }, 547 body: JSON.stringify({ 548 email: this.email, 549 code: this.verificationCode, 550 }), 551 }); 552 553 if (!response.ok) { 554 const data = await response.json(); 555 this.error = data.error || "Verification failed"; 556 return; 557 } 558 559 // Successfully verified - redirect to classes 560 this.closeModal(); 561 await this.checkAuth(); 562 window.dispatchEvent(new CustomEvent("auth-changed")); 563 window.location.href = "/classes"; 564 } catch (error) { 565 this.error = error instanceof Error ? error.message : "An error occurred"; 566 } finally { 567 this.isSubmitting = false; 568 } 569 } 570 571 private handlePasswordBlur() { 572 if (!this.needsRegistration) return; 573 574 const strengthComponent = this.shadowRoot?.querySelector( 575 "password-strength", 576 ) as PasswordStrength | null; 577 if (strengthComponent && this.password) { 578 strengthComponent.checkHIBP(this.password); 579 } 580 } 581 582 private handleStrengthChange(e: CustomEvent<PasswordStrengthResult>) { 583 this.passwordStrength = e.detail; 584 } 585 586 private async handlePasskeyLogin() { 587 this.error = ""; 588 this.isSubmitting = true; 589 590 try { 591 const result = await authenticateWithPasskey(this.email || undefined); 592 593 if (!result.success) { 594 this.error = result.error || "Passkey authentication failed"; 595 return; 596 } 597 598 // Success - reload to get user info 599 await this.checkAuth(); 600 this.closeModal(); 601 window.dispatchEvent(new CustomEvent("auth-changed")); 602 window.location.href = "/classes"; 603 } finally { 604 this.isSubmitting = false; 605 } 606 } 607 608 private startResendTimer(sentAtTimestamp: number) { 609 // Use provided timestamp 610 this.codeSentAt = sentAtTimestamp; 611 612 // Clear existing interval if any 613 if (this.resendInterval !== null) { 614 clearInterval(this.resendInterval); 615 } 616 617 // Update timer based on elapsed time 618 const updateTimer = () => { 619 if (this.codeSentAt === null) return; 620 621 const now = Math.floor(Date.now() / 1000); 622 const elapsed = now - this.codeSentAt; 623 const remaining = Math.max(0, 5 * 60 - elapsed); 624 this.resendCodeTimer = remaining; 625 626 if (remaining <= 0) { 627 if (this.resendInterval !== null) { 628 clearInterval(this.resendInterval); 629 this.resendInterval = null; 630 } 631 } 632 }; 633 634 // Update immediately 635 updateTimer(); 636 637 // Then update every second 638 this.resendInterval = window.setInterval(updateTimer, 1000); 639 } 640 641 private async handleResendCode() { 642 this.error = ""; 643 this.resendingCode = true; 644 645 try { 646 const response = await fetch("/api/auth/resend-verification-code", { 647 method: "POST", 648 headers: { 649 "Content-Type": "application/json", 650 }, 651 body: JSON.stringify({ 652 email: this.email, 653 }), 654 }); 655 656 if (!response.ok) { 657 const data = await response.json(); 658 this.error = data.error || "Failed to resend code"; 659 return; 660 } 661 662 // Start the 5-minute timer 663 this.startResendTimer(data.verification_code_sent_at); 664 } catch (error) { 665 this.error = error instanceof Error ? error.message : "An error occurred"; 666 } finally { 667 this.resendingCode = false; 668 } 669 } 670 671 private formatTimer(seconds: number): string { 672 const mins = Math.floor(seconds / 60); 673 const secs = seconds % 60; 674 return `${mins}:${secs.toString().padStart(2, "0")}`; 675 } 676 677 override disconnectedCallback() { 678 super.disconnectedCallback(); 679 // Clean up timer when component is removed 680 if (this.resendInterval !== null) { 681 clearInterval(this.resendInterval); 682 this.resendInterval = null; 683 } 684 } 685 686 override render() { 687 if (this.loading) { 688 return html`<div class="loading">Loading...</div>`; 689 } 690 691 return html` 692 <div class="auth-container"> 693 ${ 694 this.user 695 ? html` 696 <button class="auth-button" @click=${this.toggleMenu}> 697 <div class="user-info"> 698 <img 699 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 700 alt="Avatar" 701 width="32" 702 height="32" 703 style="border-radius: 50%" 704 /> 705 <span class="email">${this.user.name ?? this.user.email}</span> 706 </div> 707 </button> 708 ${ 709 this.showModal 710 ? html` 711 <div class="user-menu"> 712 <a href="/classes" @click=${this.closeModal}>Classes</a> 713 <a href="/settings" @click=${this.closeModal}>Settings</a> 714 ${ 715 this.user.role === "admin" 716 ? html`<a href="/admin" @click=${this.closeModal} class="admin-link">Admin</a>` 717 : "" 718 } 719 <button @click=${this.handleLogout}>Logout</button> 720 </div> 721 ` 722 : "" 723 } 724 ` 725 : html` 726 <button class="auth-button" @click=${this.openModal}> 727 Sign In 728 </button> 729 ` 730 } 731 </div> 732 733 ${ 734 this.showModal && !this.user 735 ? html` 736 <div class="modal-overlay" @click=${this.closeModal}> 737 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 738 <h2 class="modal-title"> 739 ${this.needsEmailVerification ? "Verify Email" : this.needsRegistration ? "Create Account" : "Sign In"} 740 </h2> 741 742 ${ 743 this.needsEmailVerification 744 ? html` 745 <p class="info-text"> 746 We sent a 6-digit verification code to <strong>${this.email}</strong>.<br> 747 Check your email and enter the code below. 748 </p> 749 750 <form @submit=${this.handleVerifyEmail}> 751 <div class="form-group"> 752 <label for="verification-code">Verification Code</label> 753 <input 754 type="text" 755 id="verification-code" 756 class="verification-code-input" 757 placeholder="000000" 758 .value=${this.verificationCode} 759 @input=${this.handleVerificationCodeInput} 760 required 761 maxlength="6" 762 pattern="[0-9]{6}" 763 inputmode="numeric" 764 ?disabled=${this.isSubmitting} 765 autocomplete="one-time-code" 766 /> 767 </div> 768 769 ${ 770 this.error 771 ? html`<div class="error-message">${this.error}</div>` 772 : "" 773 } 774 775 <div class="resend-link"> 776 ${ 777 this.resendCodeTimer > 0 778 ? html`Resend code in ${this.formatTimer(this.resendCodeTimer)}` 779 : html` 780 <button 781 type="button" 782 class="resend-button" 783 @click=${this.handleResendCode} 784 ?disabled=${this.resendingCode} 785 > 786 ${this.resendingCode ? "Sending..." : "Resend code"} 787 </button> 788 ` 789 } 790 </div> 791 792 <div class="modal-actions"> 793 <button 794 type="submit" 795 class="btn-primary" 796 ?disabled=${this.isSubmitting || this.verificationCode.length !== 6} 797 > 798 ${this.isSubmitting ? "Verifying..." : "Verify Email"} 799 </button> 800 <button 801 type="button" 802 class="btn-secondary" 803 @click=${() => { 804 this.needsEmailVerification = false; 805 this.verificationCode = ""; 806 this.error = ""; 807 }} 808 ?disabled=${this.isSubmitting} 809 > 810 Back 811 </button> 812 </div> 813 </form> 814 ` 815 : html` 816 ${ 817 this.needsRegistration 818 ? html` 819 <p class="info-text"> 820 Looks like you might not have an account yet. Create one below! 821 </p> 822 ` 823 : "" 824 } 825 826 ${ 827 !this.needsRegistration && this.passkeySupported 828 ? html` 829 <button 830 type="button" 831 class="btn-passkey" 832 @click=${this.handlePasskeyLogin} 833 ?disabled=${this.isSubmitting} 834 > 835 馃攽 ${this.isSubmitting ? "Loading..." : "Sign in with Passkey"} 836 </button> 837 <div class="divider">or sign in with password</div> 838 ` 839 : "" 840 } 841 842 <form @submit=${this.handleSubmit}> 843 <div class="form-group"> 844 <input 845 type="email" 846 id="email" 847 placeholder="heidi@awesome.net" 848 .value=${this.email} 849 @input=${this.handleEmailInput} 850 required 851 ?disabled=${this.isSubmitting} 852 /> 853 </div> 854 855 ${ 856 this.needsRegistration 857 ? html` 858 <div class="form-group"> 859 <label for="name">Name (optional)</label> 860 <input 861 type="text" 862 id="name" 863 placeholder="Heidi VanCoolbeans" 864 .value=${this.name} 865 @input=${this.handleNameInput} 866 ?disabled=${this.isSubmitting} 867 /> 868 </div> 869 ` 870 : "" 871 } 872 873 <div class="form-group"> 874 <label for="password">Password</label> 875 <input 876 type="password" 877 id="password" 878 placeholder="*************" 879 .value=${this.password} 880 @input=${this.handlePasswordInput} 881 @blur=${this.handlePasswordBlur} 882 required 883 ?disabled=${this.isSubmitting} 884 /> 885 ${ 886 this.needsRegistration 887 ? html`<password-strength 888 .password=${this.password} 889 @strength-change=${this.handleStrengthChange} 890 ></password-strength>` 891 : "" 892 } 893 </div> 894 895 ${ 896 this.error 897 ? html`<div class="error-message">${this.error}</div>` 898 : "" 899 } 900 901 <div class="modal-actions"> 902 <button 903 type="submit" 904 class="btn-primary" 905 ?disabled=${ 906 this.isSubmitting || 907 (this.passwordStrength?.isChecking ?? false) || 908 (this.needsRegistration && 909 !(this.passwordStrength?.isValid ?? false)) 910 } 911 > 912 ${ 913 this.isSubmitting 914 ? "Loading..." 915 : this.needsRegistration 916 ? "Create Account" 917 : "Sign In" 918 } 919 </button> 920 <button 921 type="button" 922 class="btn-neutral" 923 @click=${this.closeModal} 924 ?disabled=${this.isSubmitting} 925 > 926 Cancel 927 </button> 928 </div> 929 </form> 930 ` 931 } 932 </div> 933 </div> 934 ` 935 : "" 936 } 937 `; 938 } 939}