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