🪻 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 { isPasskeySupported, registerPasskey } from "../lib/client-passkey"; 6 7interface User { 8 email: string; 9 name: string | null; 10 avatar: string; 11 created_at: number; 12} 13 14interface Session { 15 id: string; 16 ip_address: string | null; 17 user_agent: string | null; 18 created_at: number; 19 expires_at: number; 20 is_current: boolean; 21} 22 23interface Passkey { 24 id: string; 25 name: string | null; 26 created_at: number; 27 last_used_at: number | null; 28} 29 30type SettingsPage = "account" | "sessions" | "passkeys" | "danger"; 31 32@customElement("user-settings") 33export class UserSettings extends LitElement { 34 @state() user: User | null = null; 35 @state() sessions: Session[] = []; 36 @state() passkeys: Passkey[] = []; 37 @state() loading = true; 38 @state() loadingSessions = true; 39 @state() loadingPasskeys = true; 40 @state() error = ""; 41 @state() showDeleteConfirm = false; 42 @state() currentPage: SettingsPage = "account"; 43 @state() editingEmail = false; 44 @state() editingPassword = false; 45 @state() newEmail = ""; 46 @state() newPassword = ""; 47 @state() newName = ""; 48 @state() newAvatar = ""; 49 @state() passkeySupported = false; 50 @state() addingPasskey = false; 51 52 static override styles = css` 53 :host { 54 display: block; 55 } 56 57 .settings-container { 58 max-width: 80rem; 59 margin: 0 auto; 60 padding: 2rem; 61 } 62 63 h1 { 64 margin-bottom: 1rem; 65 color: var(--text); 66 } 67 68 .tabs { 69 display: flex; 70 gap: 1rem; 71 border-bottom: 2px solid var(--secondary); 72 margin-bottom: 2rem; 73 } 74 75 .tab { 76 padding: 0.75rem 1.5rem; 77 border: none; 78 background: transparent; 79 color: var(--text); 80 cursor: pointer; 81 font-size: 1rem; 82 font-weight: 500; 83 font-family: inherit; 84 border-bottom: 2px solid transparent; 85 margin-bottom: -2px; 86 transition: all 0.2s; 87 } 88 89 .tab:hover { 90 color: var(--primary); 91 } 92 93 .tab.active { 94 color: var(--primary); 95 border-bottom-color: var(--primary); 96 } 97 98 .tab-content { 99 display: none; 100 } 101 102 .tab-content.active { 103 display: block; 104 } 105 106 .content-inner { 107 max-width: 56rem; 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 padding: 1rem; 397 } 398 399 .tabs { 400 overflow-x: auto; 401 } 402 403 .tab { 404 white-space: nowrap; 405 } 406 407 .content-inner { 408 padding: 0; 409 } 410 } 411 `; 412 413 override async connectedCallback() { 414 super.connectedCallback(); 415 this.passkeySupported = isPasskeySupported(); 416 await this.loadUser(); 417 await this.loadSessions(); 418 if (this.passkeySupported) { 419 await this.loadPasskeys(); 420 } 421 } 422 423 async loadUser() { 424 try { 425 const response = await fetch("/api/auth/me"); 426 427 if (!response.ok) { 428 window.location.href = "/"; 429 return; 430 } 431 432 this.user = await response.json(); 433 } finally { 434 this.loading = false; 435 } 436 } 437 438 async loadSessions() { 439 try { 440 const response = await fetch("/api/sessions"); 441 442 if (response.ok) { 443 const data = await response.json(); 444 this.sessions = data.sessions; 445 } 446 } finally { 447 this.loadingSessions = false; 448 } 449 } 450 451 async loadPasskeys() { 452 try { 453 const response = await fetch("/api/passkeys"); 454 455 if (response.ok) { 456 const data = await response.json(); 457 this.passkeys = data.passkeys; 458 } 459 } finally { 460 this.loadingPasskeys = false; 461 } 462 } 463 464 async handleAddPasskey() { 465 this.addingPasskey = true; 466 this.error = ""; 467 468 try { 469 const name = prompt("Name this passkey (optional):"); 470 if (name === null) { 471 // User cancelled 472 return; 473 } 474 475 const result = await registerPasskey(name || undefined); 476 477 if (!result.success) { 478 this.error = result.error || "Failed to register passkey"; 479 return; 480 } 481 482 // Reload passkeys 483 await this.loadPasskeys(); 484 } finally { 485 this.addingPasskey = false; 486 } 487 } 488 489 async handleDeletePasskey(passkeyId: string) { 490 if (!confirm("Are you sure you want to delete this passkey?")) { 491 return; 492 } 493 494 try { 495 const response = await fetch(`/api/passkeys/${passkeyId}`, { 496 method: "DELETE", 497 }); 498 499 if (!response.ok) { 500 const error = await response.json(); 501 this.error = error.error || "Failed to delete passkey"; 502 return; 503 } 504 505 // Reload passkeys 506 await this.loadPasskeys(); 507 } catch { 508 this.error = "Failed to delete passkey"; 509 } 510 } 511 512 async handleLogout() { 513 try { 514 await fetch("/api/auth/logout", { method: "POST" }); 515 window.location.href = "/"; 516 } catch { 517 this.error = "Failed to logout"; 518 } 519 } 520 521 async handleDeleteAccount() { 522 try { 523 const response = await fetch("/api/auth/delete-account", { 524 method: "DELETE", 525 }); 526 527 if (!response.ok) { 528 this.error = "Failed to delete account"; 529 return; 530 } 531 532 window.location.href = "/"; 533 } catch { 534 this.error = "Failed to delete account"; 535 } finally { 536 this.showDeleteConfirm = false; 537 } 538 } 539 540 async handleUpdateEmail() { 541 if (!this.newEmail) { 542 this.error = "Email required"; 543 return; 544 } 545 546 try { 547 const response = await fetch("/api/user/email", { 548 method: "PUT", 549 headers: { "Content-Type": "application/json" }, 550 body: JSON.stringify({ email: this.newEmail }), 551 }); 552 553 if (!response.ok) { 554 const data = await response.json(); 555 this.error = data.error || "Failed to update email"; 556 return; 557 } 558 559 // Reload user data 560 await this.loadUser(); 561 this.editingEmail = false; 562 this.newEmail = ""; 563 } catch { 564 this.error = "Failed to update email"; 565 } 566 } 567 568 async handleUpdatePassword() { 569 if (!this.newPassword) { 570 this.error = "Password required"; 571 return; 572 } 573 574 if (this.newPassword.length < 8) { 575 this.error = "Password must be at least 8 characters"; 576 return; 577 } 578 579 try { 580 // Hash password client-side before sending 581 const passwordHash = await hashPasswordClient( 582 this.newPassword, 583 this.user?.email ?? "", 584 ); 585 586 const response = await fetch("/api/user/password", { 587 method: "PUT", 588 headers: { "Content-Type": "application/json" }, 589 body: JSON.stringify({ password: passwordHash }), 590 }); 591 592 if (!response.ok) { 593 const data = await response.json(); 594 this.error = data.error || "Failed to update password"; 595 return; 596 } 597 598 this.editingPassword = false; 599 this.newPassword = ""; 600 } catch { 601 this.error = "Failed to update password"; 602 } 603 } 604 605 async handleUpdateName() { 606 if (!this.newName) { 607 this.error = "Name required"; 608 return; 609 } 610 611 try { 612 const response = await fetch("/api/user/name", { 613 method: "PUT", 614 headers: { "Content-Type": "application/json" }, 615 body: JSON.stringify({ name: this.newName }), 616 }); 617 618 if (!response.ok) { 619 const data = await response.json(); 620 this.error = data.error || "Failed to update name"; 621 return; 622 } 623 624 // Reload user data 625 await this.loadUser(); 626 this.newName = ""; 627 } catch { 628 this.error = "Failed to update name"; 629 } 630 } 631 632 async handleUpdateAvatar() { 633 if (!this.newAvatar) { 634 this.error = "Avatar required"; 635 return; 636 } 637 638 try { 639 const response = await fetch("/api/user/avatar", { 640 method: "PUT", 641 headers: { "Content-Type": "application/json" }, 642 body: JSON.stringify({ avatar: this.newAvatar }), 643 }); 644 645 if (!response.ok) { 646 const data = await response.json(); 647 this.error = data.error || "Failed to update avatar"; 648 return; 649 } 650 651 // Reload user data 652 await this.loadUser(); 653 this.newAvatar = ""; 654 } catch { 655 this.error = "Failed to update avatar"; 656 } 657 } 658 659 generateRandomAvatar() { 660 // Generate a random string for the avatar 661 const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; 662 let result = ""; 663 for (let i = 0; i < 8; i++) { 664 result += chars.charAt(Math.floor(Math.random() * chars.length)); 665 } 666 this.newAvatar = result; 667 this.handleUpdateAvatar(); 668 } 669 670 formatDate(timestamp: number, future = false): string { 671 const date = new Date(timestamp * 1000); 672 const now = new Date(); 673 const diff = Math.abs(now.getTime() - date.getTime()); 674 675 // For future dates (like expiration) 676 if (future || date > now) { 677 // Less than a day 678 if (diff < 24 * 60 * 60 * 1000) { 679 const hours = Math.floor(diff / (60 * 60 * 1000)); 680 return `in ${hours} hour${hours === 1 ? "" : "s"}`; 681 } 682 683 // Less than a week 684 if (diff < 7 * 24 * 60 * 60 * 1000) { 685 const days = Math.floor(diff / (24 * 60 * 60 * 1000)); 686 return `in ${days} day${days === 1 ? "" : "s"}`; 687 } 688 689 // Show full date 690 return date.toLocaleDateString(undefined, { 691 month: "short", 692 day: "numeric", 693 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 694 }); 695 } 696 697 // For past dates 698 // Less than a minute 699 if (diff < 60 * 1000) { 700 return "Just now"; 701 } 702 703 // Less than an hour 704 if (diff < 60 * 60 * 1000) { 705 const minutes = Math.floor(diff / (60 * 1000)); 706 return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; 707 } 708 709 // Less than a day 710 if (diff < 24 * 60 * 60 * 1000) { 711 const hours = Math.floor(diff / (60 * 60 * 1000)); 712 return `${hours} hour${hours === 1 ? "" : "s"} ago`; 713 } 714 715 // Less than a week 716 if (diff < 7 * 24 * 60 * 60 * 1000) { 717 const days = Math.floor(diff / (24 * 60 * 60 * 1000)); 718 return `${days} day${days === 1 ? "" : "s"} ago`; 719 } 720 721 // Show full date 722 return date.toLocaleDateString(undefined, { 723 month: "short", 724 day: "numeric", 725 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 726 }); 727 } 728 729 async handleKillSession(sessionId: string) { 730 try { 731 const response = await fetch(`/api/sessions`, { 732 method: "DELETE", 733 headers: { "Content-Type": "application/json" }, 734 body: JSON.stringify({ sessionId }), 735 }); 736 737 if (!response.ok) { 738 this.error = "Failed to kill session"; 739 return; 740 } 741 742 // Reload sessions 743 await this.loadSessions(); 744 } catch { 745 this.error = "Failed to kill session"; 746 } 747 } 748 749 parseUserAgent(userAgent: string | null): string { 750 if (!userAgent) return "Unknown"; 751 752 const parser = new UAParser(userAgent); 753 const result = parser.getResult(); 754 755 const browser = result.browser.name 756 ? `${result.browser.name}${result.browser.version ? ` ${result.browser.version}` : ""}` 757 : ""; 758 const os = result.os.name 759 ? `${result.os.name}${result.os.version ? ` ${result.os.version}` : ""}` 760 : ""; 761 762 if (browser && os) { 763 return `${browser} on ${os}`; 764 } 765 if (browser) return browser; 766 if (os) return os; 767 768 return userAgent; 769 } 770 771 renderAccountPage() { 772 if (!this.user) return html``; 773 774 const createdDate = new Date( 775 this.user.created_at * 1000, 776 ).toLocaleDateString(); 777 778 return html` 779 <div class="content-inner"> 780 <div class="section"> 781 <h2 class="section-title">Profile Information</h2> 782 783 <div class="field-group"> 784 <div class="profile-row"> 785 <div class="avatar-container"> 786 <img 787 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 788 alt="Avatar" 789 style="border-radius: 50%; width: 48px; height: 48px; border: 2px solid var(--secondary); cursor: pointer;" 790 @click=${this.generateRandomAvatar} 791 /> 792 <div class="avatar-overlay" @click=${this.generateRandomAvatar}> 793 <span class="reload-symbol">↻</span> 794 </div> 795 </div> 796 <input 797 type="text" 798 class="field-input" 799 style="flex: 1;" 800 .value=${this.user.name ?? ""} 801 @input=${(e: Event) => { 802 this.newName = (e.target as HTMLInputElement).value; 803 }} 804 @blur=${() => { 805 if (this.newName && this.newName !== (this.user?.name ?? "")) { 806 this.handleUpdateName(); 807 } 808 }} 809 placeholder="Your name" 810 /> 811 </div> 812 </div> 813 814 <div class="field-group"> 815 <label class="field-label">Email</label> 816 ${ 817 this.editingEmail 818 ? html` 819 <div style="display: flex; gap: 0.5rem; align-items: center;"> 820 <input 821 type="email" 822 class="field-input" 823 .value=${this.newEmail} 824 @input=${(e: Event) => { 825 this.newEmail = (e.target as HTMLInputElement).value; 826 }} 827 placeholder=${this.user.email} 828 /> 829 <button 830 class="btn btn-affirmative btn-small" 831 @click=${this.handleUpdateEmail} 832 > 833 Save 834 </button> 835 <button 836 class="btn btn-neutral btn-small" 837 @click=${() => { 838 this.editingEmail = false; 839 this.newEmail = ""; 840 }} 841 > 842 Cancel 843 </button> 844 </div> 845 ` 846 : html` 847 <div class="field-row"> 848 <div class="field-value">${this.user.email}</div> 849 <button 850 class="change-link" 851 @click=${() => { 852 this.editingEmail = true; 853 this.newEmail = this.user?.email ?? ""; 854 }} 855 > 856 Change 857 </button> 858 </div> 859 ` 860 } 861 </div> 862 863 <div class="field-group"> 864 <label class="field-label">Password</label> 865 ${ 866 this.editingPassword 867 ? html` 868 <div style="display: flex; gap: 0.5rem; align-items: center;"> 869 <input 870 type="password" 871 class="field-input" 872 .value=${this.newPassword} 873 @input=${(e: Event) => { 874 this.newPassword = (e.target as HTMLInputElement).value; 875 }} 876 placeholder="New password" 877 /> 878 <button 879 class="btn btn-affirmative btn-small" 880 @click=${this.handleUpdatePassword} 881 > 882 Save 883 </button> 884 <button 885 class="btn btn-neutral btn-small" 886 @click=${() => { 887 this.editingPassword = false; 888 this.newPassword = ""; 889 }} 890 > 891 Cancel 892 </button> 893 </div> 894 ` 895 : html` 896 <div class="field-row"> 897 <div class="field-value">••••••••</div> 898 <button 899 class="change-link" 900 @click=${() => { 901 this.editingPassword = true; 902 }} 903 > 904 Change 905 </button> 906 </div> 907 ` 908 } 909 </div> 910 911 ${ 912 this.passkeySupported 913 ? html` 914 <div class="field-group"> 915 <label class="field-label">Passkeys</label> 916 <p class="field-description"> 917 Passkeys provide a more secure and convenient way to sign in without passwords. 918 They use biometric authentication or your device's security features. 919 </p> 920 ${ 921 this.loadingPasskeys 922 ? html`<div class="field-value">Loading passkeys...</div>` 923 : this.passkeys.length === 0 924 ? html`<div class="field-value" style="color: var(--secondary);">No passkeys registered yet</div>` 925 : html` 926 <div style="display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem;"> 927 ${this.passkeys.map( 928 (passkey) => html` 929 <div class="session-card"> 930 <div class="session-details"> 931 <div class="session-row"> 932 <span class="session-label">Name</span> 933 <span class="session-value">${passkey.name || "Unnamed passkey"}</span> 934 </div> 935 <div class="session-row"> 936 <span class="session-label">Created</span> 937 <span class="session-value">${new Date(passkey.created_at * 1000).toLocaleDateString()}</span> 938 </div> 939 ${ 940 passkey.last_used_at 941 ? html` 942 <div class="session-row"> 943 <span class="session-label">Last used</span> 944 <span class="session-value">${new Date(passkey.last_used_at * 1000).toLocaleDateString()}</span> 945 </div> 946 ` 947 : "" 948 } 949 </div> 950 <button 951 class="btn btn-rejection btn-small" 952 @click=${() => this.handleDeletePasskey(passkey.id)} 953 style="margin-top: 0.75rem;" 954 > 955 Delete 956 </button> 957 </div> 958 `, 959 )} 960 </div> 961 ` 962 } 963 <button 964 class="btn btn-affirmative" 965 style="margin-top: 1rem;" 966 @click=${this.handleAddPasskey} 967 ?disabled=${this.addingPasskey} 968 > 969 ${this.addingPasskey ? "Adding..." : "Add Passkey"} 970 </button> 971 </div> 972 ` 973 : "" 974 } 975 976 <div class="field-group"> 977 <label class="field-label">Member Since</label> 978 <div class="field-value">${createdDate}</div> 979 </div> 980 </div> 981 </div> 982 `; 983 } 984 985 renderSessionsPage() { 986 return html` 987 <div class="content-inner"> 988 <div class="section"> 989 <h2 class="section-title">Active Sessions</h2> 990 ${ 991 this.loadingSessions 992 ? html`<div class="loading">Loading sessions...</div>` 993 : this.sessions.length === 0 994 ? html`<p>No active sessions</p>` 995 : html` 996 <div class="session-list"> 997 ${this.sessions.map( 998 (session) => html` 999 <div class="session-card ${session.is_current ? "current" : ""}"> 1000 <div class="session-header"> 1001 <span class="session-title">Session</span> 1002 ${session.is_current ? html`<span class="current-badge">Current</span>` : ""} 1003 </div> 1004 <div class="session-details"> 1005 <div class="session-row"> 1006 <span class="session-label">IP Address</span> 1007 <span class="session-value">${session.ip_address ?? "Unknown"}</span> 1008 </div> 1009 <div class="session-row"> 1010 <span class="session-label">Device</span> 1011 <span class="session-value">${this.parseUserAgent(session.user_agent)}</span> 1012 </div> 1013 <div class="session-row"> 1014 <span class="session-label">Created</span> 1015 <span class="session-value">${this.formatDate(session.created_at)}</span> 1016 </div> 1017 <div class="session-row"> 1018 <span class="session-label">Expires</span> 1019 <span class="session-value">${this.formatDate(session.expires_at, true)}</span> 1020 </div> 1021 </div> 1022 <div style="margin-top: 1rem;"> 1023 ${ 1024 session.is_current 1025 ? html` 1026 <button 1027 class="btn btn-rejection" 1028 @click=${this.handleLogout} 1029 > 1030 Logout 1031 </button> 1032 ` 1033 : html` 1034 <button 1035 class="btn btn-rejection" 1036 @click=${() => this.handleKillSession(session.id)} 1037 > 1038 Kill Session 1039 </button> 1040 ` 1041 } 1042 </div> 1043 </div> 1044 `, 1045 )} 1046 </div> 1047 ` 1048 } 1049 </div> 1050 </div> 1051 `; 1052 } 1053 1054 renderDangerPage() { 1055 return html` 1056 <div class="content-inner"> 1057 <div class="section danger-section"> 1058 <h2 class="section-title">Delete Account</h2> 1059 <p class="danger-text"> 1060 Once you delete your account, there is no going back. This will 1061 permanently delete your account and all associated data. 1062 </p> 1063 <button 1064 class="btn btn-rejection" 1065 @click=${() => { 1066 this.showDeleteConfirm = true; 1067 }} 1068 > 1069 Delete Account 1070 </button> 1071 </div> 1072 </div> 1073 `; 1074 } 1075 1076 override render() { 1077 if (this.loading) { 1078 return html`<div class="loading">Loading...</div>`; 1079 } 1080 1081 if (this.error) { 1082 return html`<div class="error">${this.error}</div>`; 1083 } 1084 1085 if (!this.user) { 1086 return html`<div class="error">No user data available</div>`; 1087 } 1088 1089 return html` 1090 <div class="settings-container"> 1091 <h1>Settings</h1> 1092 1093 <div class="tabs"> 1094 <button 1095 class="tab ${this.currentPage === "account" ? "active" : ""}" 1096 @click=${() => { 1097 this.currentPage = "account"; 1098 }} 1099 > 1100 Account 1101 </button> 1102 <button 1103 class="tab ${this.currentPage === "sessions" ? "active" : ""}" 1104 @click=${() => { 1105 this.currentPage = "sessions"; 1106 }} 1107 > 1108 Sessions 1109 </button> 1110 <button 1111 class="tab ${this.currentPage === "danger" ? "active" : ""}" 1112 @click=${() => { 1113 this.currentPage = "danger"; 1114 }} 1115 > 1116 Danger Zone 1117 </button> 1118 </div> 1119 1120 ${this.currentPage === "account" ? this.renderAccountPage() : ""} 1121 ${this.currentPage === "sessions" ? this.renderSessionsPage() : ""} 1122 ${this.currentPage === "danger" ? this.renderDangerPage() : ""} 1123 </div> 1124 1125 ${ 1126 this.showDeleteConfirm 1127 ? html` 1128 <div 1129 class="modal-overlay" 1130 @click=${() => { 1131 this.showDeleteConfirm = false; 1132 }} 1133 > 1134 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 1135 <h3>Delete Account</h3> 1136 <p> 1137 Are you absolutely sure? This action cannot be undone. All your data will be 1138 permanently deleted. 1139 </p> 1140 <div class="modal-actions"> 1141 <button class="btn btn-rejection" @click=${this.handleDeleteAccount}> 1142 Yes, Delete My Account 1143 </button> 1144 <button 1145 class="btn btn-neutral" 1146 @click=${() => { 1147 this.showDeleteConfirm = false; 1148 }} 1149 > 1150 Cancel 1151 </button> 1152 </div> 1153 </div> 1154 </div> 1155 ` 1156 : "" 1157 } 1158 `; 1159 } 1160}