馃 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3 4interface User { 5 email: string; 6 name: string | null; 7 avatar: string; 8} 9 10@customElement("auth-component") 11export class AuthComponent extends LitElement { 12 @state() user: User | null = null; 13 @state() loading = true; 14 @state() showModal = false; 15 @state() email = ""; 16 @state() password = ""; 17 @state() name = ""; 18 @state() error = ""; 19 @state() isSubmitting = false; 20 @state() needsRegistration = false; 21 22 static override styles = css` 23 :host { 24 display: block; 25 } 26 27 .auth-container { 28 position: relative; 29 } 30 31 .auth-button { 32 display: flex; 33 align-items: center; 34 gap: 0.5rem; 35 padding: 0.5rem 1rem; 36 background: var(--primary); 37 color: white; 38 border: 2px solid var(--primary); 39 border-radius: 8px; 40 cursor: pointer; 41 font-size: 1rem; 42 font-weight: 500; 43 transition: all 0.2s; 44 font-family: inherit; 45 } 46 47 .auth-button:hover { 48 background: transparent; 49 color: var(--primary); 50 } 51 52 .auth-button:hover .email { 53 color: var(--primary); 54 } 55 56 .auth-button img { 57 transition: all 0.2s; 58 } 59 60 .auth-button:hover img { 61 opacity: 0.8; 62 } 63 64 .user-info { 65 display: flex; 66 align-items: center; 67 gap: 0.75rem; 68 } 69 70 .email { 71 font-weight: 500; 72 color: white; 73 font-size: 0.875rem; 74 transition: all 0.2s; 75 } 76 77 .modal-overlay { 78 position: fixed; 79 top: 0; 80 left: 0; 81 right: 0; 82 bottom: 0; 83 background: rgba(0, 0, 0, 0.5); 84 display: flex; 85 align-items: center; 86 justify-content: center; 87 z-index: 2000; 88 padding: 1rem; 89 } 90 91 .modal { 92 background: var(--background); 93 border: 2px solid var(--secondary); 94 border-radius: 12px; 95 padding: 2rem; 96 max-width: 400px; 97 width: 100%; 98 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); 99 } 100 101 .modal-title { 102 margin-top: 0; 103 margin-bottom: 1rem; 104 color: var(--text); 105 } 106 107 .form-group { 108 margin-bottom: 1rem; 109 } 110 111 label { 112 display: block; 113 margin-bottom: 0.25rem; 114 font-weight: 500; 115 color: var(--text); 116 font-size: 0.875rem; 117 } 118 119 input { 120 width: 100%; 121 padding: 0.75rem; 122 border: 2px solid var(--secondary); 123 border-radius: 6px; 124 font-size: 1rem; 125 font-family: inherit; 126 background: var(--background); 127 color: var(--text); 128 transition: all 0.2s; 129 box-sizing: border-box; 130 } 131 132 input:focus { 133 outline: none; 134 border-color: var(--primary); 135 } 136 137 .error-message { 138 color: var(--accent); 139 font-size: 0.875rem; 140 margin-top: 1rem; 141 } 142 143 button { 144 padding: 0.75rem 1.5rem; 145 border: 2px solid var(--primary); 146 border-radius: 6px; 147 font-size: 1rem; 148 font-weight: 500; 149 cursor: pointer; 150 transition: all 0.2s; 151 font-family: inherit; 152 } 153 154 button:disabled { 155 opacity: 0.6; 156 cursor: not-allowed; 157 } 158 159 .btn-primary { 160 background: var(--primary); 161 color: white; 162 flex: 1; 163 } 164 165 .btn-primary:hover:not(:disabled) { 166 background: transparent; 167 color: var(--primary); 168 } 169 170 .btn-neutral { 171 background: transparent; 172 color: var(--text); 173 border-color: var(--secondary); 174 } 175 176 .btn-neutral:hover:not(:disabled) { 177 border-color: var(--primary); 178 color: var(--primary); 179 } 180 181 .btn-rejection { 182 background: transparent; 183 color: var(--accent); 184 border-color: var(--accent); 185 } 186 187 .btn-rejection:hover:not(:disabled) { 188 background: var(--accent); 189 color: white; 190 } 191 192 .modal-actions { 193 display: flex; 194 gap: 0.5rem; 195 margin-top: 1rem; 196 } 197 198 .user-menu { 199 position: absolute; 200 top: calc(100% + 0.5rem); 201 right: 0; 202 background: var(--background); 203 border: 2px solid var(--secondary); 204 border-radius: 8px; 205 padding: 0.5rem; 206 min-width: 200px; 207 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 208 display: flex; 209 flex-direction: column; 210 gap: 0.5rem; 211 z-index: 100; 212 } 213 214 .user-menu a, 215 .user-menu button { 216 padding: 0.75rem 1rem; 217 background: transparent; 218 color: var(--text); 219 text-decoration: none; 220 border: none; 221 border-radius: 6px; 222 font-weight: 500; 223 text-align: left; 224 transition: all 0.2s; 225 font-family: inherit; 226 font-size: 1rem; 227 cursor: pointer; 228 } 229 230 .user-menu a:hover, 231 .user-menu button:hover { 232 background: var(--secondary); 233 } 234 235 .loading { 236 font-size: 0.875rem; 237 color: var(--text); 238 } 239 240 .info-text { 241 color: var(--text); 242 font-size: 0.875rem; 243 margin: 0; 244 } 245 `; 246 247 override async connectedCallback() { 248 super.connectedCallback(); 249 await this.checkAuth(); 250 } 251 252 async checkAuth() { 253 try { 254 const response = await fetch("/api/auth/me"); 255 256 if (response.ok) { 257 this.user = await response.json(); 258 } 259 } finally { 260 this.loading = false; 261 } 262 } 263 264 private openModal() { 265 this.showModal = true; 266 this.needsRegistration = false; 267 this.email = ""; 268 this.password = ""; 269 this.name = ""; 270 this.error = ""; 271 } 272 273 private closeModal() { 274 this.showModal = false; 275 this.needsRegistration = false; 276 this.email = ""; 277 this.password = ""; 278 this.name = ""; 279 this.error = ""; 280 } 281 282 private async handleSubmit(e: Event) { 283 e.preventDefault(); 284 this.error = ""; 285 this.isSubmitting = true; 286 287 try { 288 if (this.needsRegistration) { 289 const response = await fetch("/api/auth/register", { 290 method: "POST", 291 headers: { 292 "Content-Type": "application/json", 293 }, 294 body: JSON.stringify({ 295 email: this.email, 296 password: this.password, 297 name: this.name || null, 298 }), 299 }); 300 301 if (!response.ok) { 302 const data = await response.json(); 303 this.error = data.error || "Registration failed"; 304 return; 305 } 306 307 this.user = await response.json(); 308 this.closeModal(); 309 await this.checkAuth(); 310 window.dispatchEvent(new CustomEvent("auth-changed")); 311 } else { 312 const response = await fetch("/api/auth/login", { 313 method: "POST", 314 headers: { 315 "Content-Type": "application/json", 316 }, 317 body: JSON.stringify({ 318 email: this.email, 319 password: this.password, 320 }), 321 }); 322 323 if (!response.ok) { 324 const data = await response.json(); 325 326 if ( 327 response.status === 401 && 328 data.error?.includes("Invalid email") 329 ) { 330 this.needsRegistration = true; 331 this.error = ""; 332 return; 333 } 334 335 this.error = data.error || "Login failed"; 336 return; 337 } 338 339 this.user = await response.json(); 340 this.closeModal(); 341 await this.checkAuth(); 342 window.dispatchEvent(new CustomEvent("auth-changed")); 343 } 344 } finally { 345 this.isSubmitting = false; 346 } 347 } 348 349 private async handleLogout() { 350 try { 351 await fetch("/api/auth/logout", { method: "POST" }); 352 this.user = null; 353 window.dispatchEvent(new CustomEvent("auth-changed")); 354 } catch { 355 // Silent fail 356 } 357 } 358 359 private toggleMenu() { 360 this.showModal = !this.showModal; 361 } 362 363 private handleEmailInput(e: Event) { 364 this.email = (e.target as HTMLInputElement).value; 365 } 366 367 private handleNameInput(e: Event) { 368 this.name = (e.target as HTMLInputElement).value; 369 } 370 371 private handlePasswordInput(e: Event) { 372 this.password = (e.target as HTMLInputElement).value; 373 } 374 375 override render() { 376 if (this.loading) { 377 return html`<div class="loading">Loading...</div>`; 378 } 379 380 return html` 381 <div class="auth-container"> 382 ${ 383 this.user 384 ? html` 385 <button class="auth-button" @click=${this.toggleMenu}> 386 <div class="user-info"> 387 <img 388 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 389 alt="Avatar" 390 width="32" 391 height="32" 392 style="border-radius: 50%" 393 /> 394 <span class="email">${this.user.name ?? this.user.email}</span> 395 </div> 396 </button> 397 ${ 398 this.showModal 399 ? html` 400 <div class="user-menu"> 401 <a href="/transcribe" @click=${this.closeModal}>Transcribe</a> 402 <a href="/settings" @click=${this.closeModal}>Settings</a> 403 <button @click=${this.handleLogout}>Logout</button> 404 </div> 405 ` 406 : "" 407 } 408 ` 409 : html` 410 <button class="auth-button" @click=${this.openModal}> 411 Sign In 412 </button> 413 ` 414 } 415 </div> 416 417 ${ 418 this.showModal && !this.user 419 ? html` 420 <div class="modal-overlay" @click=${this.closeModal}> 421 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 422 <h2 class="modal-title"> 423 ${this.needsRegistration ? "Create Account" : "Sign In"} 424 </h2> 425 426 ${ 427 this.needsRegistration 428 ? html` 429 <p class="info-text"> 430 That email isn't registered yet. Let's create an 431 account! 432 </p> 433 ` 434 : "" 435 } 436 437 <form @submit=${this.handleSubmit}> 438 <div class="form-group"> 439 <label for="email">Email</label> 440 <input 441 type="email" 442 id="email" 443 .value=${this.email} 444 @input=${this.handleEmailInput} 445 required 446 ?disabled=${this.isSubmitting} 447 /> 448 </div> 449 450 ${ 451 this.needsRegistration 452 ? html` 453 <div class="form-group"> 454 <label for="name">Name (optional)</label> 455 <input 456 type="text" 457 id="name" 458 .value=${this.name} 459 @input=${this.handleNameInput} 460 ?disabled=${this.isSubmitting} 461 /> 462 </div> 463 ` 464 : "" 465 } 466 467 <div class="form-group"> 468 <label for="password">Password</label> 469 <input 470 type="password" 471 id="password" 472 .value=${this.password} 473 @input=${this.handlePasswordInput} 474 required 475 ?disabled=${this.isSubmitting} 476 /> 477 </div> 478 479 ${ 480 this.error 481 ? html`<div class="error-message">${this.error}</div>` 482 : "" 483 } 484 485 <div class="modal-actions"> 486 <button 487 type="submit" 488 class="btn-primary" 489 ?disabled=${this.isSubmitting} 490 > 491 ${ 492 this.isSubmitting 493 ? "Loading..." 494 : this.needsRegistration 495 ? "Create Account" 496 : "Sign In" 497 } 498 </button> 499 <button 500 type="button" 501 class="btn-neutral" 502 @click=${this.closeModal} 503 ?disabled=${this.isSubmitting} 504 > 505 Cancel 506 </button> 507 </div> 508 </form> 509 </div> 510 </div> 511 ` 512 : "" 513 } 514 `; 515 } 516}