🪻 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/user", {
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}