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