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