🪻 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 30interface Subscription { 31 id: string; 32 status: string; 33 current_period_start: number | null; 34 current_period_end: number | null; 35 cancel_at_period_end: number; 36 canceled_at: number | null; 37} 38 39type SettingsPage = 40 | "account" 41 | "sessions" 42 | "passkeys" 43 | "billing" 44 | "notifications" 45 | "danger"; 46 47@customElement("user-settings") 48export class UserSettings extends LitElement { 49 @state() user: User | null = null; 50 @state() sessions: Session[] = []; 51 @state() passkeys: Passkey[] = []; 52 @state() subscription: Subscription | null = null; 53 @state() loading = true; 54 @state() loadingSessions = true; 55 @state() loadingPasskeys = true; 56 @state() loadingSubscription = true; 57 @state() error = ""; 58 @state() showDeleteConfirm = false; 59 @state() currentPage: SettingsPage = "account"; 60 @state() editingEmail = false; 61 @state() editingPassword = false; 62 @state() newEmail = ""; 63 @state() newPassword = ""; 64 @state() newName = ""; 65 @state() newAvatar = ""; 66 @state() passkeySupported = false; 67 @state() addingPasskey = false; 68 @state() emailNotificationsEnabled = true; 69 @state() deletingAccount = false; 70 @state() emailChangeMessage = ""; 71 @state() pendingEmailChange = ""; 72 @state() updatingEmail = false; 73 74 static override styles = css` 75 :host { 76 display: block; 77 } 78 79 .settings-container { 80 max-width: 80rem; 81 margin: 0 auto; 82 padding: 2rem; 83 } 84 85 h1 { 86 margin-bottom: 1rem; 87 color: var(--text); 88 } 89 90 .tabs { 91 display: flex; 92 gap: 1rem; 93 border-bottom: 2px solid var(--secondary); 94 margin-bottom: 2rem; 95 } 96 97 .tab { 98 padding: 0.75rem 1.5rem; 99 border: none; 100 background: transparent; 101 color: var(--text); 102 cursor: pointer; 103 font-size: 1rem; 104 font-weight: 500; 105 font-family: inherit; 106 border-bottom: 2px solid transparent; 107 margin-bottom: -2px; 108 transition: all 0.2s; 109 } 110 111 .tab:hover { 112 color: var(--primary); 113 } 114 115 .tab.active { 116 color: var(--primary); 117 border-bottom-color: var(--primary); 118 } 119 120 .tab-content { 121 display: none; 122 } 123 124 .tab-content.active { 125 display: block; 126 } 127 128 .content-inner { 129 max-width: 56rem; 130 } 131 132 .section { 133 background: var(--background); 134 border: 1px solid var(--secondary); 135 border-radius: 12px; 136 padding: 2rem; 137 margin-bottom: 2rem; 138 } 139 140 .section-title { 141 font-size: 1.25rem; 142 font-weight: 600; 143 color: var(--text); 144 margin: 0 0 1.5rem 0; 145 } 146 147 .field-group { 148 margin-bottom: 1.5rem; 149 } 150 151 .field-group:last-child { 152 margin-bottom: 0; 153 } 154 155 .field-row { 156 display: flex; 157 justify-content: space-between; 158 align-items: center; 159 gap: 1rem; 160 } 161 162 .field-label { 163 font-weight: 500; 164 color: var(--text); 165 font-size: 0.875rem; 166 margin-bottom: 0.5rem; 167 display: block; 168 } 169 170 .field-value { 171 font-size: 1rem; 172 color: var(--text); 173 opacity: 0.8; 174 } 175 176 .change-link { 177 background: none; 178 border: 1px solid var(--secondary); 179 color: var(--text); 180 font-size: 0.875rem; 181 font-weight: 500; 182 cursor: pointer; 183 padding: 0.25rem 0.75rem; 184 border-radius: 6px; 185 font-family: inherit; 186 transition: all 0.2s; 187 } 188 189 .change-link:hover { 190 border-color: var(--primary); 191 color: var(--primary); 192 } 193 194 .btn { 195 padding: 0.75rem 1.5rem; 196 border-radius: 6px; 197 font-size: 1rem; 198 font-weight: 500; 199 cursor: pointer; 200 transition: all 0.2s; 201 font-family: inherit; 202 border: 2px solid transparent; 203 } 204 205 .btn-rejection { 206 background: transparent; 207 color: var(--accent); 208 border-color: var(--accent); 209 } 210 211 .btn-rejection:hover { 212 background: var(--accent); 213 color: white; 214 } 215 216 .btn-affirmative { 217 background: var(--primary); 218 color: white; 219 border-color: var(--primary); 220 } 221 222 .btn-affirmative:hover:not(:disabled) { 223 background: transparent; 224 color: var(--primary); 225 } 226 227 .btn-success { 228 background: var(--success); 229 color: white; 230 border-color: var(--success); 231 } 232 233 .btn-success:hover:not(:disabled) { 234 background: transparent; 235 color: var(--success); 236 } 237 238 .btn-small { 239 padding: 0.5rem 1rem; 240 font-size: 0.875rem; 241 } 242 243 .avatar-container:hover .avatar-overlay { 244 opacity: 1; 245 } 246 247 .avatar-overlay { 248 position: absolute; 249 top: 0; 250 left: 0; 251 width: 48px; 252 height: 48px; 253 background: rgba(0, 0, 0, 0.2); 254 border-radius: 50%; 255 border: 2px solid transparent; 256 display: flex; 257 align-items: center; 258 justify-content: center; 259 opacity: 0; 260 cursor: pointer; 261 } 262 263 .reload-symbol { 264 font-size: 18px; 265 color: white; 266 transform: rotate(79deg) translate(0px, -2px); 267 } 268 269 .profile-row { 270 display: flex; 271 align-items: center; 272 gap: 1rem; 273 } 274 275 .avatar-container { 276 position: relative; 277 } 278 279 .field-description { 280 font-size: 0.875rem; 281 color: var(--paynes-gray); 282 margin: 0.5rem 0; 283 } 284 285 .danger-section { 286 border-color: var(--accent); 287 } 288 289 .danger-section .section-title { 290 color: var(--accent); 291 } 292 293 .danger-text { 294 color: var(--text); 295 opacity: 0.7; 296 margin-bottom: 1.5rem; 297 line-height: 1.5; 298 } 299 300 .success-message { 301 padding: 1rem; 302 background: rgba(76, 175, 80, 0.1); 303 border: 1px solid rgba(76, 175, 80, 0.3); 304 border-radius: 0.5rem; 305 color: var(--text); 306 } 307 308 .spinner { 309 display: inline-block; 310 width: 1rem; 311 height: 1rem; 312 border: 2px solid rgba(255, 255, 255, 0.3); 313 border-top-color: white; 314 border-radius: 50%; 315 animation: spin 0.6s linear infinite; 316 } 317 318 @keyframes spin { 319 to { 320 transform: rotate(360deg); 321 } 322 } 323 324 .session-list { 325 display: flex; 326 flex-direction: column; 327 gap: 1rem; 328 } 329 330 .session-card { 331 background: var(--background); 332 border: 1px solid var(--secondary); 333 border-radius: 8px; 334 padding: 1.25rem; 335 } 336 337 .session-card.current { 338 border-color: var(--accent); 339 background: rgba(239, 131, 84, 0.03); 340 } 341 342 .session-header { 343 display: flex; 344 align-items: center; 345 gap: 0.5rem; 346 margin-bottom: 1rem; 347 } 348 349 .session-title { 350 font-weight: 600; 351 color: var(--text); 352 } 353 354 .current-badge { 355 display: inline-block; 356 background: var(--accent); 357 color: white; 358 padding: 0.25rem 0.5rem; 359 border-radius: 4px; 360 font-size: 0.75rem; 361 font-weight: 600; 362 } 363 364 .session-details { 365 display: grid; 366 gap: 0.75rem; 367 } 368 369 .session-row { 370 display: grid; 371 grid-template-columns: 100px 1fr; 372 gap: 1rem; 373 } 374 375 .session-label { 376 font-weight: 500; 377 color: var(--text); 378 opacity: 0.6; 379 font-size: 0.875rem; 380 } 381 382 .session-value { 383 color: var(--text); 384 font-size: 0.875rem; 385 } 386 387 .user-agent { 388 font-family: monospace; 389 word-break: break-all; 390 } 391 392 .field-input { 393 padding: 0.5rem; 394 border: 1px solid var(--secondary); 395 border-radius: 6px; 396 font-family: inherit; 397 font-size: 1rem; 398 color: var(--text); 399 background: var(--background); 400 flex: 1; 401 } 402 403 .field-input:focus { 404 outline: none; 405 border-color: var(--primary); 406 } 407 408 .modal-overlay { 409 position: fixed; 410 top: 0; 411 left: 0; 412 right: 0; 413 bottom: 0; 414 background: rgba(0, 0, 0, 0.5); 415 display: flex; 416 align-items: center; 417 justify-content: center; 418 z-index: 2000; 419 } 420 421 .modal { 422 background: var(--background); 423 border: 2px solid var(--accent); 424 border-radius: 12px; 425 padding: 2rem; 426 max-width: 400px; 427 width: 90%; 428 } 429 430 .modal h3 { 431 margin-top: 0; 432 color: var(--accent); 433 } 434 435 .modal-actions { 436 display: flex; 437 gap: 0.5rem; 438 margin-top: 1.5rem; 439 } 440 441 .btn-neutral { 442 background: transparent; 443 color: var(--text); 444 border-color: var(--secondary); 445 } 446 447 .btn-neutral:hover { 448 border-color: var(--primary); 449 color: var(--primary); 450 } 451 452 .error { 453 color: var(--accent); 454 } 455 456 .error-banner { 457 background: #fecaca; 458 border: 2px solid rgba(220, 38, 38, 0.8); 459 border-radius: 6px; 460 padding: 1rem; 461 margin-bottom: 1.5rem; 462 color: #dc2626; 463 font-weight: 500; 464 } 465 466 .loading { 467 text-align: center; 468 color: var(--text); 469 padding: 2rem; 470 } 471 472 .setting-row { 473 display: flex; 474 align-items: center; 475 justify-content: space-between; 476 padding: 1rem; 477 border: 1px solid var(--secondary); 478 border-radius: 6px; 479 gap: 1rem; 480 } 481 482 .setting-info { 483 flex: 1; 484 } 485 486 .toggle { 487 position: relative; 488 display: inline-block; 489 width: 48px; 490 height: 24px; 491 } 492 493 .toggle input { 494 opacity: 0; 495 width: 0; 496 height: 0; 497 } 498 499 .toggle-slider { 500 position: absolute; 501 cursor: pointer; 502 top: 0; 503 left: 0; 504 right: 0; 505 bottom: 0; 506 background-color: var(--secondary); 507 transition: 0.2s; 508 border-radius: 24px; 509 } 510 511 .toggle-slider:before { 512 position: absolute; 513 content: ""; 514 height: 18px; 515 width: 18px; 516 left: 3px; 517 bottom: 3px; 518 background-color: white; 519 transition: 0.2s; 520 border-radius: 50%; 521 } 522 523 .toggle input:checked + .toggle-slider { 524 background-color: var(--primary); 525 } 526 527 .toggle input:checked + .toggle-slider:before { 528 transform: translateX(24px); 529 } 530 531 @media (max-width: 768px) { 532 .settings-container { 533 padding: 1rem; 534 } 535 536 .tabs { 537 overflow-x: auto; 538 } 539 540 .tab { 541 white-space: nowrap; 542 } 543 544 .content-inner { 545 padding: 0; 546 } 547 } 548 `; 549 550 override async connectedCallback() { 551 super.connectedCallback(); 552 this.passkeySupported = isPasskeySupported(); 553 554 // Check for tab query parameter 555 const params = new URLSearchParams(window.location.search); 556 const tab = params.get("tab"); 557 if (tab && this.isValidTab(tab)) { 558 this.currentPage = tab as SettingsPage; 559 } 560 561 await this.loadUser(); 562 await this.loadSessions(); 563 await this.loadSubscription(); 564 if (this.passkeySupported) { 565 await this.loadPasskeys(); 566 } 567 } 568 569 private isValidTab(tab: string): boolean { 570 return [ 571 "account", 572 "sessions", 573 "passkeys", 574 "billing", 575 "notifications", 576 "danger", 577 ].includes(tab); 578 } 579 580 private setTab(tab: SettingsPage) { 581 this.currentPage = tab; 582 this.error = ""; // Clear errors when switching tabs 583 // Update URL without reloading page 584 const url = new URL(window.location.href); 585 url.searchParams.set("tab", tab); 586 window.history.pushState({}, "", url); 587 } 588 589 async loadUser() { 590 try { 591 const response = await fetch("/api/auth/me"); 592 593 if (!response.ok) { 594 window.location.href = "/"; 595 return; 596 } 597 598 const data = await response.json(); 599 this.user = data; 600 this.emailNotificationsEnabled = data.email_notifications_enabled ?? true; 601 } finally { 602 this.loading = false; 603 } 604 } 605 606 async loadSessions() { 607 try { 608 const response = await fetch("/api/sessions"); 609 610 if (response.ok) { 611 const data = await response.json(); 612 this.sessions = data.sessions; 613 } 614 } finally { 615 this.loadingSessions = false; 616 } 617 } 618 619 async loadPasskeys() { 620 try { 621 const response = await fetch("/api/passkeys"); 622 623 if (response.ok) { 624 const data = await response.json(); 625 this.passkeys = data.passkeys; 626 } 627 } finally { 628 this.loadingPasskeys = false; 629 } 630 } 631 632 async loadSubscription() { 633 try { 634 const response = await fetch("/api/billing/subscription"); 635 636 if (response.ok) { 637 const data = await response.json(); 638 this.subscription = data.subscription; 639 } 640 } finally { 641 this.loadingSubscription = false; 642 } 643 } 644 645 async handleAddPasskey() { 646 this.addingPasskey = true; 647 this.error = ""; 648 649 try { 650 const name = prompt("Name this passkey (optional):"); 651 if (name === null) { 652 // User cancelled 653 return; 654 } 655 656 const result = await registerPasskey(name || undefined); 657 658 if (!result.success) { 659 this.error = result.error || "Failed to register passkey"; 660 return; 661 } 662 663 // Reload passkeys 664 await this.loadPasskeys(); 665 } finally { 666 this.addingPasskey = false; 667 } 668 } 669 670 async handleDeletePasskey(passkeyId: string) { 671 if (!confirm("Are you sure you want to delete this passkey?")) { 672 return; 673 } 674 675 this.error = ""; 676 try { 677 const response = await fetch(`/api/passkeys/${passkeyId}`, { 678 method: "DELETE", 679 }); 680 681 if (!response.ok) { 682 const data = await response.json(); 683 this.error = data.error || "Failed to delete passkey"; 684 return; 685 } 686 687 // Reload passkeys 688 await this.loadPasskeys(); 689 } catch (err) { 690 this.error = 691 err instanceof Error ? err.message : "Failed to delete passkey"; 692 } 693 } 694 695 async handleLogout() { 696 this.error = ""; 697 try { 698 const response = await fetch("/api/auth/logout", { method: "POST" }); 699 700 if (!response.ok) { 701 const data = await response.json(); 702 this.error = data.error || "Failed to logout"; 703 return; 704 } 705 706 window.location.href = "/"; 707 } catch (err) { 708 this.error = err instanceof Error ? err.message : "Failed to logout"; 709 } 710 } 711 712 async handleDeleteAccount() { 713 this.deletingAccount = true; 714 this.error = ""; 715 document.body.style.cursor = "wait"; 716 717 try { 718 const response = await fetch("/api/user", { 719 method: "DELETE", 720 }); 721 722 if (!response.ok) { 723 const data = await response.json(); 724 this.error = data.error || "Failed to delete account"; 725 return; 726 } 727 728 window.location.href = "/"; 729 } catch { 730 this.error = "Failed to delete account"; 731 } finally { 732 this.deletingAccount = false; 733 this.showDeleteConfirm = false; 734 document.body.style.cursor = ""; 735 } 736 } 737 738 async handleUpdateEmail() { 739 this.error = ""; 740 this.emailChangeMessage = ""; 741 if (!this.newEmail) { 742 this.error = "Email required"; 743 return; 744 } 745 746 this.updatingEmail = true; 747 try { 748 const response = await fetch("/api/user/email", { 749 method: "PUT", 750 headers: { "Content-Type": "application/json" }, 751 body: JSON.stringify({ email: this.newEmail }), 752 }); 753 754 const data = await response.json(); 755 756 if (!response.ok) { 757 this.error = data.error || "Failed to update email"; 758 return; 759 } 760 761 // Show success message with pending email 762 this.emailChangeMessage = data.message || "Verification email sent"; 763 this.pendingEmailChange = data.pendingEmail || this.newEmail; 764 this.editingEmail = false; 765 this.newEmail = ""; 766 } catch { 767 this.error = "Failed to update email"; 768 } finally { 769 this.updatingEmail = false; 770 } 771 } 772 773 async handleUpdatePassword() { 774 this.error = ""; 775 if (!this.newPassword) { 776 this.error = "Password required"; 777 return; 778 } 779 780 if (this.newPassword.length < 8) { 781 this.error = "Password must be at least 8 characters"; 782 return; 783 } 784 785 try { 786 // Hash password client-side before sending 787 const passwordHash = await hashPasswordClient( 788 this.newPassword, 789 this.user?.email ?? "", 790 ); 791 792 const response = await fetch("/api/user/password", { 793 method: "PUT", 794 headers: { "Content-Type": "application/json" }, 795 body: JSON.stringify({ password: passwordHash }), 796 }); 797 798 if (!response.ok) { 799 const data = await response.json(); 800 this.error = data.error || "Failed to update password"; 801 return; 802 } 803 804 this.editingPassword = false; 805 this.newPassword = ""; 806 } catch { 807 this.error = "Failed to update password"; 808 } 809 } 810 811 async handleUpdateName() { 812 this.error = ""; 813 if (!this.newName) { 814 this.error = "Name required"; 815 return; 816 } 817 818 try { 819 const response = await fetch("/api/user/name", { 820 method: "PUT", 821 headers: { "Content-Type": "application/json" }, 822 body: JSON.stringify({ name: this.newName }), 823 }); 824 825 if (!response.ok) { 826 const data = await response.json(); 827 this.error = data.error || "Failed to update name"; 828 return; 829 } 830 831 // Reload user data 832 await this.loadUser(); 833 this.newName = ""; 834 } catch { 835 this.error = "Failed to update name"; 836 } 837 } 838 839 async handleUpdateAvatar() { 840 this.error = ""; 841 if (!this.newAvatar) { 842 this.error = "Avatar required"; 843 return; 844 } 845 846 try { 847 const response = await fetch("/api/user/avatar", { 848 method: "PUT", 849 headers: { "Content-Type": "application/json" }, 850 body: JSON.stringify({ avatar: this.newAvatar }), 851 }); 852 853 if (!response.ok) { 854 const data = await response.json(); 855 this.error = data.error || "Failed to update avatar"; 856 return; 857 } 858 859 // Reload user data 860 await this.loadUser(); 861 this.newAvatar = ""; 862 } catch { 863 this.error = "Failed to update avatar"; 864 } 865 } 866 867 async handleCreateCheckout() { 868 this.loading = true; 869 this.error = ""; 870 871 try { 872 const response = await fetch("/api/billing/checkout", { 873 method: "POST", 874 headers: { "Content-Type": "application/json" }, 875 }); 876 877 if (!response.ok) { 878 const data = await response.json(); 879 this.error = data.error || "Failed to create checkout session"; 880 return; 881 } 882 883 const { url } = await response.json(); 884 window.open(url, "_blank"); 885 } catch { 886 this.error = "Failed to create checkout session"; 887 } finally { 888 this.loading = false; 889 } 890 } 891 892 async handleOpenPortal() { 893 this.loading = true; 894 this.error = ""; 895 896 try { 897 const response = await fetch("/api/billing/portal", { 898 method: "POST", 899 headers: { "Content-Type": "application/json" }, 900 }); 901 902 if (!response.ok) { 903 const data = await response.json(); 904 this.error = data.error || "Failed to open customer portal"; 905 return; 906 } 907 908 const { url } = await response.json(); 909 window.open(url, "_blank"); 910 } catch { 911 this.error = "Failed to open customer portal"; 912 } finally { 913 this.loading = false; 914 } 915 } 916 917 generateRandomAvatar() { 918 // Generate a random string for the avatar 919 const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; 920 let result = ""; 921 for (let i = 0; i < 8; i++) { 922 result += chars.charAt(Math.floor(Math.random() * chars.length)); 923 } 924 this.newAvatar = result; 925 this.handleUpdateAvatar(); 926 } 927 928 formatDate(timestamp: number, future = false): string { 929 const date = new Date(timestamp * 1000); 930 const now = new Date(); 931 const diff = Math.abs(now.getTime() - date.getTime()); 932 933 // For future dates (like expiration) 934 if (future || date > now) { 935 // Less than a day 936 if (diff < 24 * 60 * 60 * 1000) { 937 const hours = Math.floor(diff / (60 * 60 * 1000)); 938 return `in ${hours} hour${hours === 1 ? "" : "s"}`; 939 } 940 941 // Less than a week 942 if (diff < 7 * 24 * 60 * 60 * 1000) { 943 const days = Math.floor(diff / (24 * 60 * 60 * 1000)); 944 return `in ${days} day${days === 1 ? "" : "s"}`; 945 } 946 947 // Show full date 948 return date.toLocaleDateString(undefined, { 949 month: "short", 950 day: "numeric", 951 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 952 }); 953 } 954 955 // For past dates 956 // Less than a minute 957 if (diff < 60 * 1000) { 958 return "Just now"; 959 } 960 961 // Less than an hour 962 if (diff < 60 * 60 * 1000) { 963 const minutes = Math.floor(diff / (60 * 1000)); 964 return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; 965 } 966 967 // Less than a day 968 if (diff < 24 * 60 * 60 * 1000) { 969 const hours = Math.floor(diff / (60 * 60 * 1000)); 970 return `${hours} hour${hours === 1 ? "" : "s"} ago`; 971 } 972 973 // Less than a week 974 if (diff < 7 * 24 * 60 * 60 * 1000) { 975 const days = Math.floor(diff / (24 * 60 * 60 * 1000)); 976 return `${days} day${days === 1 ? "" : "s"} ago`; 977 } 978 979 // Show full date 980 return date.toLocaleDateString(undefined, { 981 month: "short", 982 day: "numeric", 983 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 984 }); 985 } 986 987 async handleKillSession(sessionId: string) { 988 this.error = ""; 989 try { 990 const response = await fetch(`/api/sessions`, { 991 method: "DELETE", 992 headers: { "Content-Type": "application/json" }, 993 body: JSON.stringify({ sessionId }), 994 }); 995 996 if (!response.ok) { 997 const data = await response.json(); 998 this.error = data.error || "Failed to kill session"; 999 return; 1000 } 1001 1002 // Reload sessions 1003 await this.loadSessions(); 1004 } catch { 1005 this.error = "Failed to kill session"; 1006 } 1007 } 1008 1009 parseUserAgent(userAgent: string | null): string { 1010 if (!userAgent) return "Unknown"; 1011 1012 const parser = new UAParser(userAgent); 1013 const result = parser.getResult(); 1014 1015 const browser = result.browser.name 1016 ? `${result.browser.name}${result.browser.version ? ` ${result.browser.version}` : ""}` 1017 : ""; 1018 const os = result.os.name 1019 ? `${result.os.name}${result.os.version ? ` ${result.os.version}` : ""}` 1020 : ""; 1021 1022 if (browser && os) { 1023 return `${browser} on ${os}`; 1024 } 1025 if (browser) return browser; 1026 if (os) return os; 1027 1028 return userAgent; 1029 } 1030 1031 renderAccountPage() { 1032 if (!this.user) return html``; 1033 1034 const createdDate = new Date( 1035 this.user.created_at * 1000, 1036 ).toLocaleDateString(); 1037 1038 return html` 1039 <div class="content-inner"> 1040 ${ 1041 this.error 1042 ? html` 1043 <div class="error-banner"> 1044 ${this.error} 1045 </div> 1046 ` 1047 : "" 1048 } 1049 <div class="section"> 1050 <h2 class="section-title">Profile Information</h2> 1051 1052 <div class="field-group"> 1053 <div class="profile-row"> 1054 <div class="avatar-container"> 1055 <img 1056 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 1057 alt="Avatar" 1058 style="border-radius: 50%; width: 48px; height: 48px; border: 2px solid var(--secondary); cursor: pointer;" 1059 @click=${this.generateRandomAvatar} 1060 /> 1061 <div class="avatar-overlay" @click=${this.generateRandomAvatar}> 1062 <span class="reload-symbol">↻</span> 1063 </div> 1064 </div> 1065 <input 1066 type="text" 1067 class="field-input" 1068 style="flex: 1;" 1069 .value=${this.user.name ?? ""} 1070 @input=${(e: Event) => { 1071 this.newName = (e.target as HTMLInputElement).value; 1072 }} 1073 @blur=${() => { 1074 if (this.newName && this.newName !== (this.user?.name ?? "")) { 1075 this.handleUpdateName(); 1076 } 1077 }} 1078 placeholder="Your name" 1079 /> 1080 </div> 1081 </div> 1082 1083 <div class="field-group"> 1084 <label class="field-label">Email</label> 1085 ${ 1086 this.emailChangeMessage 1087 ? html` 1088 <div class="success-message" style="margin-bottom: 1rem;"> 1089 ${this.emailChangeMessage} 1090 ${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ""} 1091 </div> 1092 <div class="field-row"> 1093 <div class="field-value">${this.user.email}</div> 1094 </div> 1095 ` 1096 : this.editingEmail 1097 ? html` 1098 <div style="display: flex; gap: 0.5rem; align-items: center;"> 1099 <input 1100 type="email" 1101 class="field-input" 1102 .value=${this.newEmail} 1103 @input=${(e: Event) => { 1104 this.newEmail = (e.target as HTMLInputElement).value; 1105 }} 1106 placeholder=${this.user.email} 1107 /> 1108 <button 1109 class="btn btn-affirmative btn-small" 1110 @click=${this.handleUpdateEmail} 1111 ?disabled=${this.updatingEmail} 1112 > 1113 ${this.updatingEmail ? html`<span class="spinner"></span>` : "Save"} 1114 </button> 1115 <button 1116 class="btn btn-neutral btn-small" 1117 @click=${() => { 1118 this.editingEmail = false; 1119 this.newEmail = ""; 1120 }} 1121 > 1122 Cancel 1123 </button> 1124 </div> 1125 ` 1126 : html` 1127 <div class="field-row"> 1128 <div class="field-value">${this.user.email}</div> 1129 <button 1130 class="change-link" 1131 @click=${() => { 1132 this.editingEmail = true; 1133 this.newEmail = this.user?.email ?? ""; 1134 this.emailChangeMessage = ""; 1135 }} 1136 > 1137 Change 1138 </button> 1139 </div> 1140 ` 1141 } 1142 </div> 1143 1144 <div class="field-group"> 1145 <label class="field-label">Password</label> 1146 ${ 1147 this.editingPassword 1148 ? html` 1149 <div style="display: flex; gap: 0.5rem; align-items: center;"> 1150 <input 1151 type="password" 1152 class="field-input" 1153 .value=${this.newPassword} 1154 @input=${(e: Event) => { 1155 this.newPassword = (e.target as HTMLInputElement).value; 1156 }} 1157 placeholder="New password" 1158 /> 1159 <button 1160 class="btn btn-affirmative btn-small" 1161 @click=${this.handleUpdatePassword} 1162 > 1163 Save 1164 </button> 1165 <button 1166 class="btn btn-neutral btn-small" 1167 @click=${() => { 1168 this.editingPassword = false; 1169 this.newPassword = ""; 1170 }} 1171 > 1172 Cancel 1173 </button> 1174 </div> 1175 ` 1176 : html` 1177 <div class="field-row"> 1178 <div class="field-value">••••••••</div> 1179 <button 1180 class="change-link" 1181 @click=${() => { 1182 this.editingPassword = true; 1183 }} 1184 > 1185 Change 1186 </button> 1187 </div> 1188 ` 1189 } 1190 </div> 1191 1192 ${ 1193 this.passkeySupported 1194 ? html` 1195 <div class="field-group"> 1196 <label class="field-label">Passkeys</label> 1197 <p class="field-description"> 1198 Passkeys provide a more secure and convenient way to sign in without passwords. 1199 They use biometric authentication or your device's security features. 1200 </p> 1201 ${ 1202 this.loadingPasskeys 1203 ? html`<div class="field-value">Loading passkeys...</div>` 1204 : this.passkeys.length === 0 1205 ? html`<div class="field-value" style="color: var(--paynes-gray);">No passkeys registered yet</div>` 1206 : html` 1207 <div style="display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem;"> 1208 ${this.passkeys.map( 1209 (passkey) => html` 1210 <div class="session-card"> 1211 <div class="session-details"> 1212 <div class="session-row"> 1213 <span class="session-label">Name</span> 1214 <span class="session-value">${passkey.name || "Unnamed passkey"}</span> 1215 </div> 1216 <div class="session-row"> 1217 <span class="session-label">Created</span> 1218 <span class="session-value">${new Date(passkey.created_at * 1000).toLocaleDateString()}</span> 1219 </div> 1220 ${ 1221 passkey.last_used_at 1222 ? html` 1223 <div class="session-row"> 1224 <span class="session-label">Last used</span> 1225 <span class="session-value">${new Date(passkey.last_used_at * 1000).toLocaleDateString()}</span> 1226 </div> 1227 ` 1228 : "" 1229 } 1230 </div> 1231 <button 1232 class="btn btn-rejection btn-small" 1233 @click=${() => this.handleDeletePasskey(passkey.id)} 1234 style="margin-top: 0.75rem;" 1235 > 1236 Delete 1237 </button> 1238 </div> 1239 `, 1240 )} 1241 </div> 1242 ` 1243 } 1244 <button 1245 class="btn btn-affirmative" 1246 style="margin-top: 1rem;" 1247 @click=${this.handleAddPasskey} 1248 ?disabled=${this.addingPasskey} 1249 > 1250 ${this.addingPasskey ? "Adding..." : "Add Passkey"} 1251 </button> 1252 </div> 1253 ` 1254 : "" 1255 } 1256 1257 <div class="field-group"> 1258 <label class="field-label">Member Since</label> 1259 <div class="field-value">${createdDate}</div> 1260 </div> 1261 </div> 1262 </div> 1263 `; 1264 } 1265 1266 renderSessionsPage() { 1267 return html` 1268 <div class="content-inner"> 1269 ${ 1270 this.error 1271 ? html` 1272 <div class="error-banner"> 1273 ${this.error} 1274 </div> 1275 ` 1276 : "" 1277 } 1278 <div class="section"> 1279 <h2 class="section-title">Active Sessions</h2> 1280 ${ 1281 this.loadingSessions 1282 ? html`<div class="loading">Loading sessions...</div>` 1283 : this.sessions.length === 0 1284 ? html`<p>No active sessions</p>` 1285 : html` 1286 <div class="session-list"> 1287 ${this.sessions.map( 1288 (session) => html` 1289 <div class="session-card ${session.is_current ? "current" : ""}"> 1290 <div class="session-header"> 1291 <span class="session-title">Session</span> 1292 ${session.is_current ? html`<span class="current-badge">Current</span>` : ""} 1293 </div> 1294 <div class="session-details"> 1295 <div class="session-row"> 1296 <span class="session-label">IP Address</span> 1297 <span class="session-value">${session.ip_address ?? "Unknown"}</span> 1298 </div> 1299 <div class="session-row"> 1300 <span class="session-label">Device</span> 1301 <span class="session-value">${this.parseUserAgent(session.user_agent)}</span> 1302 </div> 1303 <div class="session-row"> 1304 <span class="session-label">Created</span> 1305 <span class="session-value">${this.formatDate(session.created_at)}</span> 1306 </div> 1307 <div class="session-row"> 1308 <span class="session-label">Expires</span> 1309 <span class="session-value">${this.formatDate(session.expires_at, true)}</span> 1310 </div> 1311 </div> 1312 <div style="margin-top: 1rem;"> 1313 ${ 1314 session.is_current 1315 ? html` 1316 <button 1317 class="btn btn-rejection" 1318 @click=${this.handleLogout} 1319 > 1320 Logout 1321 </button> 1322 ` 1323 : html` 1324 <button 1325 class="btn btn-rejection" 1326 @click=${() => this.handleKillSession(session.id)} 1327 > 1328 Kill Session 1329 </button> 1330 ` 1331 } 1332 </div> 1333 </div> 1334 `, 1335 )} 1336 </div> 1337 ` 1338 } 1339 </div> 1340 </div> 1341 `; 1342 } 1343 1344 renderBillingPage() { 1345 if (this.loadingSubscription) { 1346 return html` 1347 <div class="content-inner"> 1348 <div class="section"> 1349 <div class="loading">Loading subscription...</div> 1350 </div> 1351 </div> 1352 `; 1353 } 1354 1355 const hasActiveSubscription = 1356 this.subscription && 1357 (this.subscription.status === "active" || 1358 this.subscription.status === "trialing"); 1359 1360 if (this.subscription && !hasActiveSubscription) { 1361 // Has a subscription but it's not active (canceled, expired, etc.) 1362 const statusColor = 1363 this.subscription.status === "canceled" 1364 ? "var(--accent)" 1365 : "var(--secondary)"; 1366 1367 return html` 1368 <div class="content-inner"> 1369 ${ 1370 this.error 1371 ? html` 1372 <div class="error-banner"> 1373 ${this.error} 1374 </div> 1375 ` 1376 : "" 1377 } 1378 <div class="section"> 1379 <h2 class="section-title">Subscription</h2> 1380 1381 <div class="field-group"> 1382 <label class="field-label">Status</label> 1383 <div style="display: flex; align-items: center; gap: 0.75rem;"> 1384 <span style=" 1385 display: inline-block; 1386 padding: 0.25rem 0.75rem; 1387 border-radius: 4px; 1388 background: ${statusColor}; 1389 color: var(--white); 1390 font-size: 0.875rem; 1391 font-weight: 600; 1392 text-transform: uppercase; 1393 "> 1394 ${this.subscription.status} 1395 </span> 1396 </div> 1397 </div> 1398 1399 ${ 1400 this.subscription.canceled_at 1401 ? html` 1402 <div class="field-group"> 1403 <label class="field-label">Canceled At</label> 1404 <div class="field-value" style="color: var(--accent);"> 1405 ${this.formatDate(this.subscription.canceled_at)} 1406 </div> 1407 </div> 1408 ` 1409 : "" 1410 } 1411 1412 <div class="field-group" style="margin-top: 2rem;"> 1413 <button 1414 class="btn btn-success" 1415 @click=${this.handleCreateCheckout} 1416 ?disabled=${this.loading} 1417 > 1418 ${this.loading ? "Loading..." : "Activate Your Subscription"} 1419 </button> 1420 <p class="field-description" style="margin-top: 0.75rem;"> 1421 Reactivate your subscription to unlock unlimited transcriptions. 1422 </p> 1423 </div> 1424 </div> 1425 </div> 1426 `; 1427 } 1428 1429 if (hasActiveSubscription) { 1430 return html` 1431 <div class="content-inner"> 1432 ${ 1433 this.error 1434 ? html` 1435 <div class="error-banner"> 1436 ${this.error} 1437 </div> 1438 ` 1439 : "" 1440 } 1441 <div class="section"> 1442 <h2 class="section-title">Subscription</h2> 1443 1444 <div class="field-group"> 1445 <label class="field-label">Status</label> 1446 <div style="display: flex; align-items: center; gap: 0.75rem;"> 1447 <span style=" 1448 display: inline-block; 1449 padding: 0.25rem 0.75rem; 1450 border-radius: 4px; 1451 background: var(--success); 1452 color: var(--white); 1453 font-size: 0.875rem; 1454 font-weight: 600; 1455 text-transform: uppercase; 1456 "> 1457 ${this.subscription.status} 1458 </span> 1459 ${ 1460 this.subscription.cancel_at_period_end 1461 ? html` 1462 <span style="color: var(--accent); font-size: 0.875rem;"> 1463 (Cancels at end of period) 1464 </span> 1465 ` 1466 : "" 1467 } 1468 </div> 1469 </div> 1470 1471 ${ 1472 this.subscription.current_period_start && 1473 this.subscription.current_period_end 1474 ? html` 1475 <div class="field-group"> 1476 <label class="field-label">Current Period</label> 1477 <div class="field-value"> 1478 ${this.formatDate(this.subscription.current_period_start)} - 1479 ${this.formatDate(this.subscription.current_period_end)} 1480 </div> 1481 </div> 1482 ` 1483 : "" 1484 } 1485 1486 <div class="field-group" style="margin-top: 2rem;"> 1487 <button 1488 class="btn btn-affirmative" 1489 @click=${this.handleOpenPortal} 1490 ?disabled=${this.loading} 1491 > 1492 ${this.loading ? "Loading..." : "Manage Subscription"} 1493 </button> 1494 <p class="field-description" style="margin-top: 0.75rem;"> 1495 Opens the customer portal where you can update payment methods, view invoices, and manage your subscription. 1496 </p> 1497 </div> 1498 </div> 1499 </div> 1500 `; 1501 } 1502 1503 return html` 1504 <div class="content-inner"> 1505 ${ 1506 this.error 1507 ? html` 1508 <div class="error-banner"> 1509 ${this.error} 1510 </div> 1511 ` 1512 : "" 1513 } 1514 <div class="section"> 1515 <h2 class="section-title">Billing & Subscription</h2> 1516 <p class="field-description" style="margin-bottom: 1.5rem;"> 1517 Activate your subscription to unlock unlimited transcriptions. Note: We currently offer a single subscription tier. 1518 </p> 1519 <button 1520 class="btn btn-success" 1521 @click=${this.handleCreateCheckout} 1522 ?disabled=${this.loading} 1523 > 1524 ${this.loading ? "Loading..." : "Activate Your Subscription"} 1525 </button> 1526 </div> 1527 </div> 1528 `; 1529 } 1530 1531 renderDangerPage() { 1532 return html` 1533 <div class="content-inner"> 1534 ${ 1535 this.error 1536 ? html` 1537 <div class="error-banner"> 1538 ${this.error} 1539 </div> 1540 ` 1541 : "" 1542 } 1543 <div class="section danger-section"> 1544 <h2 class="section-title">Delete Account</h2> 1545 <p class="danger-text"> 1546 Once you delete your account, there is no going back. This will 1547 permanently delete your account and all associated data. 1548 </p> 1549 <button 1550 class="btn btn-rejection" 1551 @click=${() => { 1552 this.showDeleteConfirm = true; 1553 }} 1554 > 1555 Delete Account 1556 </button> 1557 </div> 1558 </div> 1559 `; 1560 } 1561 1562 renderNotificationsPage() { 1563 return html` 1564 <div class="content-inner"> 1565 ${ 1566 this.error 1567 ? html` 1568 <div class="error-banner"> 1569 ${this.error} 1570 </div> 1571 ` 1572 : "" 1573 } 1574 <div class="section"> 1575 <h2 class="section-title">Email Notifications</h2> 1576 <p style="color: var(--text); margin-bottom: 1rem;"> 1577 Control which emails you receive from Thistle. 1578 </p> 1579 1580 <div class="setting-row"> 1581 <div class="setting-info"> 1582 <strong>Transcription Complete</strong> 1583 <p style="color: var(--paynes-gray); font-size: 0.875rem; margin: 0.25rem 0 0 0;"> 1584 Get notified when your transcription is ready 1585 </p> 1586 </div> 1587 <label class="toggle"> 1588 <input 1589 type="checkbox" 1590 .checked=${this.emailNotificationsEnabled} 1591 @change=${async (e: Event) => { 1592 const target = e.target as HTMLInputElement; 1593 this.emailNotificationsEnabled = target.checked; 1594 this.error = ""; 1595 1596 try { 1597 const response = await fetch("/api/user/notifications", { 1598 method: "PUT", 1599 headers: { "Content-Type": "application/json" }, 1600 body: JSON.stringify({ 1601 email_notifications_enabled: 1602 this.emailNotificationsEnabled, 1603 }), 1604 }); 1605 1606 if (!response.ok) { 1607 const data = await response.json(); 1608 throw new Error( 1609 data.error || "Failed to update notification settings", 1610 ); 1611 } 1612 } catch (err) { 1613 // Revert on error 1614 this.emailNotificationsEnabled = !target.checked; 1615 target.checked = !target.checked; 1616 this.error = 1617 err instanceof Error 1618 ? err.message 1619 : "Failed to update notification settings"; 1620 } 1621 }} 1622 /> 1623 <span class="toggle-slider"></span> 1624 </label> 1625 </div> 1626 </div> 1627 </div> 1628 `; 1629 } 1630 1631 override render() { 1632 if (this.loading) { 1633 return html`<div class="loading">Loading...</div>`; 1634 } 1635 1636 if (!this.user) { 1637 return html`<div class="error">No user data available</div>`; 1638 } 1639 1640 return html` 1641 <div class="settings-container"> 1642 <h1>Settings</h1> 1643 1644 <div class="tabs"> 1645 <button 1646 class="tab ${this.currentPage === "account" ? "active" : ""}" 1647 @click=${() => { 1648 this.setTab("account"); 1649 }} 1650 > 1651 Account 1652 </button> 1653 <button 1654 class="tab ${this.currentPage === "sessions" ? "active" : ""}" 1655 @click=${() => { 1656 this.setTab("sessions"); 1657 }} 1658 > 1659 Sessions 1660 </button> 1661 <button 1662 class="tab ${this.currentPage === "billing" ? "active" : ""}" 1663 @click=${() => { 1664 this.setTab("billing"); 1665 }} 1666 > 1667 Billing 1668 </button> 1669 <button 1670 class="tab ${this.currentPage === "notifications" ? "active" : ""}" 1671 @click=${() => { 1672 this.setTab("notifications"); 1673 }} 1674 > 1675 Notifications 1676 </button> 1677 <button 1678 class="tab ${this.currentPage === "danger" ? "active" : ""}" 1679 @click=${() => { 1680 this.setTab("danger"); 1681 }} 1682 > 1683 Danger Zone 1684 </button> 1685 </div> 1686 1687 ${this.currentPage === "account" ? this.renderAccountPage() : ""} 1688 ${this.currentPage === "sessions" ? this.renderSessionsPage() : ""} 1689 ${this.currentPage === "billing" ? this.renderBillingPage() : ""} 1690 ${this.currentPage === "notifications" ? this.renderNotificationsPage() : ""} 1691 ${this.currentPage === "danger" ? this.renderDangerPage() : ""} 1692 </div> 1693 1694 ${ 1695 this.showDeleteConfirm 1696 ? html` 1697 <div 1698 class="modal-overlay" 1699 @click=${() => { 1700 this.showDeleteConfirm = false; 1701 }} 1702 > 1703 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 1704 <h3>Delete Account</h3> 1705 <p> 1706 Are you absolutely sure? This action cannot be undone. All your data will be 1707 permanently deleted. 1708 </p> 1709 <div class="modal-actions"> 1710 <button 1711 class="btn btn-rejection" 1712 @click=${this.handleDeleteAccount} 1713 ?disabled=${this.deletingAccount} 1714 > 1715 ${this.deletingAccount ? "Deleting..." : "Yes, Delete My Account"} 1716 </button> 1717 <button 1718 class="btn btn-neutral" 1719 @click=${() => { 1720 this.showDeleteConfirm = false; 1721 }} 1722 ?disabled=${this.deletingAccount} 1723 > 1724 Cancel 1725 </button> 1726 </div> 1727 </div> 1728 </div> 1729 ` 1730 : "" 1731 } 1732 `; 1733 } 1734}