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