🪻 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
30type SettingsPage = "account" | "sessions" | "passkeys" | "danger";
31
32@customElement("user-settings")
33export class UserSettings extends LitElement {
34 @state() user: User | null = null;
35 @state() sessions: Session[] = [];
36 @state() passkeys: Passkey[] = [];
37 @state() loading = true;
38 @state() loadingSessions = true;
39 @state() loadingPasskeys = true;
40 @state() error = "";
41 @state() showDeleteConfirm = false;
42 @state() currentPage: SettingsPage = "account";
43 @state() editingEmail = false;
44 @state() editingPassword = false;
45 @state() newEmail = "";
46 @state() newPassword = "";
47 @state() newName = "";
48 @state() newAvatar = "";
49 @state() passkeySupported = false;
50 @state() addingPasskey = false;
51
52 static override styles = css`
53 :host {
54 display: block;
55 }
56
57 .settings-container {
58 max-width: 80rem;
59 margin: 0 auto;
60 padding: 2rem;
61 }
62
63 h1 {
64 margin-bottom: 1rem;
65 color: var(--text);
66 }
67
68 .tabs {
69 display: flex;
70 gap: 1rem;
71 border-bottom: 2px solid var(--secondary);
72 margin-bottom: 2rem;
73 }
74
75 .tab {
76 padding: 0.75rem 1.5rem;
77 border: none;
78 background: transparent;
79 color: var(--text);
80 cursor: pointer;
81 font-size: 1rem;
82 font-weight: 500;
83 font-family: inherit;
84 border-bottom: 2px solid transparent;
85 margin-bottom: -2px;
86 transition: all 0.2s;
87 }
88
89 .tab:hover {
90 color: var(--primary);
91 }
92
93 .tab.active {
94 color: var(--primary);
95 border-bottom-color: var(--primary);
96 }
97
98 .tab-content {
99 display: none;
100 }
101
102 .tab-content.active {
103 display: block;
104 }
105
106 .content-inner {
107 max-width: 56rem;
108 }
109
110 .section {
111 background: var(--background);
112 border: 1px solid var(--secondary);
113 border-radius: 12px;
114 padding: 2rem;
115 margin-bottom: 2rem;
116 }
117
118 .section-title {
119 font-size: 1.25rem;
120 font-weight: 600;
121 color: var(--text);
122 margin: 0 0 1.5rem 0;
123 }
124
125 .field-group {
126 margin-bottom: 1.5rem;
127 }
128
129 .field-group:last-child {
130 margin-bottom: 0;
131 }
132
133 .field-row {
134 display: flex;
135 justify-content: space-between;
136 align-items: center;
137 gap: 1rem;
138 }
139
140 .field-label {
141 font-weight: 500;
142 color: var(--text);
143 font-size: 0.875rem;
144 margin-bottom: 0.5rem;
145 display: block;
146 }
147
148 .field-value {
149 font-size: 1rem;
150 color: var(--text);
151 opacity: 0.8;
152 }
153
154 .change-link {
155 background: none;
156 border: 1px solid var(--secondary);
157 color: var(--text);
158 font-size: 0.875rem;
159 font-weight: 500;
160 cursor: pointer;
161 padding: 0.25rem 0.75rem;
162 border-radius: 6px;
163 font-family: inherit;
164 transition: all 0.2s;
165 }
166
167 .change-link:hover {
168 border-color: var(--primary);
169 color: var(--primary);
170 }
171
172 .btn {
173 padding: 0.75rem 1.5rem;
174 border-radius: 6px;
175 font-size: 1rem;
176 font-weight: 500;
177 cursor: pointer;
178 transition: all 0.2s;
179 font-family: inherit;
180 border: 2px solid transparent;
181 }
182
183 .btn-rejection {
184 background: transparent;
185 color: var(--accent);
186 border-color: var(--accent);
187 }
188
189 .btn-rejection:hover {
190 background: var(--accent);
191 color: white;
192 }
193
194 .btn-small {
195 padding: 0.5rem 1rem;
196 font-size: 0.875rem;
197 }
198
199 .avatar-container:hover .avatar-overlay {
200 opacity: 1;
201 }
202
203 .avatar-overlay {
204 position: absolute;
205 top: 0;
206 left: 0;
207 width: 48px;
208 height: 48px;
209 background: rgba(0, 0, 0, 0.2);
210 border-radius: 50%;
211 border: 2px solid transparent;
212 display: flex;
213 align-items: center;
214 justify-content: center;
215 opacity: 0;
216 cursor: pointer;
217 }
218
219 .reload-symbol {
220 font-size: 18px;
221 color: white;
222 transform: rotate(79deg) translate(0px, -2px);
223 }
224
225 .profile-row {
226 display: flex;
227 align-items: center;
228 gap: 1rem;
229 }
230
231 .avatar-container {
232 position: relative;
233 }
234
235 .field-description {
236 font-size: 0.875rem;
237 color: var(--secondary);
238 margin: 0.5rem 0;
239 }
240
241 .danger-section {
242 border-color: var(--accent);
243 }
244
245 .danger-section .section-title {
246 color: var(--accent);
247 }
248
249 .danger-text {
250 color: var(--text);
251 opacity: 0.7;
252 margin-bottom: 1.5rem;
253 line-height: 1.5;
254 }
255
256 .session-list {
257 display: flex;
258 flex-direction: column;
259 gap: 1rem;
260 }
261
262 .session-card {
263 background: var(--background);
264 border: 1px solid var(--secondary);
265 border-radius: 8px;
266 padding: 1.25rem;
267 }
268
269 .session-card.current {
270 border-color: var(--accent);
271 background: rgba(239, 131, 84, 0.03);
272 }
273
274 .session-header {
275 display: flex;
276 align-items: center;
277 gap: 0.5rem;
278 margin-bottom: 1rem;
279 }
280
281 .session-title {
282 font-weight: 600;
283 color: var(--text);
284 }
285
286 .current-badge {
287 display: inline-block;
288 background: var(--accent);
289 color: white;
290 padding: 0.25rem 0.5rem;
291 border-radius: 4px;
292 font-size: 0.75rem;
293 font-weight: 600;
294 }
295
296 .session-details {
297 display: grid;
298 gap: 0.75rem;
299 }
300
301 .session-row {
302 display: grid;
303 grid-template-columns: 100px 1fr;
304 gap: 1rem;
305 }
306
307 .session-label {
308 font-weight: 500;
309 color: var(--text);
310 opacity: 0.6;
311 font-size: 0.875rem;
312 }
313
314 .session-value {
315 color: var(--text);
316 font-size: 0.875rem;
317 }
318
319 .user-agent {
320 font-family: monospace;
321 word-break: break-all;
322 }
323
324 .field-input {
325 padding: 0.5rem;
326 border: 1px solid var(--secondary);
327 border-radius: 6px;
328 font-family: inherit;
329 font-size: 1rem;
330 color: var(--text);
331 background: var(--background);
332 flex: 1;
333 }
334
335 .field-input:focus {
336 outline: none;
337 border-color: var(--primary);
338 }
339
340 .modal-overlay {
341 position: fixed;
342 top: 0;
343 left: 0;
344 right: 0;
345 bottom: 0;
346 background: rgba(0, 0, 0, 0.5);
347 display: flex;
348 align-items: center;
349 justify-content: center;
350 z-index: 2000;
351 }
352
353 .modal {
354 background: var(--background);
355 border: 2px solid var(--accent);
356 border-radius: 12px;
357 padding: 2rem;
358 max-width: 400px;
359 width: 90%;
360 }
361
362 .modal h3 {
363 margin-top: 0;
364 color: var(--accent);
365 }
366
367 .modal-actions {
368 display: flex;
369 gap: 0.5rem;
370 margin-top: 1.5rem;
371 }
372
373 .btn-neutral {
374 background: transparent;
375 color: var(--text);
376 border-color: var(--secondary);
377 }
378
379 .btn-neutral:hover {
380 border-color: var(--primary);
381 color: var(--primary);
382 }
383
384 .error {
385 color: var(--accent);
386 }
387
388 .loading {
389 text-align: center;
390 color: var(--text);
391 padding: 2rem;
392 }
393
394 @media (max-width: 768px) {
395 .settings-container {
396 padding: 1rem;
397 }
398
399 .tabs {
400 overflow-x: auto;
401 }
402
403 .tab {
404 white-space: nowrap;
405 }
406
407 .content-inner {
408 padding: 0;
409 }
410 }
411 `;
412
413 override async connectedCallback() {
414 super.connectedCallback();
415 this.passkeySupported = isPasskeySupported();
416 await this.loadUser();
417 await this.loadSessions();
418 if (this.passkeySupported) {
419 await this.loadPasskeys();
420 }
421 }
422
423 async loadUser() {
424 try {
425 const response = await fetch("/api/auth/me");
426
427 if (!response.ok) {
428 window.location.href = "/";
429 return;
430 }
431
432 this.user = await response.json();
433 } finally {
434 this.loading = false;
435 }
436 }
437
438 async loadSessions() {
439 try {
440 const response = await fetch("/api/sessions");
441
442 if (response.ok) {
443 const data = await response.json();
444 this.sessions = data.sessions;
445 }
446 } finally {
447 this.loadingSessions = false;
448 }
449 }
450
451 async loadPasskeys() {
452 try {
453 const response = await fetch("/api/passkeys");
454
455 if (response.ok) {
456 const data = await response.json();
457 this.passkeys = data.passkeys;
458 }
459 } finally {
460 this.loadingPasskeys = false;
461 }
462 }
463
464 async handleAddPasskey() {
465 this.addingPasskey = true;
466 this.error = "";
467
468 try {
469 const name = prompt("Name this passkey (optional):");
470 if (name === null) {
471 // User cancelled
472 return;
473 }
474
475 const result = await registerPasskey(name || undefined);
476
477 if (!result.success) {
478 this.error = result.error || "Failed to register passkey";
479 return;
480 }
481
482 // Reload passkeys
483 await this.loadPasskeys();
484 } finally {
485 this.addingPasskey = false;
486 }
487 }
488
489 async handleDeletePasskey(passkeyId: string) {
490 if (!confirm("Are you sure you want to delete this passkey?")) {
491 return;
492 }
493
494 try {
495 const response = await fetch(`/api/passkeys/${passkeyId}`, {
496 method: "DELETE",
497 });
498
499 if (!response.ok) {
500 const error = await response.json();
501 this.error = error.error || "Failed to delete passkey";
502 return;
503 }
504
505 // Reload passkeys
506 await this.loadPasskeys();
507 } catch {
508 this.error = "Failed to delete passkey";
509 }
510 }
511
512 async handleLogout() {
513 try {
514 await fetch("/api/auth/logout", { method: "POST" });
515 window.location.href = "/";
516 } catch {
517 this.error = "Failed to logout";
518 }
519 }
520
521 async handleDeleteAccount() {
522 try {
523 const response = await fetch("/api/auth/delete-account", {
524 method: "DELETE",
525 });
526
527 if (!response.ok) {
528 this.error = "Failed to delete account";
529 return;
530 }
531
532 window.location.href = "/";
533 } catch {
534 this.error = "Failed to delete account";
535 } finally {
536 this.showDeleteConfirm = false;
537 }
538 }
539
540 async handleUpdateEmail() {
541 if (!this.newEmail) {
542 this.error = "Email required";
543 return;
544 }
545
546 try {
547 const response = await fetch("/api/user/email", {
548 method: "PUT",
549 headers: { "Content-Type": "application/json" },
550 body: JSON.stringify({ email: this.newEmail }),
551 });
552
553 if (!response.ok) {
554 const data = await response.json();
555 this.error = data.error || "Failed to update email";
556 return;
557 }
558
559 // Reload user data
560 await this.loadUser();
561 this.editingEmail = false;
562 this.newEmail = "";
563 } catch {
564 this.error = "Failed to update email";
565 }
566 }
567
568 async handleUpdatePassword() {
569 if (!this.newPassword) {
570 this.error = "Password required";
571 return;
572 }
573
574 if (this.newPassword.length < 8) {
575 this.error = "Password must be at least 8 characters";
576 return;
577 }
578
579 try {
580 // Hash password client-side before sending
581 const passwordHash = await hashPasswordClient(
582 this.newPassword,
583 this.user?.email ?? "",
584 );
585
586 const response = await fetch("/api/user/password", {
587 method: "PUT",
588 headers: { "Content-Type": "application/json" },
589 body: JSON.stringify({ password: passwordHash }),
590 });
591
592 if (!response.ok) {
593 const data = await response.json();
594 this.error = data.error || "Failed to update password";
595 return;
596 }
597
598 this.editingPassword = false;
599 this.newPassword = "";
600 } catch {
601 this.error = "Failed to update password";
602 }
603 }
604
605 async handleUpdateName() {
606 if (!this.newName) {
607 this.error = "Name required";
608 return;
609 }
610
611 try {
612 const response = await fetch("/api/user/name", {
613 method: "PUT",
614 headers: { "Content-Type": "application/json" },
615 body: JSON.stringify({ name: this.newName }),
616 });
617
618 if (!response.ok) {
619 const data = await response.json();
620 this.error = data.error || "Failed to update name";
621 return;
622 }
623
624 // Reload user data
625 await this.loadUser();
626 this.newName = "";
627 } catch {
628 this.error = "Failed to update name";
629 }
630 }
631
632 async handleUpdateAvatar() {
633 if (!this.newAvatar) {
634 this.error = "Avatar required";
635 return;
636 }
637
638 try {
639 const response = await fetch("/api/user/avatar", {
640 method: "PUT",
641 headers: { "Content-Type": "application/json" },
642 body: JSON.stringify({ avatar: this.newAvatar }),
643 });
644
645 if (!response.ok) {
646 const data = await response.json();
647 this.error = data.error || "Failed to update avatar";
648 return;
649 }
650
651 // Reload user data
652 await this.loadUser();
653 this.newAvatar = "";
654 } catch {
655 this.error = "Failed to update avatar";
656 }
657 }
658
659 generateRandomAvatar() {
660 // Generate a random string for the avatar
661 const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
662 let result = "";
663 for (let i = 0; i < 8; i++) {
664 result += chars.charAt(Math.floor(Math.random() * chars.length));
665 }
666 this.newAvatar = result;
667 this.handleUpdateAvatar();
668 }
669
670 formatDate(timestamp: number, future = false): string {
671 const date = new Date(timestamp * 1000);
672 const now = new Date();
673 const diff = Math.abs(now.getTime() - date.getTime());
674
675 // For future dates (like expiration)
676 if (future || date > now) {
677 // Less than a day
678 if (diff < 24 * 60 * 60 * 1000) {
679 const hours = Math.floor(diff / (60 * 60 * 1000));
680 return `in ${hours} hour${hours === 1 ? "" : "s"}`;
681 }
682
683 // Less than a week
684 if (diff < 7 * 24 * 60 * 60 * 1000) {
685 const days = Math.floor(diff / (24 * 60 * 60 * 1000));
686 return `in ${days} day${days === 1 ? "" : "s"}`;
687 }
688
689 // Show full date
690 return date.toLocaleDateString(undefined, {
691 month: "short",
692 day: "numeric",
693 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
694 });
695 }
696
697 // For past dates
698 // Less than a minute
699 if (diff < 60 * 1000) {
700 return "Just now";
701 }
702
703 // Less than an hour
704 if (diff < 60 * 60 * 1000) {
705 const minutes = Math.floor(diff / (60 * 1000));
706 return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
707 }
708
709 // Less than a day
710 if (diff < 24 * 60 * 60 * 1000) {
711 const hours = Math.floor(diff / (60 * 60 * 1000));
712 return `${hours} hour${hours === 1 ? "" : "s"} ago`;
713 }
714
715 // Less than a week
716 if (diff < 7 * 24 * 60 * 60 * 1000) {
717 const days = Math.floor(diff / (24 * 60 * 60 * 1000));
718 return `${days} day${days === 1 ? "" : "s"} ago`;
719 }
720
721 // Show full date
722 return date.toLocaleDateString(undefined, {
723 month: "short",
724 day: "numeric",
725 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
726 });
727 }
728
729 async handleKillSession(sessionId: string) {
730 try {
731 const response = await fetch(`/api/sessions`, {
732 method: "DELETE",
733 headers: { "Content-Type": "application/json" },
734 body: JSON.stringify({ sessionId }),
735 });
736
737 if (!response.ok) {
738 this.error = "Failed to kill session";
739 return;
740 }
741
742 // Reload sessions
743 await this.loadSessions();
744 } catch {
745 this.error = "Failed to kill session";
746 }
747 }
748
749 parseUserAgent(userAgent: string | null): string {
750 if (!userAgent) return "Unknown";
751
752 const parser = new UAParser(userAgent);
753 const result = parser.getResult();
754
755 const browser = result.browser.name
756 ? `${result.browser.name}${result.browser.version ? ` ${result.browser.version}` : ""}`
757 : "";
758 const os = result.os.name
759 ? `${result.os.name}${result.os.version ? ` ${result.os.version}` : ""}`
760 : "";
761
762 if (browser && os) {
763 return `${browser} on ${os}`;
764 }
765 if (browser) return browser;
766 if (os) return os;
767
768 return userAgent;
769 }
770
771 renderAccountPage() {
772 if (!this.user) return html``;
773
774 const createdDate = new Date(
775 this.user.created_at * 1000,
776 ).toLocaleDateString();
777
778 return html`
779 <div class="content-inner">
780 <div class="section">
781 <h2 class="section-title">Profile Information</h2>
782
783 <div class="field-group">
784 <div class="profile-row">
785 <div class="avatar-container">
786 <img
787 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
788 alt="Avatar"
789 style="border-radius: 50%; width: 48px; height: 48px; border: 2px solid var(--secondary); cursor: pointer;"
790 @click=${this.generateRandomAvatar}
791 />
792 <div class="avatar-overlay" @click=${this.generateRandomAvatar}>
793 <span class="reload-symbol">↻</span>
794 </div>
795 </div>
796 <input
797 type="text"
798 class="field-input"
799 style="flex: 1;"
800 .value=${this.user.name ?? ""}
801 @input=${(e: Event) => {
802 this.newName = (e.target as HTMLInputElement).value;
803 }}
804 @blur=${() => {
805 if (this.newName && this.newName !== (this.user?.name ?? "")) {
806 this.handleUpdateName();
807 }
808 }}
809 placeholder="Your name"
810 />
811 </div>
812 </div>
813
814 <div class="field-group">
815 <label class="field-label">Email</label>
816 ${
817 this.editingEmail
818 ? html`
819 <div style="display: flex; gap: 0.5rem; align-items: center;">
820 <input
821 type="email"
822 class="field-input"
823 .value=${this.newEmail}
824 @input=${(e: Event) => {
825 this.newEmail = (e.target as HTMLInputElement).value;
826 }}
827 placeholder=${this.user.email}
828 />
829 <button
830 class="btn btn-affirmative btn-small"
831 @click=${this.handleUpdateEmail}
832 >
833 Save
834 </button>
835 <button
836 class="btn btn-neutral btn-small"
837 @click=${() => {
838 this.editingEmail = false;
839 this.newEmail = "";
840 }}
841 >
842 Cancel
843 </button>
844 </div>
845 `
846 : html`
847 <div class="field-row">
848 <div class="field-value">${this.user.email}</div>
849 <button
850 class="change-link"
851 @click=${() => {
852 this.editingEmail = true;
853 this.newEmail = this.user?.email ?? "";
854 }}
855 >
856 Change
857 </button>
858 </div>
859 `
860 }
861 </div>
862
863 <div class="field-group">
864 <label class="field-label">Password</label>
865 ${
866 this.editingPassword
867 ? html`
868 <div style="display: flex; gap: 0.5rem; align-items: center;">
869 <input
870 type="password"
871 class="field-input"
872 .value=${this.newPassword}
873 @input=${(e: Event) => {
874 this.newPassword = (e.target as HTMLInputElement).value;
875 }}
876 placeholder="New password"
877 />
878 <button
879 class="btn btn-affirmative btn-small"
880 @click=${this.handleUpdatePassword}
881 >
882 Save
883 </button>
884 <button
885 class="btn btn-neutral btn-small"
886 @click=${() => {
887 this.editingPassword = false;
888 this.newPassword = "";
889 }}
890 >
891 Cancel
892 </button>
893 </div>
894 `
895 : html`
896 <div class="field-row">
897 <div class="field-value">••••••••</div>
898 <button
899 class="change-link"
900 @click=${() => {
901 this.editingPassword = true;
902 }}
903 >
904 Change
905 </button>
906 </div>
907 `
908 }
909 </div>
910
911 ${
912 this.passkeySupported
913 ? html`
914 <div class="field-group">
915 <label class="field-label">Passkeys</label>
916 <p class="field-description">
917 Passkeys provide a more secure and convenient way to sign in without passwords.
918 They use biometric authentication or your device's security features.
919 </p>
920 ${
921 this.loadingPasskeys
922 ? html`<div class="field-value">Loading passkeys...</div>`
923 : this.passkeys.length === 0
924 ? html`<div class="field-value" style="color: var(--secondary);">No passkeys registered yet</div>`
925 : html`
926 <div style="display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem;">
927 ${this.passkeys.map(
928 (passkey) => html`
929 <div class="session-card">
930 <div class="session-details">
931 <div class="session-row">
932 <span class="session-label">Name</span>
933 <span class="session-value">${passkey.name || "Unnamed passkey"}</span>
934 </div>
935 <div class="session-row">
936 <span class="session-label">Created</span>
937 <span class="session-value">${new Date(passkey.created_at * 1000).toLocaleDateString()}</span>
938 </div>
939 ${
940 passkey.last_used_at
941 ? html`
942 <div class="session-row">
943 <span class="session-label">Last used</span>
944 <span class="session-value">${new Date(passkey.last_used_at * 1000).toLocaleDateString()}</span>
945 </div>
946 `
947 : ""
948 }
949 </div>
950 <button
951 class="btn btn-rejection btn-small"
952 @click=${() => this.handleDeletePasskey(passkey.id)}
953 style="margin-top: 0.75rem;"
954 >
955 Delete
956 </button>
957 </div>
958 `,
959 )}
960 </div>
961 `
962 }
963 <button
964 class="btn btn-affirmative"
965 style="margin-top: 1rem;"
966 @click=${this.handleAddPasskey}
967 ?disabled=${this.addingPasskey}
968 >
969 ${this.addingPasskey ? "Adding..." : "Add Passkey"}
970 </button>
971 </div>
972 `
973 : ""
974 }
975
976 <div class="field-group">
977 <label class="field-label">Member Since</label>
978 <div class="field-value">${createdDate}</div>
979 </div>
980 </div>
981 </div>
982 `;
983 }
984
985 renderSessionsPage() {
986 return html`
987 <div class="content-inner">
988 <div class="section">
989 <h2 class="section-title">Active Sessions</h2>
990 ${
991 this.loadingSessions
992 ? html`<div class="loading">Loading sessions...</div>`
993 : this.sessions.length === 0
994 ? html`<p>No active sessions</p>`
995 : html`
996 <div class="session-list">
997 ${this.sessions.map(
998 (session) => html`
999 <div class="session-card ${session.is_current ? "current" : ""}">
1000 <div class="session-header">
1001 <span class="session-title">Session</span>
1002 ${session.is_current ? html`<span class="current-badge">Current</span>` : ""}
1003 </div>
1004 <div class="session-details">
1005 <div class="session-row">
1006 <span class="session-label">IP Address</span>
1007 <span class="session-value">${session.ip_address ?? "Unknown"}</span>
1008 </div>
1009 <div class="session-row">
1010 <span class="session-label">Device</span>
1011 <span class="session-value">${this.parseUserAgent(session.user_agent)}</span>
1012 </div>
1013 <div class="session-row">
1014 <span class="session-label">Created</span>
1015 <span class="session-value">${this.formatDate(session.created_at)}</span>
1016 </div>
1017 <div class="session-row">
1018 <span class="session-label">Expires</span>
1019 <span class="session-value">${this.formatDate(session.expires_at, true)}</span>
1020 </div>
1021 </div>
1022 <div style="margin-top: 1rem;">
1023 ${
1024 session.is_current
1025 ? html`
1026 <button
1027 class="btn btn-rejection"
1028 @click=${this.handleLogout}
1029 >
1030 Logout
1031 </button>
1032 `
1033 : html`
1034 <button
1035 class="btn btn-rejection"
1036 @click=${() => this.handleKillSession(session.id)}
1037 >
1038 Kill Session
1039 </button>
1040 `
1041 }
1042 </div>
1043 </div>
1044 `,
1045 )}
1046 </div>
1047 `
1048 }
1049 </div>
1050 </div>
1051 `;
1052 }
1053
1054 renderDangerPage() {
1055 return html`
1056 <div class="content-inner">
1057 <div class="section danger-section">
1058 <h2 class="section-title">Delete Account</h2>
1059 <p class="danger-text">
1060 Once you delete your account, there is no going back. This will
1061 permanently delete your account and all associated data.
1062 </p>
1063 <button
1064 class="btn btn-rejection"
1065 @click=${() => {
1066 this.showDeleteConfirm = true;
1067 }}
1068 >
1069 Delete Account
1070 </button>
1071 </div>
1072 </div>
1073 `;
1074 }
1075
1076 override render() {
1077 if (this.loading) {
1078 return html`<div class="loading">Loading...</div>`;
1079 }
1080
1081 if (this.error) {
1082 return html`<div class="error">${this.error}</div>`;
1083 }
1084
1085 if (!this.user) {
1086 return html`<div class="error">No user data available</div>`;
1087 }
1088
1089 return html`
1090 <div class="settings-container">
1091 <h1>Settings</h1>
1092
1093 <div class="tabs">
1094 <button
1095 class="tab ${this.currentPage === "account" ? "active" : ""}"
1096 @click=${() => {
1097 this.currentPage = "account";
1098 }}
1099 >
1100 Account
1101 </button>
1102 <button
1103 class="tab ${this.currentPage === "sessions" ? "active" : ""}"
1104 @click=${() => {
1105 this.currentPage = "sessions";
1106 }}
1107 >
1108 Sessions
1109 </button>
1110 <button
1111 class="tab ${this.currentPage === "danger" ? "active" : ""}"
1112 @click=${() => {
1113 this.currentPage = "danger";
1114 }}
1115 >
1116 Danger Zone
1117 </button>
1118 </div>
1119
1120 ${this.currentPage === "account" ? this.renderAccountPage() : ""}
1121 ${this.currentPage === "sessions" ? this.renderSessionsPage() : ""}
1122 ${this.currentPage === "danger" ? this.renderDangerPage() : ""}
1123 </div>
1124
1125 ${
1126 this.showDeleteConfirm
1127 ? html`
1128 <div
1129 class="modal-overlay"
1130 @click=${() => {
1131 this.showDeleteConfirm = false;
1132 }}
1133 >
1134 <div class="modal" @click=${(e: Event) => e.stopPropagation()}>
1135 <h3>Delete Account</h3>
1136 <p>
1137 Are you absolutely sure? This action cannot be undone. All your data will be
1138 permanently deleted.
1139 </p>
1140 <div class="modal-actions">
1141 <button class="btn btn-rejection" @click=${this.handleDeleteAccount}>
1142 Yes, Delete My Account
1143 </button>
1144 <button
1145 class="btn btn-neutral"
1146 @click=${() => {
1147 this.showDeleteConfirm = false;
1148 }}
1149 >
1150 Cancel
1151 </button>
1152 </div>
1153 </div>
1154 </div>
1155 `
1156 : ""
1157 }
1158 `;
1159 }
1160}