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