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