🪻 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 {
6 isPasskeySupported,
7 registerPasskey,
8} from "../lib/client-passkey";
9
10interface User {
11 email: string;
12 name: string | null;
13 avatar: string;
14 created_at: number;
15}
16
17interface Session {
18 id: string;
19 ip_address: string | null;
20 user_agent: string | null;
21 created_at: number;
22 expires_at: number;
23 is_current: boolean;
24}
25
26interface Passkey {
27 id: string;
28 name: string | null;
29 created_at: number;
30 last_used_at: number | null;
31}
32
33type SettingsPage = "account" | "sessions" | "passkeys" | "danger";
34
35@customElement("user-settings")
36export class UserSettings extends LitElement {
37 @state() user: User | null = null;
38 @state() sessions: Session[] = [];
39 @state() passkeys: Passkey[] = [];
40 @state() loading = true;
41 @state() loadingSessions = true;
42 @state() loadingPasskeys = true;
43 @state() error = "";
44 @state() showDeleteConfirm = false;
45 @state() currentPage: SettingsPage = "account";
46 @state() editingEmail = false;
47 @state() editingPassword = false;
48 @state() newEmail = "";
49 @state() newPassword = "";
50 @state() newName = "";
51 @state() newAvatar = "";
52 @state() passkeySupported = false;
53 @state() addingPasskey = false;
54
55 static override styles = css`
56 :host {
57 display: block;
58 }
59
60 .settings-container {
61 display: flex;
62 gap: 3rem;
63 }
64
65 .sidebar {
66 width: 250px;
67 background: var(--background);
68 padding: 2rem 0;
69 display: flex;
70 flex-direction: column;
71 }
72
73 .sidebar-item {
74 padding: 0.75rem 1.5rem;
75 background: transparent;
76 color: var(--text);
77 border-radius: 6px;
78 border: 2px solid rgba(191, 192, 192, 0.3);
79 cursor: pointer;
80 font-family: inherit;
81 font-size: 1rem;
82 font-weight: 500;
83 text-align: left;
84 transition: all 0.2s;
85 margin: 0.25rem 1rem;
86 }
87
88 .sidebar-item:hover {
89 background: rgba(79, 93, 117, 0.1);
90 border-color: var(--secondary);
91 color: var(--primary);
92 }
93
94 .sidebar-item.active {
95 background: var(--primary);
96 color: white;
97 border-color: var(--primary);
98 }
99
100 .content {
101 flex: 1;
102 background: var(--background);
103 }
104
105 .content-inner {
106 max-width: 900px;
107 padding: 3rem 2rem 0rem 0;
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 flex-direction: column;
397 }
398
399 .sidebar {
400 width: 100%;
401 flex-direction: row;
402 overflow-x: auto;
403 padding: 1rem 0;
404 }
405
406 .sidebar-item {
407 white-space: nowrap;
408 border-left: none;
409 border-bottom: 3px solid transparent;
410 }
411
412 .sidebar-item.active {
413 border-left-color: transparent;
414 border-bottom-color: var(--accent);
415 }
416
417 .content-inner {
418 padding: 2rem 1rem;
419 }
420 }
421 `;
422
423 override async connectedCallback() {
424 super.connectedCallback();
425 this.passkeySupported = isPasskeySupported();
426 await this.loadUser();
427 await this.loadSessions();
428 if (this.passkeySupported) {
429 await this.loadPasskeys();
430 }
431 }
432
433 async loadUser() {
434 try {
435 const response = await fetch("/api/auth/me");
436
437 if (!response.ok) {
438 window.location.href = "/";
439 return;
440 }
441
442 this.user = await response.json();
443 } finally {
444 this.loading = false;
445 }
446 }
447
448 async loadSessions() {
449 try {
450 const response = await fetch("/api/sessions");
451
452 if (response.ok) {
453 const data = await response.json();
454 this.sessions = data.sessions;
455 }
456 } finally {
457 this.loadingSessions = false;
458 }
459 }
460
461 async loadPasskeys() {
462 try {
463 const response = await fetch("/api/passkeys");
464
465 if (response.ok) {
466 const data = await response.json();
467 this.passkeys = data.passkeys;
468 }
469 } finally {
470 this.loadingPasskeys = false;
471 }
472 }
473
474 async handleAddPasskey() {
475 this.addingPasskey = true;
476 this.error = "";
477
478 try {
479 const name = prompt("Name this passkey (optional):");
480 if (name === null) {
481 // User cancelled
482 return;
483 }
484
485 const result = await registerPasskey(name || undefined);
486
487 if (!result.success) {
488 this.error = result.error || "Failed to register passkey";
489 return;
490 }
491
492 // Reload passkeys
493 await this.loadPasskeys();
494 } finally {
495 this.addingPasskey = false;
496 }
497 }
498
499 async handleDeletePasskey(passkeyId: string) {
500 if (!confirm("Are you sure you want to delete this passkey?")) {
501 return;
502 }
503
504 try {
505 const response = await fetch(`/api/passkeys/${passkeyId}`, {
506 method: "DELETE",
507 });
508
509 if (!response.ok) {
510 const error = await response.json();
511 this.error = error.error || "Failed to delete passkey";
512 return;
513 }
514
515 // Reload passkeys
516 await this.loadPasskeys();
517 } catch {
518 this.error = "Failed to delete passkey";
519 }
520 }
521
522 async handleLogout() {
523 try {
524 await fetch("/api/auth/logout", { method: "POST" });
525 window.location.href = "/";
526 } catch {
527 this.error = "Failed to logout";
528 }
529 }
530
531 async handleDeleteAccount() {
532 try {
533 const response = await fetch("/api/auth/delete-account", {
534 method: "DELETE",
535 });
536
537 if (!response.ok) {
538 this.error = "Failed to delete account";
539 return;
540 }
541
542 window.location.href = "/";
543 } catch {
544 this.error = "Failed to delete account";
545 } finally {
546 this.showDeleteConfirm = false;
547 }
548 }
549
550 async handleUpdateEmail() {
551 if (!this.newEmail) {
552 this.error = "Email required";
553 return;
554 }
555
556 try {
557 const response = await fetch("/api/user/email", {
558 method: "PUT",
559 headers: { "Content-Type": "application/json" },
560 body: JSON.stringify({ email: this.newEmail }),
561 });
562
563 if (!response.ok) {
564 const data = await response.json();
565 this.error = data.error || "Failed to update email";
566 return;
567 }
568
569 // Reload user data
570 await this.loadUser();
571 this.editingEmail = false;
572 this.newEmail = "";
573 } catch {
574 this.error = "Failed to update email";
575 }
576 }
577
578 async handleUpdatePassword() {
579 if (!this.newPassword) {
580 this.error = "Password required";
581 return;
582 }
583
584 if (this.newPassword.length < 8) {
585 this.error = "Password must be at least 8 characters";
586 return;
587 }
588
589 try {
590 // Hash password client-side before sending
591 const passwordHash = await hashPasswordClient(
592 this.newPassword,
593 this.user?.email ?? "",
594 );
595
596 const response = await fetch("/api/user/password", {
597 method: "PUT",
598 headers: { "Content-Type": "application/json" },
599 body: JSON.stringify({ password: passwordHash }),
600 });
601
602 if (!response.ok) {
603 const data = await response.json();
604 this.error = data.error || "Failed to update password";
605 return;
606 }
607
608 this.editingPassword = false;
609 this.newPassword = "";
610 } catch {
611 this.error = "Failed to update password";
612 }
613 }
614
615 async handleUpdateName() {
616 if (!this.newName) {
617 this.error = "Name required";
618 return;
619 }
620
621 try {
622 const response = await fetch("/api/user/name", {
623 method: "PUT",
624 headers: { "Content-Type": "application/json" },
625 body: JSON.stringify({ name: this.newName }),
626 });
627
628 if (!response.ok) {
629 const data = await response.json();
630 this.error = data.error || "Failed to update name";
631 return;
632 }
633
634 // Reload user data
635 await this.loadUser();
636 this.newName = "";
637 } catch {
638 this.error = "Failed to update name";
639 }
640 }
641
642 async handleUpdateAvatar() {
643 if (!this.newAvatar) {
644 this.error = "Avatar required";
645 return;
646 }
647
648 try {
649 const response = await fetch("/api/user/avatar", {
650 method: "PUT",
651 headers: { "Content-Type": "application/json" },
652 body: JSON.stringify({ avatar: this.newAvatar }),
653 });
654
655 if (!response.ok) {
656 const data = await response.json();
657 this.error = data.error || "Failed to update avatar";
658 return;
659 }
660
661 // Reload user data
662 await this.loadUser();
663 this.newAvatar = "";
664 } catch {
665 this.error = "Failed to update avatar";
666 }
667 }
668
669 generateRandomAvatar() {
670 // Generate a random string for the avatar
671 const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
672 let result = "";
673 for (let i = 0; i < 8; i++) {
674 result += chars.charAt(Math.floor(Math.random() * chars.length));
675 }
676 this.newAvatar = result;
677 this.handleUpdateAvatar();
678 }
679
680 formatDate(timestamp: number, future = false): string {
681 const date = new Date(timestamp * 1000);
682 const now = new Date();
683 const diff = Math.abs(now.getTime() - date.getTime());
684
685 // For future dates (like expiration)
686 if (future || date > now) {
687 // Less than a day
688 if (diff < 24 * 60 * 60 * 1000) {
689 const hours = Math.floor(diff / (60 * 60 * 1000));
690 return `in ${hours} hour${hours === 1 ? "" : "s"}`;
691 }
692
693 // Less than a week
694 if (diff < 7 * 24 * 60 * 60 * 1000) {
695 const days = Math.floor(diff / (24 * 60 * 60 * 1000));
696 return `in ${days} day${days === 1 ? "" : "s"}`;
697 }
698
699 // Show full date
700 return date.toLocaleDateString(undefined, {
701 month: "short",
702 day: "numeric",
703 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
704 });
705 }
706
707 // For past dates
708 // Less than a minute
709 if (diff < 60 * 1000) {
710 return "Just now";
711 }
712
713 // Less than an hour
714 if (diff < 60 * 60 * 1000) {
715 const minutes = Math.floor(diff / (60 * 1000));
716 return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
717 }
718
719 // Less than a day
720 if (diff < 24 * 60 * 60 * 1000) {
721 const hours = Math.floor(diff / (60 * 60 * 1000));
722 return `${hours} hour${hours === 1 ? "" : "s"} ago`;
723 }
724
725 // Less than a week
726 if (diff < 7 * 24 * 60 * 60 * 1000) {
727 const days = Math.floor(diff / (24 * 60 * 60 * 1000));
728 return `${days} day${days === 1 ? "" : "s"} ago`;
729 }
730
731 // Show full date
732 return date.toLocaleDateString(undefined, {
733 month: "short",
734 day: "numeric",
735 year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
736 });
737 }
738
739 async handleKillSession(sessionId: string) {
740 try {
741 const response = await fetch(`/api/sessions`, {
742 method: "DELETE",
743 headers: { "Content-Type": "application/json" },
744 body: JSON.stringify({ sessionId }),
745 });
746
747 if (!response.ok) {
748 this.error = "Failed to kill session";
749 return;
750 }
751
752 // Reload sessions
753 await this.loadSessions();
754 } catch {
755 this.error = "Failed to kill session";
756 }
757 }
758
759 parseUserAgent(userAgent: string | null): string {
760 if (!userAgent) return "Unknown";
761
762 const parser = new UAParser(userAgent);
763 const result = parser.getResult();
764
765 const browser = result.browser.name
766 ? `${result.browser.name}${result.browser.version ? ` ${result.browser.version}` : ""}`
767 : "";
768 const os = result.os.name
769 ? `${result.os.name}${result.os.version ? ` ${result.os.version}` : ""}`
770 : "";
771
772 if (browser && os) {
773 return `${browser} on ${os}`;
774 }
775 if (browser) return browser;
776 if (os) return os;
777
778 return userAgent;
779 }
780
781 renderAccountPage() {
782 if (!this.user) return html``;
783
784 const createdDate = new Date(
785 this.user.created_at * 1000,
786 ).toLocaleDateString();
787
788 return html`
789 <div class="section">
790 <h2 class="section-title">Profile Information</h2>
791
792 <div class="field-group">
793 <div class="profile-row">
794 <div class="avatar-container">
795 <img
796 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
797 alt="Avatar"
798 style="border-radius: 50%; width: 48px; height: 48px; border: 2px solid var(--secondary); cursor: pointer;"
799 @click=${this.generateRandomAvatar}
800 />
801 <div class="avatar-overlay" @click=${this.generateRandomAvatar}>
802 <span class="reload-symbol">↻</span>
803 </div>
804 </div>
805 <input
806 type="text"
807 class="field-input"
808 style="flex: 1;"
809 .value=${this.user.name ?? ""}
810 @input=${(e: Event) => {
811 this.newName = (e.target as HTMLInputElement).value;
812 }}
813 @blur=${() => {
814 if (this.newName && this.newName !== (this.user?.name ?? "")) {
815 this.handleUpdateName();
816 }
817 }}
818 placeholder="Your name"
819 />
820 </div>
821 </div>
822
823 <div class="field-group">
824 <label class="field-label">Email</label>
825 ${
826 this.editingEmail
827 ? html`
828 <div style="display: flex; gap: 0.5rem; align-items: center;">
829 <input
830 type="email"
831 class="field-input"
832 .value=${this.newEmail}
833 @input=${(e: Event) => {
834 this.newEmail = (e.target as HTMLInputElement).value;
835 }}
836 placeholder=${this.user.email}
837 />
838 <button
839 class="btn btn-affirmative btn-small"
840 @click=${this.handleUpdateEmail}
841 >
842 Save
843 </button>
844 <button
845 class="btn btn-neutral btn-small"
846 @click=${() => {
847 this.editingEmail = false;
848 this.newEmail = "";
849 }}
850 >
851 Cancel
852 </button>
853 </div>
854 `
855 : html`
856 <div class="field-row">
857 <div class="field-value">${this.user.email}</div>
858 <button
859 class="change-link"
860 @click=${() => {
861 this.editingEmail = true;
862 this.newEmail = this.user?.email ?? "";
863 }}
864 >
865 Change
866 </button>
867 </div>
868 `
869 }
870 </div>
871
872 <div class="field-group">
873 <label class="field-label">Password</label>
874 ${
875 this.editingPassword
876 ? html`
877 <div style="display: flex; gap: 0.5rem; align-items: center;">
878 <input
879 type="password"
880 class="field-input"
881 .value=${this.newPassword}
882 @input=${(e: Event) => {
883 this.newPassword = (e.target as HTMLInputElement).value;
884 }}
885 placeholder="New password"
886 />
887 <button
888 class="btn btn-affirmative btn-small"
889 @click=${this.handleUpdatePassword}
890 >
891 Save
892 </button>
893 <button
894 class="btn btn-neutral btn-small"
895 @click=${() => {
896 this.editingPassword = false;
897 this.newPassword = "";
898 }}
899 >
900 Cancel
901 </button>
902 </div>
903 `
904 : html`
905 <div class="field-row">
906 <div class="field-value">••••••••</div>
907 <button
908 class="change-link"
909 @click=${() => {
910 this.editingPassword = true;
911 }}
912 >
913 Change
914 </button>
915 </div>
916 `
917 }
918 </div>
919
920 ${
921 this.passkeySupported
922 ? html`
923 <div class="field-group">
924 <label class="field-label">Passkeys</label>
925 <p class="field-description">
926 Passkeys provide a more secure and convenient way to sign in without passwords.
927 They use biometric authentication or your device's security features.
928 </p>
929 ${
930 this.loadingPasskeys
931 ? html`<div class="field-value">Loading passkeys...</div>`
932 : this.passkeys.length === 0
933 ? html`<div class="field-value" style="color: var(--secondary);">No passkeys registered yet</div>`
934 : html`
935 <div style="display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem;">
936 ${this.passkeys.map(
937 (passkey) => html`
938 <div class="session-card">
939 <div class="session-details">
940 <div class="session-row">
941 <span class="session-label">Name</span>
942 <span class="session-value">${passkey.name || "Unnamed passkey"}</span>
943 </div>
944 <div class="session-row">
945 <span class="session-label">Created</span>
946 <span class="session-value">${new Date(passkey.created_at * 1000).toLocaleDateString()}</span>
947 </div>
948 ${
949 passkey.last_used_at
950 ? html`
951 <div class="session-row">
952 <span class="session-label">Last used</span>
953 <span class="session-value">${new Date(passkey.last_used_at * 1000).toLocaleDateString()}</span>
954 </div>
955 `
956 : ""
957 }
958 </div>
959 <button
960 class="btn btn-rejection btn-small"
961 @click=${() => this.handleDeletePasskey(passkey.id)}
962 style="margin-top: 0.75rem;"
963 >
964 Delete
965 </button>
966 </div>
967 `,
968 )}
969 </div>
970 `
971 }
972 <button
973 class="btn btn-affirmative"
974 style="margin-top: 1rem;"
975 @click=${this.handleAddPasskey}
976 ?disabled=${this.addingPasskey}
977 >
978 ${this.addingPasskey ? "Adding..." : "Add Passkey"}
979 </button>
980 </div>
981 `
982 : ""
983 }
984
985 <div class="field-group">
986 <label class="field-label">Member Since</label>
987 <div class="field-value">${createdDate}</div>
988 </div>
989 </div>
990
991 `;
992 }
993
994 renderSessionsPage() {
995 return html`
996 <div class="section">
997 <h2 class="section-title">Active Sessions</h2>
998 ${
999 this.loadingSessions
1000 ? html`<div class="loading">Loading sessions...</div>`
1001 : this.sessions.length === 0
1002 ? html`<p>No active sessions</p>`
1003 : html`
1004 <div class="session-list">
1005 ${this.sessions.map(
1006 (session) => html`
1007 <div class="session-card ${session.is_current ? "current" : ""}">
1008 <div class="session-header">
1009 <span class="session-title">Session</span>
1010 ${session.is_current ? html`<span class="current-badge">Current</span>` : ""}
1011 </div>
1012 <div class="session-details">
1013 <div class="session-row">
1014 <span class="session-label">IP Address</span>
1015 <span class="session-value">${session.ip_address ?? "Unknown"}</span>
1016 </div>
1017 <div class="session-row">
1018 <span class="session-label">Device</span>
1019 <span class="session-value">${this.parseUserAgent(session.user_agent)}</span>
1020 </div>
1021 <div class="session-row">
1022 <span class="session-label">Created</span>
1023 <span class="session-value">${this.formatDate(session.created_at)}</span>
1024 </div>
1025 <div class="session-row">
1026 <span class="session-label">Expires</span>
1027 <span class="session-value">${this.formatDate(session.expires_at, true)}</span>
1028 </div>
1029 </div>
1030 <div style="margin-top: 1rem;">
1031 ${
1032 session.is_current
1033 ? html`
1034 <button
1035 class="btn btn-rejection"
1036 @click=${this.handleLogout}
1037 >
1038 Logout
1039 </button>
1040 `
1041 : html`
1042 <button
1043 class="btn btn-rejection"
1044 @click=${() => this.handleKillSession(session.id)}
1045 >
1046 Kill Session
1047 </button>
1048 `
1049 }
1050 </div>
1051 </div>
1052 `,
1053 )}
1054 </div>
1055 `
1056 }
1057 </div>
1058 `;
1059 }
1060
1061 renderDangerPage() {
1062 return html`
1063 <div class="section danger-section">
1064 <h2 class="section-title">Delete Account</h2>
1065 <p class="danger-text">
1066 Once you delete your account, there is no going back. This will
1067 permanently delete your account and all associated data.
1068 </p>
1069 <button
1070 class="btn btn-rejection"
1071 @click=${() => {
1072 this.showDeleteConfirm = true;
1073 }}
1074 >
1075 Delete Account
1076 </button>
1077 </div>
1078 `;
1079 }
1080
1081 override render() {
1082 if (this.loading) {
1083 return html`<div class="loading">Loading...</div>`;
1084 }
1085
1086 if (this.error) {
1087 return html`<div class="error">${this.error}</div>`;
1088 }
1089
1090 if (!this.user) {
1091 return html`<div class="error">No user data available</div>`;
1092 }
1093
1094 return html`
1095 <div class="settings-container">
1096 <div class="sidebar">
1097 <button
1098 class="sidebar-item ${this.currentPage === "account" ? "active" : ""}"
1099 @click=${() => {
1100 this.currentPage = "account";
1101 }}
1102 >
1103 Account
1104 </button>
1105 <button
1106 class="sidebar-item ${this.currentPage === "sessions" ? "active" : ""}"
1107 @click=${() => {
1108 this.currentPage = "sessions";
1109 }}
1110 >
1111 Sessions
1112 </button>
1113 <button
1114 class="sidebar-item ${this.currentPage === "danger" ? "active" : ""}"
1115 @click=${() => {
1116 this.currentPage = "danger";
1117 }}
1118 >
1119 Danger Zone
1120 </button>
1121 </div>
1122
1123 <div class="content">
1124 <div class="content-inner">
1125 ${
1126 this.currentPage === "account"
1127 ? this.renderAccountPage()
1128 : this.currentPage === "sessions"
1129 ? this.renderSessionsPage()
1130 : this.renderDangerPage()
1131 }
1132 </div>
1133 </div>
1134 </div>
1135
1136 ${
1137 this.showDeleteConfirm
1138 ? html`
1139 <div
1140 class="modal-overlay"
1141 @click=${() => {
1142 this.showDeleteConfirm = false;
1143 }}
1144 >
1145 <div class="modal" @click=${(e: Event) => e.stopPropagation()}>
1146 <h3>Delete Account</h3>
1147 <p>
1148 Are you absolutely sure? This action cannot be undone. All your data will be
1149 permanently deleted.
1150 </p>
1151 <div class="modal-actions">
1152 <button class="btn btn-rejection" @click=${this.handleDeleteAccount}>
1153 Yes, Delete My Account
1154 </button>
1155 <button
1156 class="btn btn-neutral"
1157 @click=${() => {
1158 this.showDeleteConfirm = false;
1159 }}
1160 >
1161 Cancel
1162 </button>
1163 </div>
1164 </div>
1165 </div>
1166 `
1167 : ""
1168 }
1169 `;
1170 }
1171}