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