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