馃 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 } else if (window.location.pathname !== "/") { 259 window.location.href = "/"; 260 } 261 } finally { 262 this.loading = false; 263 } 264 } 265 266 private openModal() { 267 this.showModal = true; 268 this.needsRegistration = false; 269 this.email = ""; 270 this.password = ""; 271 this.name = ""; 272 this.error = ""; 273 } 274 275 private closeModal() { 276 this.showModal = false; 277 this.needsRegistration = false; 278 this.email = ""; 279 this.password = ""; 280 this.name = ""; 281 this.error = ""; 282 } 283 284 private async handleSubmit(e: Event) { 285 e.preventDefault(); 286 this.error = ""; 287 this.isSubmitting = true; 288 289 try { 290 if (this.needsRegistration) { 291 const response = await fetch("/api/auth/register", { 292 method: "POST", 293 headers: { 294 "Content-Type": "application/json", 295 }, 296 body: JSON.stringify({ 297 email: this.email, 298 password: this.password, 299 name: this.name || null, 300 }), 301 }); 302 303 if (!response.ok) { 304 const data = await response.json(); 305 this.error = data.error || "Registration failed"; 306 return; 307 } 308 309 this.user = await response.json(); 310 this.closeModal(); 311 await this.checkAuth(); 312 window.dispatchEvent(new CustomEvent("auth-changed")); 313 } else { 314 const response = await fetch("/api/auth/login", { 315 method: "POST", 316 headers: { 317 "Content-Type": "application/json", 318 }, 319 body: JSON.stringify({ 320 email: this.email, 321 password: this.password, 322 }), 323 }); 324 325 if (!response.ok) { 326 const data = await response.json(); 327 328 if ( 329 response.status === 401 && 330 data.error?.includes("Invalid email") 331 ) { 332 this.needsRegistration = true; 333 this.error = ""; 334 return; 335 } 336 337 this.error = data.error || "Login failed"; 338 return; 339 } 340 341 this.user = await response.json(); 342 this.closeModal(); 343 await this.checkAuth(); 344 window.dispatchEvent(new CustomEvent("auth-changed")); 345 } 346 } finally { 347 this.isSubmitting = false; 348 } 349 } 350 351 private async handleLogout() { 352 try { 353 await fetch("/api/auth/logout", { method: "POST" }); 354 this.user = null; 355 window.dispatchEvent(new CustomEvent("auth-changed")); 356 window.location.href = "/"; 357 } catch { 358 // Silent fail 359 } 360 } 361 362 private toggleMenu() { 363 this.showModal = !this.showModal; 364 } 365 366 private handleEmailInput(e: Event) { 367 this.email = (e.target as HTMLInputElement).value; 368 } 369 370 private handleNameInput(e: Event) { 371 this.name = (e.target as HTMLInputElement).value; 372 } 373 374 private handlePasswordInput(e: Event) { 375 this.password = (e.target as HTMLInputElement).value; 376 } 377 378 override render() { 379 if (this.loading) { 380 return html`<div class="loading">Loading...</div>`; 381 } 382 383 return html` 384 <div class="auth-container"> 385 ${ 386 this.user 387 ? html` 388 <button class="auth-button" @click=${this.toggleMenu}> 389 <div class="user-info"> 390 <img 391 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 392 alt="Avatar" 393 width="32" 394 height="32" 395 style="border-radius: 50%" 396 /> 397 <span class="email">${this.user.name ?? this.user.email}</span> 398 </div> 399 </button> 400 ${ 401 this.showModal 402 ? html` 403 <div class="user-menu"> 404 <a href="/transcribe" @click=${this.closeModal}>Transcribe</a> 405 <a href="/settings" @click=${this.closeModal}>Settings</a> 406 <button @click=${this.handleLogout}>Logout</button> 407 </div> 408 ` 409 : "" 410 } 411 ` 412 : html` 413 <button class="auth-button" @click=${this.openModal}> 414 Sign In 415 </button> 416 ` 417 } 418 </div> 419 420 ${ 421 this.showModal && !this.user 422 ? html` 423 <div class="modal-overlay" @click=${this.closeModal}> 424 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 425 <h2 class="modal-title"> 426 ${this.needsRegistration ? "Create Account" : "Sign In"} 427 </h2> 428 429 ${ 430 this.needsRegistration 431 ? html` 432 <p class="info-text"> 433 That email isn't registered yet. Let's create an 434 account! 435 </p> 436 ` 437 : "" 438 } 439 440 <form @submit=${this.handleSubmit}> 441 <div class="form-group"> 442 <label for="email">Email</label> 443 <input 444 type="email" 445 id="email" 446 .value=${this.email} 447 @input=${this.handleEmailInput} 448 required 449 ?disabled=${this.isSubmitting} 450 /> 451 </div> 452 453 ${ 454 this.needsRegistration 455 ? html` 456 <div class="form-group"> 457 <label for="name">Name (optional)</label> 458 <input 459 type="text" 460 id="name" 461 .value=${this.name} 462 @input=${this.handleNameInput} 463 ?disabled=${this.isSubmitting} 464 /> 465 </div> 466 ` 467 : "" 468 } 469 470 <div class="form-group"> 471 <label for="password">Password</label> 472 <input 473 type="password" 474 id="password" 475 .value=${this.password} 476 @input=${this.handlePasswordInput} 477 required 478 ?disabled=${this.isSubmitting} 479 /> 480 </div> 481 482 ${ 483 this.error 484 ? html`<div class="error-message">${this.error}</div>` 485 : "" 486 } 487 488 <div class="modal-actions"> 489 <button 490 type="submit" 491 class="btn-primary" 492 ?disabled=${this.isSubmitting} 493 > 494 ${ 495 this.isSubmitting 496 ? "Loading..." 497 : this.needsRegistration 498 ? "Create Account" 499 : "Sign In" 500 } 501 </button> 502 <button 503 type="button" 504 class="btn-neutral" 505 @click=${this.closeModal} 506 ?disabled=${this.isSubmitting} 507 > 508 Cancel 509 </button> 510 </div> 511 </form> 512 </div> 513 </div> 514 ` 515 : "" 516 } 517 `; 518 } 519}