🪻 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3import { UAParser } from "ua-parser-js"; 4 5interface User { 6 email: string; 7 name: string | null; 8 avatar: string; 9 created_at: number; 10} 11 12interface Session { 13 id: string; 14 ip_address: string | null; 15 user_agent: string | null; 16 created_at: number; 17 expires_at: number; 18 is_current: boolean; 19} 20 21type SettingsPage = "account" | "sessions" | "danger"; 22 23@customElement("user-settings") 24export class UserSettings extends LitElement { 25 @state() user: User | null = null; 26 @state() sessions: Session[] = []; 27 @state() loading = true; 28 @state() loadingSessions = true; 29 @state() error = ""; 30 @state() showDeleteConfirm = false; 31 @state() currentPage: SettingsPage = "account"; 32 @state() editingEmail = false; 33 @state() editingPassword = false; 34 @state() newEmail = ""; 35 @state() newPassword = ""; 36 @state() newName = ""; 37 @state() newAvatar = ""; 38 39 static override styles = css` 40 :host { 41 display: block; 42 } 43 44 .settings-container { 45 display: flex; 46 gap: 3rem; 47 } 48 49 .sidebar { 50 width: 250px; 51 background: var(--background); 52 padding: 2rem 0; 53 display: flex; 54 flex-direction: column; 55 } 56 57 .sidebar-item { 58 padding: 0.75rem 1.5rem; 59 background: transparent; 60 color: var(--text); 61 border-radius: 6px; 62 border: 2px solid rgba(191, 192, 192, 0.3); 63 cursor: pointer; 64 font-family: inherit; 65 font-size: 1rem; 66 font-weight: 500; 67 text-align: left; 68 transition: all 0.2s; 69 margin: 0.25rem 1rem; 70 } 71 72 .sidebar-item:hover { 73 background: rgba(79, 93, 117, 0.1); 74 border-color: var(--secondary); 75 color: var(--primary); 76 } 77 78 .sidebar-item.active { 79 background: var(--primary); 80 color: white; 81 border-color: var(--primary); 82 } 83 84 .content { 85 flex: 1; 86 background: var(--background); 87 } 88 89 .content-inner { 90 max-width: 900px; 91 padding: 3rem 2rem 0rem 0; 92 } 93 94 .section { 95 background: var(--background); 96 border: 1px solid var(--secondary); 97 border-radius: 12px; 98 padding: 2rem; 99 margin-bottom: 2rem; 100 } 101 102 .section-title { 103 font-size: 1.25rem; 104 font-weight: 600; 105 color: var(--text); 106 margin: 0 0 1.5rem 0; 107 } 108 109 .field-group { 110 margin-bottom: 1.5rem; 111 } 112 113 .field-group:last-child { 114 margin-bottom: 0; 115 } 116 117 .field-row { 118 display: flex; 119 justify-content: space-between; 120 align-items: center; 121 gap: 1rem; 122 } 123 124 .field-label { 125 font-weight: 500; 126 color: var(--text); 127 font-size: 0.875rem; 128 margin-bottom: 0.5rem; 129 display: block; 130 } 131 132 .field-value { 133 font-size: 1rem; 134 color: var(--text); 135 opacity: 0.8; 136 } 137 138 .change-link { 139 background: none; 140 border: 1px solid var(--secondary); 141 color: var(--text); 142 font-size: 0.875rem; 143 font-weight: 500; 144 cursor: pointer; 145 padding: 0.25rem 0.75rem; 146 border-radius: 6px; 147 font-family: inherit; 148 transition: all 0.2s; 149 } 150 151 .change-link:hover { 152 border-color: var(--primary); 153 color: var(--primary); 154 } 155 156 .btn { 157 padding: 0.75rem 1.5rem; 158 border-radius: 6px; 159 font-size: 1rem; 160 font-weight: 500; 161 cursor: pointer; 162 transition: all 0.2s; 163 font-family: inherit; 164 border: 2px solid transparent; 165 } 166 167 .btn-rejection { 168 background: transparent; 169 color: var(--accent); 170 border-color: var(--accent); 171 } 172 173 .btn-rejection:hover { 174 background: var(--accent); 175 color: white; 176 } 177 178 .btn-small { 179 padding: 0.5rem 1rem; 180 font-size: 0.875rem; 181 } 182 183 .avatar-container:hover .avatar-overlay { 184 opacity: 1; 185 } 186 187 .avatar-overlay { 188 position: absolute; 189 top: 0; 190 left: 0; 191 width: 48px; 192 height: 48px; 193 background: rgba(0, 0, 0, 0.2); 194 border-radius: 50%; 195 border: 2px solid transparent; 196 display: flex; 197 align-items: center; 198 justify-content: center; 199 opacity: 0; 200 cursor: pointer; 201 } 202 203 .reload-symbol { 204 font-size: 18px; 205 color: white; 206 transform: rotate(79deg) translate(0px, -2px); 207 } 208 209 .profile-row { 210 display: flex; 211 align-items: center; 212 gap: 1rem; 213 } 214 215 .avatar-container { 216 position: relative; 217 } 218 219 220 221 .danger-section { 222 border-color: var(--accent); 223 } 224 225 .danger-section .section-title { 226 color: var(--accent); 227 } 228 229 .danger-text { 230 color: var(--text); 231 opacity: 0.7; 232 margin-bottom: 1.5rem; 233 line-height: 1.5; 234 } 235 236 .session-list { 237 display: flex; 238 flex-direction: column; 239 gap: 1rem; 240 } 241 242 .session-card { 243 background: var(--background); 244 border: 1px solid var(--secondary); 245 border-radius: 8px; 246 padding: 1.25rem; 247 } 248 249 .session-card.current { 250 border-color: var(--accent); 251 background: rgba(239, 131, 84, 0.03); 252 } 253 254 .session-header { 255 display: flex; 256 align-items: center; 257 gap: 0.5rem; 258 margin-bottom: 1rem; 259 } 260 261 .session-title { 262 font-weight: 600; 263 color: var(--text); 264 } 265 266 .current-badge { 267 display: inline-block; 268 background: var(--accent); 269 color: white; 270 padding: 0.25rem 0.5rem; 271 border-radius: 4px; 272 font-size: 0.75rem; 273 font-weight: 600; 274 } 275 276 .session-details { 277 display: grid; 278 gap: 0.75rem; 279 } 280 281 .session-row { 282 display: grid; 283 grid-template-columns: 100px 1fr; 284 gap: 1rem; 285 } 286 287 .session-label { 288 font-weight: 500; 289 color: var(--text); 290 opacity: 0.6; 291 font-size: 0.875rem; 292 } 293 294 .session-value { 295 color: var(--text); 296 font-size: 0.875rem; 297 } 298 299 .user-agent { 300 font-family: monospace; 301 word-break: break-all; 302 } 303 304 .field-input { 305 padding: 0.5rem; 306 border: 1px solid var(--secondary); 307 border-radius: 6px; 308 font-family: inherit; 309 font-size: 1rem; 310 color: var(--text); 311 background: var(--background); 312 flex: 1; 313 } 314 315 .field-input:focus { 316 outline: none; 317 border-color: var(--primary); 318 } 319 320 .modal-overlay { 321 position: fixed; 322 top: 0; 323 left: 0; 324 right: 0; 325 bottom: 0; 326 background: rgba(0, 0, 0, 0.5); 327 display: flex; 328 align-items: center; 329 justify-content: center; 330 z-index: 2000; 331 } 332 333 .modal { 334 background: var(--background); 335 border: 2px solid var(--accent); 336 border-radius: 12px; 337 padding: 2rem; 338 max-width: 400px; 339 width: 90%; 340 } 341 342 .modal h3 { 343 margin-top: 0; 344 color: var(--accent); 345 } 346 347 .modal-actions { 348 display: flex; 349 gap: 0.5rem; 350 margin-top: 1.5rem; 351 } 352 353 .btn-neutral { 354 background: transparent; 355 color: var(--text); 356 border-color: var(--secondary); 357 } 358 359 .btn-neutral:hover { 360 border-color: var(--primary); 361 color: var(--primary); 362 } 363 364 .error { 365 color: var(--accent); 366 } 367 368 .loading { 369 text-align: center; 370 color: var(--text); 371 padding: 2rem; 372 } 373 374 @media (max-width: 768px) { 375 .settings-container { 376 flex-direction: column; 377 } 378 379 .sidebar { 380 width: 100%; 381 flex-direction: row; 382 overflow-x: auto; 383 padding: 1rem 0; 384 } 385 386 .sidebar-item { 387 white-space: nowrap; 388 border-left: none; 389 border-bottom: 3px solid transparent; 390 } 391 392 .sidebar-item.active { 393 border-left-color: transparent; 394 border-bottom-color: var(--accent); 395 } 396 397 .content-inner { 398 padding: 2rem 1rem; 399 } 400 } 401 `; 402 403 override async connectedCallback() { 404 super.connectedCallback(); 405 await this.loadUser(); 406 await this.loadSessions(); 407 } 408 409 async loadUser() { 410 try { 411 const response = await fetch("/api/auth/me"); 412 413 if (!response.ok) { 414 window.location.href = "/"; 415 return; 416 } 417 418 this.user = await response.json(); 419 } finally { 420 this.loading = false; 421 } 422 } 423 424 async loadSessions() { 425 try { 426 const response = await fetch("/api/sessions"); 427 428 if (response.ok) { 429 const data = await response.json(); 430 this.sessions = data.sessions; 431 } 432 } finally { 433 this.loadingSessions = false; 434 } 435 } 436 437 async handleLogout() { 438 try { 439 await fetch("/api/auth/logout", { method: "POST" }); 440 window.location.href = "/"; 441 } catch { 442 this.error = "Failed to logout"; 443 } 444 } 445 446 async handleDeleteAccount() { 447 try { 448 const response = await fetch("/api/auth/delete-account", { 449 method: "DELETE", 450 }); 451 452 if (!response.ok) { 453 this.error = "Failed to delete account"; 454 return; 455 } 456 457 window.location.href = "/"; 458 } catch { 459 this.error = "Failed to delete account"; 460 } finally { 461 this.showDeleteConfirm = false; 462 } 463 } 464 465 async handleUpdateEmail() { 466 if (!this.newEmail) { 467 this.error = "Email required"; 468 return; 469 } 470 471 try { 472 const response = await fetch("/api/user/email", { 473 method: "PUT", 474 headers: { "Content-Type": "application/json" }, 475 body: JSON.stringify({ email: this.newEmail }), 476 }); 477 478 if (!response.ok) { 479 const data = await response.json(); 480 this.error = data.error || "Failed to update email"; 481 return; 482 } 483 484 // Reload user data 485 await this.loadUser(); 486 this.editingEmail = false; 487 this.newEmail = ""; 488 } catch { 489 this.error = "Failed to update email"; 490 } 491 } 492 493 async handleUpdatePassword() { 494 if (!this.newPassword) { 495 this.error = "Password required"; 496 return; 497 } 498 499 if (this.newPassword.length < 8) { 500 this.error = "Password must be at least 8 characters"; 501 return; 502 } 503 504 try { 505 const response = await fetch("/api/user/password", { 506 method: "PUT", 507 headers: { "Content-Type": "application/json" }, 508 body: JSON.stringify({ password: this.newPassword }), 509 }); 510 511 if (!response.ok) { 512 const data = await response.json(); 513 this.error = data.error || "Failed to update password"; 514 return; 515 } 516 517 this.editingPassword = false; 518 this.newPassword = ""; 519 } catch { 520 this.error = "Failed to update password"; 521 } 522 } 523 524 async handleUpdateName() { 525 if (!this.newName) { 526 this.error = "Name required"; 527 return; 528 } 529 530 try { 531 const response = await fetch("/api/user/name", { 532 method: "PUT", 533 headers: { "Content-Type": "application/json" }, 534 body: JSON.stringify({ name: this.newName }), 535 }); 536 537 if (!response.ok) { 538 const data = await response.json(); 539 this.error = data.error || "Failed to update name"; 540 return; 541 } 542 543 // Reload user data 544 await this.loadUser(); 545 this.newName = ""; 546 } catch { 547 this.error = "Failed to update name"; 548 } 549 } 550 551 async handleUpdateAvatar() { 552 if (!this.newAvatar) { 553 this.error = "Avatar required"; 554 return; 555 } 556 557 try { 558 const response = await fetch("/api/user/avatar", { 559 method: "PUT", 560 headers: { "Content-Type": "application/json" }, 561 body: JSON.stringify({ avatar: this.newAvatar }), 562 }); 563 564 if (!response.ok) { 565 const data = await response.json(); 566 this.error = data.error || "Failed to update avatar"; 567 return; 568 } 569 570 // Reload user data 571 await this.loadUser(); 572 this.newAvatar = ""; 573 } catch { 574 this.error = "Failed to update avatar"; 575 } 576 } 577 578 generateRandomAvatar() { 579 // Generate a random string for the avatar 580 const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; 581 let result = ""; 582 for (let i = 0; i < 8; i++) { 583 result += chars.charAt(Math.floor(Math.random() * chars.length)); 584 } 585 this.newAvatar = result; 586 this.handleUpdateAvatar(); 587 } 588 589 formatDate(timestamp: number, future = false): string { 590 const date = new Date(timestamp * 1000); 591 const now = new Date(); 592 const diff = Math.abs(now.getTime() - date.getTime()); 593 594 // For future dates (like expiration) 595 if (future || date > now) { 596 // Less than a day 597 if (diff < 24 * 60 * 60 * 1000) { 598 const hours = Math.floor(diff / (60 * 60 * 1000)); 599 return `in ${hours} hour${hours === 1 ? "" : "s"}`; 600 } 601 602 // Less than a week 603 if (diff < 7 * 24 * 60 * 60 * 1000) { 604 const days = Math.floor(diff / (24 * 60 * 60 * 1000)); 605 return `in ${days} day${days === 1 ? "" : "s"}`; 606 } 607 608 // Show full date 609 return date.toLocaleDateString(undefined, { 610 month: "short", 611 day: "numeric", 612 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 613 }); 614 } 615 616 // For past dates 617 // Less than a minute 618 if (diff < 60 * 1000) { 619 return "Just now"; 620 } 621 622 // Less than an hour 623 if (diff < 60 * 60 * 1000) { 624 const minutes = Math.floor(diff / (60 * 1000)); 625 return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; 626 } 627 628 // Less than a day 629 if (diff < 24 * 60 * 60 * 1000) { 630 const hours = Math.floor(diff / (60 * 60 * 1000)); 631 return `${hours} hour${hours === 1 ? "" : "s"} ago`; 632 } 633 634 // Less than a week 635 if (diff < 7 * 24 * 60 * 60 * 1000) { 636 const days = Math.floor(diff / (24 * 60 * 60 * 1000)); 637 return `${days} day${days === 1 ? "" : "s"} ago`; 638 } 639 640 // Show full date 641 return date.toLocaleDateString(undefined, { 642 month: "short", 643 day: "numeric", 644 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 645 }); 646 } 647 648 async handleKillSession(sessionId: string) { 649 try { 650 const response = await fetch(`/api/sessions`, { 651 method: "DELETE", 652 headers: { "Content-Type": "application/json" }, 653 body: JSON.stringify({ sessionId }), 654 }); 655 656 if (!response.ok) { 657 this.error = "Failed to kill session"; 658 return; 659 } 660 661 // Reload sessions 662 await this.loadSessions(); 663 } catch { 664 this.error = "Failed to kill session"; 665 } 666 } 667 668 parseUserAgent(userAgent: string | null): string { 669 if (!userAgent) return "Unknown"; 670 671 const parser = new UAParser(userAgent); 672 const result = parser.getResult(); 673 674 const browser = result.browser.name 675 ? `${result.browser.name}${result.browser.version ? ` ${result.browser.version}` : ""}` 676 : ""; 677 const os = result.os.name 678 ? `${result.os.name}${result.os.version ? ` ${result.os.version}` : ""}` 679 : ""; 680 681 if (browser && os) { 682 return `${browser} on ${os}`; 683 } 684 if (browser) return browser; 685 if (os) return os; 686 687 return userAgent; 688 } 689 690 renderAccountPage() { 691 if (!this.user) return html``; 692 693 const createdDate = new Date( 694 this.user.created_at * 1000, 695 ).toLocaleDateString(); 696 697 return html` 698 <div class="section"> 699 <h2 class="section-title">Profile Information</h2> 700 701 <div class="field-group"> 702 <div class="profile-row"> 703 <div class="avatar-container"> 704 <img 705 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 706 alt="Avatar" 707 style="border-radius: 50%; width: 48px; height: 48px; border: 2px solid var(--secondary); cursor: pointer;" 708 @click=${this.generateRandomAvatar} 709 /> 710 <div class="avatar-overlay" @click=${this.generateRandomAvatar}> 711 <span class="reload-symbol">↻</span> 712 </div> 713 </div> 714 <input 715 type="text" 716 class="field-input" 717 style="flex: 1;" 718 .value=${this.user.name ?? ""} 719 @input=${(e: Event) => { 720 this.newName = (e.target as HTMLInputElement).value; 721 }} 722 @blur=${() => { 723 if (this.newName && this.newName !== (this.user?.name ?? "")) { 724 this.handleUpdateName(); 725 } 726 }} 727 placeholder="Your name" 728 /> 729 </div> 730 </div> 731 732 <div class="field-group"> 733 <label class="field-label">Email</label> 734 ${ 735 this.editingEmail 736 ? html` 737 <div style="display: flex; gap: 0.5rem; align-items: center;"> 738 <input 739 type="email" 740 class="field-input" 741 .value=${this.newEmail} 742 @input=${(e: Event) => { 743 this.newEmail = (e.target as HTMLInputElement).value; 744 }} 745 placeholder=${this.user.email} 746 /> 747 <button 748 class="btn btn-affirmative btn-small" 749 @click=${this.handleUpdateEmail} 750 > 751 Save 752 </button> 753 <button 754 class="btn btn-neutral btn-small" 755 @click=${() => { 756 this.editingEmail = false; 757 this.newEmail = ""; 758 }} 759 > 760 Cancel 761 </button> 762 </div> 763 ` 764 : html` 765 <div class="field-row"> 766 <div class="field-value">${this.user.email}</div> 767 <button 768 class="change-link" 769 @click=${() => { 770 this.editingEmail = true; 771 this.newEmail = this.user?.email ?? ""; 772 }} 773 > 774 Change 775 </button> 776 </div> 777 ` 778 } 779 </div> 780 781 <div class="field-group"> 782 <label class="field-label">Password</label> 783 ${ 784 this.editingPassword 785 ? html` 786 <div style="display: flex; gap: 0.5rem; align-items: center;"> 787 <input 788 type="password" 789 class="field-input" 790 .value=${this.newPassword} 791 @input=${(e: Event) => { 792 this.newPassword = (e.target as HTMLInputElement).value; 793 }} 794 placeholder="New password" 795 /> 796 <button 797 class="btn btn-affirmative btn-small" 798 @click=${this.handleUpdatePassword} 799 > 800 Save 801 </button> 802 <button 803 class="btn btn-neutral btn-small" 804 @click=${() => { 805 this.editingPassword = false; 806 this.newPassword = ""; 807 }} 808 > 809 Cancel 810 </button> 811 </div> 812 ` 813 : html` 814 <div class="field-row"> 815 <div class="field-value">••••••••</div> 816 <button 817 class="change-link" 818 @click=${() => { 819 this.editingPassword = true; 820 }} 821 > 822 Change 823 </button> 824 </div> 825 ` 826 } 827 </div> 828 829 <div class="field-group"> 830 <label class="field-label">Member Since</label> 831 <div class="field-value">${createdDate}</div> 832 </div> 833 </div> 834 835 `; 836 } 837 838 renderSessionsPage() { 839 return html` 840 <div class="section"> 841 <h2 class="section-title">Active Sessions</h2> 842 ${ 843 this.loadingSessions 844 ? html`<div class="loading">Loading sessions...</div>` 845 : this.sessions.length === 0 846 ? html`<p>No active sessions</p>` 847 : html` 848 <div class="session-list"> 849 ${this.sessions.map( 850 (session) => html` 851 <div class="session-card ${session.is_current ? "current" : ""}"> 852 <div class="session-header"> 853 <span class="session-title">Session</span> 854 ${session.is_current ? html`<span class="current-badge">Current</span>` : ""} 855 </div> 856 <div class="session-details"> 857 <div class="session-row"> 858 <span class="session-label">IP Address</span> 859 <span class="session-value">${session.ip_address ?? "Unknown"}</span> 860 </div> 861 <div class="session-row"> 862 <span class="session-label">Device</span> 863 <span class="session-value">${this.parseUserAgent(session.user_agent)}</span> 864 </div> 865 <div class="session-row"> 866 <span class="session-label">Created</span> 867 <span class="session-value">${this.formatDate(session.created_at)}</span> 868 </div> 869 <div class="session-row"> 870 <span class="session-label">Expires</span> 871 <span class="session-value">${this.formatDate(session.expires_at, true)}</span> 872 </div> 873 </div> 874 <div style="margin-top: 1rem;"> 875 ${ 876 session.is_current 877 ? html` 878 <button 879 class="btn btn-rejection" 880 @click=${this.handleLogout} 881 > 882 Logout 883 </button> 884 ` 885 : html` 886 <button 887 class="btn btn-rejection" 888 @click=${() => this.handleKillSession(session.id)} 889 > 890 Kill Session 891 </button> 892 ` 893 } 894 </div> 895 </div> 896 `, 897 )} 898 </div> 899 ` 900 } 901 </div> 902 `; 903 } 904 905 renderDangerPage() { 906 return html` 907 <div class="section danger-section"> 908 <h2 class="section-title">Delete Account</h2> 909 <p class="danger-text"> 910 Once you delete your account, there is no going back. This will 911 permanently delete your account and all associated data. 912 </p> 913 <button 914 class="btn btn-rejection" 915 @click=${() => { 916 this.showDeleteConfirm = true; 917 }} 918 > 919 Delete Account 920 </button> 921 </div> 922 `; 923 } 924 925 override render() { 926 if (this.loading) { 927 return html`<div class="loading">Loading...</div>`; 928 } 929 930 if (this.error) { 931 return html`<div class="error">${this.error}</div>`; 932 } 933 934 if (!this.user) { 935 return html`<div class="error">No user data available</div>`; 936 } 937 938 return html` 939 <div class="settings-container"> 940 <div class="sidebar"> 941 <button 942 class="sidebar-item ${this.currentPage === "account" ? "active" : ""}" 943 @click=${() => { 944 this.currentPage = "account"; 945 }} 946 > 947 Account 948 </button> 949 <button 950 class="sidebar-item ${this.currentPage === "sessions" ? "active" : ""}" 951 @click=${() => { 952 this.currentPage = "sessions"; 953 }} 954 > 955 Sessions 956 </button> 957 <button 958 class="sidebar-item ${this.currentPage === "danger" ? "active" : ""}" 959 @click=${() => { 960 this.currentPage = "danger"; 961 }} 962 > 963 Danger Zone 964 </button> 965 </div> 966 967 <div class="content"> 968 <div class="content-inner"> 969 ${ 970 this.currentPage === "account" 971 ? this.renderAccountPage() 972 : this.currentPage === "sessions" 973 ? this.renderSessionsPage() 974 : this.renderDangerPage() 975 } 976 </div> 977 </div> 978 </div> 979 980 ${ 981 this.showDeleteConfirm 982 ? html` 983 <div 984 class="modal-overlay" 985 @click=${() => { 986 this.showDeleteConfirm = false; 987 }} 988 > 989 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 990 <h3>Delete Account</h3> 991 <p> 992 Are you absolutely sure? This action cannot be undone. All your data will be 993 permanently deleted. 994 </p> 995 <div class="modal-actions"> 996 <button class="btn btn-rejection" @click=${this.handleDeleteAccount}> 997 Yes, Delete My Account 998 </button> 999 <button 1000 class="btn btn-neutral" 1001 @click=${() => { 1002 this.showDeleteConfirm = false; 1003 }} 1004 > 1005 Cancel 1006 </button> 1007 </div> 1008 </div> 1009 </div> 1010 ` 1011 : "" 1012 } 1013 `; 1014 } 1015}