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