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