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