🪻 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3
4interface Session {
5 id: string;
6 user_agent: string;
7 ip_address: string;
8 created_at: number;
9 expires_at: number;
10}
11
12interface Passkey {
13 id: string;
14 name: string;
15 created_at: number;
16 last_used_at: number | null;
17}
18
19interface UserDetails {
20 id: string;
21 email: string;
22 name: string | null;
23 role: string;
24 created_at: number;
25 last_login: number | null;
26 transcriptionCount: number;
27 hasPassword: boolean;
28 sessions: Session[];
29 passkeys: Passkey[];
30}
31
32@customElement("user-modal")
33export class UserModal extends LitElement {
34 @property({ type: String }) userId: string | null = null;
35 @state() private user: UserDetails | null = null;
36 @state() private loading = false;
37 @state() private error: string | null = null;
38
39 static override styles = css`
40 :host {
41 display: none;
42 position: fixed;
43 top: 0;
44 left: 0;
45 right: 0;
46 bottom: 0;
47 background: rgba(0, 0, 0, 0.5);
48 z-index: 1000;
49 align-items: center;
50 justify-content: center;
51 padding: 2rem;
52 }
53
54 :host([open]) {
55 display: flex;
56 }
57
58 .modal-content {
59 background: var(--background);
60 border-radius: 8px;
61 max-width: 40rem;
62 width: 100%;
63 max-height: 80vh;
64 overflow-y: auto;
65 box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3);
66 }
67
68 .modal-header {
69 padding: 1.5rem;
70 border-bottom: 2px solid var(--secondary);
71 display: flex;
72 justify-content: space-between;
73 align-items: center;
74 }
75
76 .modal-title {
77 font-size: 1.5rem;
78 font-weight: 600;
79 color: var(--text);
80 margin: 0;
81 }
82
83 .modal-close {
84 background: transparent;
85 border: none;
86 font-size: 1.5rem;
87 cursor: pointer;
88 color: var(--text);
89 padding: 0;
90 width: 2rem;
91 height: 2rem;
92 display: flex;
93 align-items: center;
94 justify-content: center;
95 border-radius: 4px;
96 transition: background 0.2s;
97 }
98
99 .modal-close:hover {
100 background: var(--secondary);
101 }
102
103 .modal-body {
104 padding: 1.5rem;
105 }
106
107 .detail-section {
108 margin-bottom: 2rem;
109 }
110
111 .detail-section:last-child {
112 margin-bottom: 0;
113 }
114
115 .detail-section-title {
116 font-size: 1.125rem;
117 font-weight: 600;
118 color: var(--text);
119 margin-bottom: 1rem;
120 padding-bottom: 0.5rem;
121 border-bottom: 2px solid var(--secondary);
122 }
123
124 .detail-row {
125 display: flex;
126 justify-content: space-between;
127 align-items: center;
128 padding: 0.75rem 0;
129 border-bottom: 1px solid var(--secondary);
130 }
131
132 .detail-row:last-child {
133 border-bottom: none;
134 }
135
136 .detail-label {
137 font-weight: 500;
138 color: var(--text);
139 }
140
141 .detail-value {
142 color: var(--text);
143 opacity: 0.8;
144 }
145
146 .form-group {
147 margin-bottom: 1rem;
148 }
149
150 .form-label {
151 display: block;
152 font-weight: 500;
153 color: var(--text);
154 margin-bottom: 0.5rem;
155 }
156
157 .form-input {
158 width: 100%;
159 padding: 0.5rem 0.75rem;
160 border: 2px solid var(--secondary);
161 border-radius: 4px;
162 font-size: 1rem;
163 font-family: inherit;
164 background: var(--background);
165 color: var(--text);
166 box-sizing: border-box;
167 }
168
169 .form-input:focus {
170 outline: none;
171 border-color: var(--primary);
172 }
173
174 .btn {
175 padding: 0.5rem 1rem;
176 border: none;
177 border-radius: 4px;
178 font-size: 1rem;
179 font-weight: 500;
180 font-family: inherit;
181 cursor: pointer;
182 transition: all 0.2s;
183 }
184
185 .btn-primary {
186 background: var(--primary);
187 color: white;
188 }
189
190 .btn-primary:hover {
191 background: var(--gunmetal);
192 }
193
194 .btn-primary:disabled {
195 opacity: 0.5;
196 cursor: not-allowed;
197 }
198
199 .btn-danger {
200 background: #dc2626;
201 color: white;
202 }
203
204 .btn-danger:hover {
205 background: #b91c1c;
206 }
207
208 .btn-danger:disabled {
209 opacity: 0.5;
210 cursor: not-allowed;
211 }
212
213 .btn-small {
214 padding: 0.25rem 0.75rem;
215 font-size: 0.875rem;
216 }
217
218 .password-status {
219 display: inline-block;
220 padding: 0.25rem 0.75rem;
221 border-radius: 4px;
222 font-size: 0.875rem;
223 font-weight: 500;
224 }
225
226 .password-status.has-password {
227 background: #dcfce7;
228 color: #166534;
229 }
230
231 .password-status.no-password {
232 background: #fee2e2;
233 color: #991b1b;
234 }
235
236 .info-text {
237 color: var(--text);
238 font-size: 0.875rem;
239 margin: 0 0 1rem 0;
240 line-height: 1.5;
241 opacity: 0.8;
242 }
243
244 .session-list, .passkey-list {
245 list-style: none;
246 padding: 0;
247 margin: 0;
248 }
249
250 .session-item, .passkey-item {
251 display: flex;
252 justify-content: space-between;
253 align-items: center;
254 padding: 0.75rem;
255 border: 2px solid var(--secondary);
256 border-radius: 4px;
257 margin-bottom: 0.5rem;
258 }
259
260 .session-item:last-child, .passkey-item:last-child {
261 margin-bottom: 0;
262 }
263
264 .session-info, .passkey-info {
265 flex: 1;
266 }
267
268 .session-device, .passkey-name {
269 font-weight: 500;
270 color: var(--text);
271 margin-bottom: 0.25rem;
272 }
273
274 .session-meta, .passkey-meta {
275 font-size: 0.875rem;
276 color: var(--text);
277 opacity: 0.6;
278 }
279
280 .session-actions, .passkey-actions {
281 display: flex;
282 gap: 0.5rem;
283 }
284
285 .empty-sessions, .empty-passkeys {
286 text-align: center;
287 padding: 2rem;
288 color: var(--text);
289 opacity: 0.6;
290 background: rgba(0, 0, 0, 0.02);
291 border-radius: 4px;
292 }
293
294 .section-actions {
295 display: flex;
296 justify-content: space-between;
297 align-items: center;
298 margin-bottom: 1rem;
299 }
300
301 .loading, .error {
302 text-align: center;
303 padding: 2rem;
304 }
305
306 .error {
307 color: #dc2626;
308 }
309 `;
310
311 override connectedCallback() {
312 super.connectedCallback();
313 if (this.userId) {
314 this.loadUserDetails();
315 }
316 }
317
318 override updated(changedProperties: Map<string, unknown>) {
319 if (changedProperties.has("userId") && this.userId) {
320 this.loadUserDetails();
321 }
322 }
323
324 private async loadUserDetails() {
325 if (!this.userId) return;
326
327 this.loading = true;
328 this.error = null;
329
330 try {
331 const res = await fetch(`/api/admin/users/${this.userId}/details`);
332 if (!res.ok) {
333 throw new Error("Failed to load user details");
334 }
335
336 this.user = await res.json();
337 } catch (err) {
338 this.error =
339 err instanceof Error ? err.message : "Failed to load user details";
340 this.user = null;
341 } finally {
342 this.loading = false;
343 }
344 }
345
346 private close() {
347 this.dispatchEvent(
348 new CustomEvent("close", { bubbles: true, composed: true }),
349 );
350 }
351
352 private formatTimestamp(timestamp: number) {
353 const date = new Date(timestamp * 1000);
354 return date.toLocaleString();
355 }
356
357 private parseUserAgent(userAgent: string) {
358 if (!userAgent) return "🖥️ Unknown Device";
359 if (userAgent.includes("iPhone")) return "📱 iPhone";
360 if (userAgent.includes("iPad")) return "📱 iPad";
361 if (userAgent.includes("Android")) return "📱 Android";
362 if (userAgent.includes("Mac")) return "💻 Mac";
363 if (userAgent.includes("Windows")) return "💻 Windows";
364 if (userAgent.includes("Linux")) return "💻 Linux";
365 return "🖥️ Unknown Device";
366 }
367
368 private async handleChangeName(e: Event) {
369 e.preventDefault();
370 const form = e.target as HTMLFormElement;
371 const input = form.querySelector("input") as HTMLInputElement;
372 const name = input.value.trim();
373
374 if (!name) {
375 alert("Please enter a name");
376 return;
377 }
378
379 const submitBtn = form.querySelector(
380 'button[type="submit"]',
381 ) as HTMLButtonElement;
382 submitBtn.disabled = true;
383 submitBtn.textContent = "Updating...";
384
385 try {
386 const res = await fetch(`/api/admin/users/${this.userId}/name`, {
387 method: "PUT",
388 headers: { "Content-Type": "application/json" },
389 body: JSON.stringify({ name }),
390 });
391
392 if (!res.ok) {
393 throw new Error("Failed to update name");
394 }
395
396 alert("Name updated successfully");
397 await this.loadUserDetails();
398 this.dispatchEvent(
399 new CustomEvent("user-updated", { bubbles: true, composed: true }),
400 );
401 } catch {
402 alert("Failed to update name");
403 } finally {
404 submitBtn.disabled = false;
405 submitBtn.textContent = "Update Name";
406 }
407 }
408
409 private async handleChangeEmail(e: Event) {
410 e.preventDefault();
411 const form = e.target as HTMLFormElement;
412 const input = form.querySelector('input[type="email"]') as HTMLInputElement;
413 const checkbox = form.querySelector(
414 'input[type="checkbox"]',
415 ) as HTMLInputElement;
416 const email = input.value.trim();
417 const skipVerification = checkbox?.checked || false;
418
419 if (!email || !email.includes("@")) {
420 alert("Please enter a valid email");
421 return;
422 }
423
424 const submitBtn = form.querySelector(
425 'button[type="submit"]',
426 ) as HTMLButtonElement;
427 submitBtn.disabled = true;
428 submitBtn.textContent = "Updating...";
429
430 try {
431 const res = await fetch(`/api/admin/users/${this.userId}/email`, {
432 method: "PUT",
433 headers: { "Content-Type": "application/json" },
434 body: JSON.stringify({ email, skipVerification }),
435 });
436
437 if (!res.ok) {
438 const data = await res.json();
439 throw new Error(data.error || "Failed to update email");
440 }
441
442 const data = await res.json();
443 alert(data.message || "Email updated successfully");
444 await this.loadUserDetails();
445 this.dispatchEvent(
446 new CustomEvent("user-updated", { bubbles: true, composed: true }),
447 );
448 } catch (error) {
449 alert(error instanceof Error ? error.message : "Failed to update email");
450 } finally {
451 submitBtn.disabled = false;
452 submitBtn.textContent = "Update Email";
453 }
454 }
455
456 private async handleChangePassword(e: Event) {
457 e.preventDefault();
458
459 if (
460 !confirm(
461 "Send a password reset email to this user? They will receive a link to set a new password.",
462 )
463 ) {
464 return;
465 }
466
467 const form = e.target as HTMLFormElement;
468 const submitBtn = form.querySelector(
469 'button[type="submit"]',
470 ) as HTMLButtonElement;
471 submitBtn.disabled = true;
472 submitBtn.textContent = "Sending...";
473
474 try {
475 const res = await fetch(
476 `/api/admin/users/${this.userId}/password-reset`,
477 {
478 method: "POST",
479 headers: { "Content-Type": "application/json" },
480 },
481 );
482
483 if (!res.ok) {
484 const data = await res.json();
485 throw new Error(data.error || "Failed to send password reset email");
486 }
487
488 alert(
489 "Password reset email sent successfully. The user will receive a link to set a new password.",
490 );
491 } catch (err) {
492 this.error =
493 err instanceof Error
494 ? err.message
495 : "Failed to send password reset email";
496 } finally {
497 submitBtn.disabled = false;
498 submitBtn.textContent = "Send Reset Email";
499 }
500 }
501
502 private async handleLogoutAll() {
503 if (
504 !confirm(
505 "Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.",
506 )
507 ) {
508 return;
509 }
510
511 try {
512 const res = await fetch(`/api/admin/users/${this.userId}/sessions`, {
513 method: "DELETE",
514 });
515
516 if (!res.ok) {
517 throw new Error("Failed to logout all devices");
518 }
519
520 alert("User logged out from all devices");
521 await this.loadUserDetails();
522 } catch {
523 alert("Failed to logout all devices");
524 }
525 }
526
527 private async handleRevokeSession(sessionId: string) {
528 if (
529 !confirm(
530 "Revoke this session? The user will be logged out of this device.",
531 )
532 ) {
533 return;
534 }
535
536 try {
537 const res = await fetch(
538 `/api/admin/users/${this.userId}/sessions/${sessionId}`,
539 {
540 method: "DELETE",
541 },
542 );
543
544 if (!res.ok) {
545 throw new Error("Failed to revoke session");
546 }
547
548 await this.loadUserDetails();
549 } catch {
550 alert("Failed to revoke session");
551 }
552 }
553
554 private async handleRevokePasskey(passkeyId: string) {
555 if (
556 !confirm(
557 "Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.",
558 )
559 ) {
560 return;
561 }
562
563 try {
564 const res = await fetch(
565 `/api/admin/users/${this.userId}/passkeys/${passkeyId}`,
566 {
567 method: "DELETE",
568 },
569 );
570
571 if (!res.ok) {
572 throw new Error("Failed to revoke passkey");
573 }
574
575 await this.loadUserDetails();
576 } catch {
577 alert("Failed to revoke passkey");
578 }
579 }
580
581 override render() {
582 return html`
583 <div class="modal-content" @click=${(e: Event) => e.stopPropagation()}>
584 <div class="modal-header">
585 <h2 class="modal-title">User Details</h2>
586 <button class="modal-close" @click=${this.close} aria-label="Close">×</button>
587 </div>
588 <div class="modal-body">
589 ${this.loading ? html`<div class="loading">Loading...</div>` : ""}
590 ${this.error ? html`<div class="error">${this.error}</div>` : ""}
591 ${this.user ? this.renderUserDetails() : ""}
592 </div>
593 </div>
594 `;
595 }
596
597 private renderUserDetails() {
598 if (!this.user) return "";
599
600 return html`
601 <div class="detail-section">
602 <h3 class="detail-section-title">User Information</h3>
603 <div class="detail-row">
604 <span class="detail-label">Email</span>
605 <span class="detail-value">${this.user.email}</span>
606 </div>
607 <div class="detail-row">
608 <span class="detail-label">Name</span>
609 <span class="detail-value">${this.user.name || "Not set"}</span>
610 </div>
611 <div class="detail-row">
612 <span class="detail-label">Role</span>
613 <span class="detail-value">${this.user.role}</span>
614 </div>
615 <div class="detail-row">
616 <span class="detail-label">Joined</span>
617 <span class="detail-value">${this.formatTimestamp(this.user.created_at)}</span>
618 </div>
619 <div class="detail-row">
620 <span class="detail-label">Last Login</span>
621 <span class="detail-value">${this.user.last_login ? this.formatTimestamp(this.user.last_login) : "Never"}</span>
622 </div>
623 <div class="detail-row">
624 <span class="detail-label">Transcriptions</span>
625 <span class="detail-value">${this.user.transcriptionCount}</span>
626 </div>
627 <div class="detail-row">
628 <span class="detail-label">Password Status</span>
629 <span class="password-status ${this.user.hasPassword ? "has-password" : "no-password"}">
630 ${this.user.hasPassword ? "Has password" : "No password (passkey only)"}
631 </span>
632 </div>
633 </div>
634
635 <div class="detail-section">
636 <h3 class="detail-section-title">Change Name</h3>
637 <form @submit=${this.handleChangeName}>
638 <div class="form-group">
639 <label class="form-label" for="new-name">New Name</label>
640 <input type="text" id="new-name" class="form-input" placeholder="Enter new name" value=${this.user.name || ""}>
641 </div>
642 <button type="submit" class="btn btn-primary">Update Name</button>
643 </form>
644 </div>
645
646 <div class="detail-section">
647 <h3 class="detail-section-title">Change Email</h3>
648 <form @submit=${this.handleChangeEmail}>
649 <div class="form-group">
650 <label class="form-label" for="new-email">New Email</label>
651 <input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>
652 </div>
653 <div class="form-group" style="margin-top: 0.5rem;">
654 <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem;">
655 <input type="checkbox" id="skip-verification" style="cursor: pointer;">
656 <span>Skip verification (use if user is locked out of email)</span>
657 </label>
658 </div>
659 <button type="submit" class="btn btn-primary">Update Email</button>
660 </form>
661 </div>
662
663 <div class="detail-section">
664 <h3 class="detail-section-title">Password Reset</h3>
665 <p class="info-text">Send a password reset email to this user. They will receive a secure link to set a new password.</p>
666 <form @submit=${this.handleChangePassword}>
667 <button type="submit" class="btn btn-primary">Send Reset Email</button>
668 </form>
669 </div>
670
671 <div class="detail-section">
672 <h3 class="detail-section-title">Active Sessions</h3>
673 <div class="section-actions">
674 <span class="detail-label">${this.user.sessions.length} active session${this.user.sessions.length !== 1 ? "s" : ""}</span>
675 <button @click=${this.handleLogoutAll} class="btn btn-danger btn-small" ?disabled=${this.user.sessions.length === 0}>
676 Logout All Devices
677 </button>
678 </div>
679 ${this.renderSessions()}
680 </div>
681
682 <div class="detail-section">
683 <h3 class="detail-section-title">Passkeys</h3>
684 ${this.renderPasskeys()}
685 </div>
686 `;
687 }
688
689 private renderSessions() {
690 if (!this.user || this.user.sessions.length === 0) {
691 return html`<div class="empty-sessions">No active sessions</div>`;
692 }
693
694 return html`
695 <ul class="session-list">
696 ${this.user.sessions.map(
697 (s) => html`
698 <li class="session-item">
699 <div class="session-info">
700 <div class="session-device">${this.parseUserAgent(s.user_agent)}</div>
701 <div class="session-meta">
702 IP: ${s.ip_address || "Unknown"} •
703 Created: ${this.formatTimestamp(s.created_at)} •
704 Expires: ${this.formatTimestamp(s.expires_at)}
705 </div>
706 </div>
707 <div class="session-actions">
708 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokeSession(s.id)}>
709 Revoke
710 </button>
711 </div>
712 </li>
713 `,
714 )}
715 </ul>
716 `;
717 }
718
719 private renderPasskeys() {
720 if (!this.user || this.user.passkeys.length === 0) {
721 return html`<div class="empty-passkeys">No passkeys registered</div>`;
722 }
723
724 return html`
725 <ul class="passkey-list">
726 ${this.user.passkeys.map(
727 (pk) => html`
728 <li class="passkey-item">
729 <div class="passkey-info">
730 <div class="passkey-name">${pk.name || "Unnamed Passkey"}</div>
731 <div class="passkey-meta">
732 Created: ${this.formatTimestamp(pk.created_at)}
733 ${pk.last_used_at ? ` • Last used: ${this.formatTimestamp(pk.last_used_at)}` : ""}
734 </div>
735 </div>
736 <div class="passkey-actions">
737 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokePasskey(pk.id)}>
738 Revoke
739 </button>
740 </div>
741 </li>
742 `,
743 )}
744 </ul>
745 `;
746 }
747}
748
749declare global {
750 interface HTMLElementTagNameMap {
751 "user-modal": UserModal;
752 }
753}