馃 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 } else { 391 const response = await fetch("/api/auth/login", { 392 method: "POST", 393 headers: { 394 "Content-Type": "application/json", 395 }, 396 body: JSON.stringify({ 397 email: this.email, 398 password: passwordHash, 399 }), 400 }); 401 402 if (!response.ok) { 403 const data = await response.json(); 404 if (response.status === 401) { 405 this.needsRegistration = true; 406 this.error = ""; 407 return; 408 } 409 this.error = data.error || "Login failed"; 410 return; 411 } 412 413 this.user = await response.json(); 414 this.closeModal(); 415 await this.checkAuth(); 416 window.dispatchEvent(new CustomEvent("auth-changed")); 417 } 418 } catch (error) { 419 // Catch crypto.subtle errors and other exceptions 420 this.error = error instanceof Error ? error.message : "An error occurred"; 421 } finally { 422 this.isSubmitting = false; 423 } 424 } 425 426 private async handleLogout() { 427 try { 428 await fetch("/api/auth/logout", { method: "POST" }); 429 this.user = null; 430 window.dispatchEvent(new CustomEvent("auth-changed")); 431 window.location.href = "/"; 432 } catch { 433 // Silent fail 434 } 435 } 436 437 private toggleMenu() { 438 this.showModal = !this.showModal; 439 } 440 441 private handleEmailInput(e: Event) { 442 this.email = (e.target as HTMLInputElement).value; 443 } 444 445 private handleNameInput(e: Event) { 446 this.name = (e.target as HTMLInputElement).value; 447 } 448 449 private handlePasswordInput(e: Event) { 450 this.password = (e.target as HTMLInputElement).value; 451 } 452 453 private handlePasswordBlur() { 454 if (!this.needsRegistration) return; 455 456 const strengthComponent = this.shadowRoot?.querySelector( 457 "password-strength", 458 ) as PasswordStrength | null; 459 if (strengthComponent && this.password) { 460 strengthComponent.checkHIBP(this.password); 461 } 462 } 463 464 private handleStrengthChange(e: CustomEvent<PasswordStrengthResult>) { 465 this.passwordStrength = e.detail; 466 } 467 468 private async handlePasskeyLogin() { 469 this.error = ""; 470 this.isSubmitting = true; 471 472 try { 473 const result = await authenticateWithPasskey(this.email || undefined); 474 475 if (!result.success) { 476 this.error = result.error || "Passkey authentication failed"; 477 return; 478 } 479 480 // Success - reload to get user info 481 await this.checkAuth(); 482 this.closeModal(); 483 window.dispatchEvent(new CustomEvent("auth-changed")); 484 } finally { 485 this.isSubmitting = false; 486 } 487 } 488 489 override render() { 490 if (this.loading) { 491 return html`<div class="loading">Loading...</div>`; 492 } 493 494 return html` 495 <div class="auth-container"> 496 ${ 497 this.user 498 ? html` 499 <button class="auth-button" @click=${this.toggleMenu}> 500 <div class="user-info"> 501 <img 502 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 503 alt="Avatar" 504 width="32" 505 height="32" 506 style="border-radius: 50%" 507 /> 508 <span class="email">${this.user.name ?? this.user.email}</span> 509 </div> 510 </button> 511 ${ 512 this.showModal 513 ? html` 514 <div class="user-menu"> 515 <a href="/classes" @click=${this.closeModal}>Classes</a> 516 <a href="/settings" @click=${this.closeModal}>Settings</a> 517 ${ 518 this.user.role === "admin" 519 ? html`<a href="/admin" @click=${this.closeModal} class="admin-link">Admin</a>` 520 : "" 521 } 522 <button @click=${this.handleLogout}>Logout</button> 523 </div> 524 ` 525 : "" 526 } 527 ` 528 : html` 529 <button class="auth-button" @click=${this.openModal}> 530 Sign In 531 </button> 532 ` 533 } 534 </div> 535 536 ${ 537 this.showModal && !this.user 538 ? html` 539 <div class="modal-overlay" @click=${this.closeModal}> 540 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 541 <h2 class="modal-title"> 542 ${this.needsRegistration ? "Create Account" : "Sign In"} 543 </h2> 544 545 ${ 546 this.needsRegistration 547 ? html` 548 <p class="info-text"> 549 Looks like you might not have an account yet. Create one below! 550 </p> 551 ` 552 : "" 553 } 554 555 ${ 556 !this.needsRegistration && this.passkeySupported 557 ? html` 558 <button 559 type="button" 560 class="btn-passkey" 561 @click=${this.handlePasskeyLogin} 562 ?disabled=${this.isSubmitting} 563 > 564 馃攽 ${this.isSubmitting ? "Loading..." : "Sign in with Passkey"} 565 </button> 566 <div class="divider">or sign in with password</div> 567 ` 568 : "" 569 } 570 571 <form @submit=${this.handleSubmit}> 572 <div class="form-group"> 573 <input 574 type="email" 575 id="email" 576 placeholder="heidi@awesome.net" 577 .value=${this.email} 578 @input=${this.handleEmailInput} 579 required 580 ?disabled=${this.isSubmitting} 581 /> 582 </div> 583 584 ${ 585 this.needsRegistration 586 ? html` 587 <div class="form-group"> 588 <label for="name">Name (optional)</label> 589 <input 590 type="text" 591 id="name" 592 placeholder="Heidi VanCoolbeans" 593 .value=${this.name} 594 @input=${this.handleNameInput} 595 ?disabled=${this.isSubmitting} 596 /> 597 </div> 598 ` 599 : "" 600 } 601 602 <div class="form-group"> 603 <label for="password">Password</label> 604 <input 605 type="password" 606 id="password" 607 placeholder="*************" 608 .value=${this.password} 609 @input=${this.handlePasswordInput} 610 @blur=${this.handlePasswordBlur} 611 required 612 ?disabled=${this.isSubmitting} 613 /> 614 ${ 615 this.needsRegistration 616 ? html`<password-strength 617 .password=${this.password} 618 @strength-change=${this.handleStrengthChange} 619 ></password-strength>` 620 : "" 621 } 622 </div> 623 624 ${ 625 this.error 626 ? html`<div class="error-message">${this.error}</div>` 627 : "" 628 } 629 630 <div class="modal-actions"> 631 <button 632 type="submit" 633 class="btn-primary" 634 ?disabled=${ 635 this.isSubmitting || 636 (this.passwordStrength?.isChecking ?? false) || 637 (this.needsRegistration && 638 !(this.passwordStrength?.isValid ?? false)) 639 } 640 > 641 ${ 642 this.isSubmitting 643 ? "Loading..." 644 : this.needsRegistration 645 ? "Create Account" 646 : "Sign In" 647 } 648 </button> 649 <button 650 type="button" 651 class="btn-neutral" 652 @click=${this.closeModal} 653 ?disabled=${this.isSubmitting} 654 > 655 Cancel 656 </button> 657 </div> 658 </form> 659 </div> 660 </div> 661 ` 662 : "" 663 } 664 `; 665 } 666}