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