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