🪻 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") as HTMLInputElement;
413 const email = input.value.trim();
414
415 if (!email || !email.includes("@")) {
416 alert("Please enter a valid email");
417 return;
418 }
419
420 const submitBtn = form.querySelector(
421 'button[type="submit"]',
422 ) as HTMLButtonElement;
423 submitBtn.disabled = true;
424 submitBtn.textContent = "Updating...";
425
426 try {
427 const res = await fetch(`/api/admin/users/${this.userId}/email`, {
428 method: "PUT",
429 headers: { "Content-Type": "application/json" },
430 body: JSON.stringify({ email }),
431 });
432
433 if (!res.ok) {
434 const data = await res.json();
435 throw new Error(data.error || "Failed to update email");
436 }
437
438 alert("Email updated successfully");
439 await this.loadUserDetails();
440 this.dispatchEvent(
441 new CustomEvent("user-updated", { bubbles: true, composed: true }),
442 );
443 } catch (error) {
444 alert(error instanceof Error ? error.message : "Failed to update email");
445 } finally {
446 submitBtn.disabled = false;
447 submitBtn.textContent = "Update Email";
448 }
449 }
450
451 private async handleChangePassword(e: Event) {
452 e.preventDefault();
453
454 if (
455 !confirm(
456 "Send a password reset email to this user? They will receive a link to set a new password.",
457 )
458 ) {
459 return;
460 }
461
462 const form = e.target as HTMLFormElement;
463 const submitBtn = form.querySelector(
464 'button[type="submit"]',
465 ) as HTMLButtonElement;
466 submitBtn.disabled = true;
467 submitBtn.textContent = "Sending...";
468
469 try {
470 const res = await fetch(`/api/admin/users/${this.userId}/password-reset`, {
471 method: "POST",
472 headers: { "Content-Type": "application/json" },
473 });
474
475 if (!res.ok) {
476 const data = await res.json();
477 throw new Error(data.error || "Failed to send password reset email");
478 }
479
480 alert(
481 "Password reset email sent successfully. The user will receive a link to set a new password.",
482 );
483 } catch (err) {
484 this.error = err instanceof Error ? err.message : "Failed to send password reset email";
485 } finally {
486 submitBtn.disabled = false;
487 submitBtn.textContent = "Send Reset Email";
488 }
489 }
490
491 private async handleLogoutAll() {
492 if (
493 !confirm(
494 "Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.",
495 )
496 ) {
497 return;
498 }
499
500 try {
501 const res = await fetch(`/api/admin/users/${this.userId}/sessions`, {
502 method: "DELETE",
503 });
504
505 if (!res.ok) {
506 throw new Error("Failed to logout all devices");
507 }
508
509 alert("User logged out from all devices");
510 await this.loadUserDetails();
511 } catch {
512 alert("Failed to logout all devices");
513 }
514 }
515
516 private async handleRevokeSession(sessionId: string) {
517 if (
518 !confirm(
519 "Revoke this session? The user will be logged out of this device.",
520 )
521 ) {
522 return;
523 }
524
525 try {
526 const res = await fetch(
527 `/api/admin/users/${this.userId}/sessions/${sessionId}`,
528 {
529 method: "DELETE",
530 },
531 );
532
533 if (!res.ok) {
534 throw new Error("Failed to revoke session");
535 }
536
537 await this.loadUserDetails();
538 } catch {
539 alert("Failed to revoke session");
540 }
541 }
542
543 private async handleRevokePasskey(passkeyId: string) {
544 if (
545 !confirm(
546 "Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.",
547 )
548 ) {
549 return;
550 }
551
552 try {
553 const res = await fetch(
554 `/api/admin/users/${this.userId}/passkeys/${passkeyId}`,
555 {
556 method: "DELETE",
557 },
558 );
559
560 if (!res.ok) {
561 throw new Error("Failed to revoke passkey");
562 }
563
564 await this.loadUserDetails();
565 } catch {
566 alert("Failed to revoke passkey");
567 }
568 }
569
570 override render() {
571 return html`
572 <div class="modal-content" @click=${(e: Event) => e.stopPropagation()}>
573 <div class="modal-header">
574 <h2 class="modal-title">User Details</h2>
575 <button class="modal-close" @click=${this.close} aria-label="Close">×</button>
576 </div>
577 <div class="modal-body">
578 ${this.loading ? html`<div class="loading">Loading...</div>` : ""}
579 ${this.error ? html`<div class="error">${this.error}</div>` : ""}
580 ${this.user ? this.renderUserDetails() : ""}
581 </div>
582 </div>
583 `;
584 }
585
586 private renderUserDetails() {
587 if (!this.user) return "";
588
589 return html`
590 <div class="detail-section">
591 <h3 class="detail-section-title">User Information</h3>
592 <div class="detail-row">
593 <span class="detail-label">Email</span>
594 <span class="detail-value">${this.user.email}</span>
595 </div>
596 <div class="detail-row">
597 <span class="detail-label">Name</span>
598 <span class="detail-value">${this.user.name || "Not set"}</span>
599 </div>
600 <div class="detail-row">
601 <span class="detail-label">Role</span>
602 <span class="detail-value">${this.user.role}</span>
603 </div>
604 <div class="detail-row">
605 <span class="detail-label">Joined</span>
606 <span class="detail-value">${this.formatTimestamp(this.user.created_at)}</span>
607 </div>
608 <div class="detail-row">
609 <span class="detail-label">Last Login</span>
610 <span class="detail-value">${this.user.last_login ? this.formatTimestamp(this.user.last_login) : "Never"}</span>
611 </div>
612 <div class="detail-row">
613 <span class="detail-label">Transcriptions</span>
614 <span class="detail-value">${this.user.transcriptionCount}</span>
615 </div>
616 <div class="detail-row">
617 <span class="detail-label">Password Status</span>
618 <span class="password-status ${this.user.hasPassword ? "has-password" : "no-password"}">
619 ${this.user.hasPassword ? "Has password" : "No password (passkey only)"}
620 </span>
621 </div>
622 </div>
623
624 <div class="detail-section">
625 <h3 class="detail-section-title">Change Name</h3>
626 <form @submit=${this.handleChangeName}>
627 <div class="form-group">
628 <label class="form-label" for="new-name">New Name</label>
629 <input type="text" id="new-name" class="form-input" placeholder="Enter new name" value=${this.user.name || ""}>
630 </div>
631 <button type="submit" class="btn btn-primary">Update Name</button>
632 </form>
633 </div>
634
635 <div class="detail-section">
636 <h3 class="detail-section-title">Change Email</h3>
637 <form @submit=${this.handleChangeEmail}>
638 <div class="form-group">
639 <label class="form-label" for="new-email">New Email</label>
640 <input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>
641 </div>
642 <button type="submit" class="btn btn-primary">Update Email</button>
643 </form>
644 </div>
645
646 <div class="detail-section">
647 <h3 class="detail-section-title">Password Reset</h3>
648 <p class="info-text">Send a password reset email to this user. They will receive a secure link to set a new password.</p>
649 <form @submit=${this.handleChangePassword}>
650 <button type="submit" class="btn btn-primary">Send Reset Email</button>
651 </form>
652 </div>
653
654 <div class="detail-section">
655 <h3 class="detail-section-title">Active Sessions</h3>
656 <div class="section-actions">
657 <span class="detail-label">${this.user.sessions.length} active session${this.user.sessions.length !== 1 ? "s" : ""}</span>
658 <button @click=${this.handleLogoutAll} class="btn btn-danger btn-small" ?disabled=${this.user.sessions.length === 0}>
659 Logout All Devices
660 </button>
661 </div>
662 ${this.renderSessions()}
663 </div>
664
665 <div class="detail-section">
666 <h3 class="detail-section-title">Passkeys</h3>
667 ${this.renderPasskeys()}
668 </div>
669 `;
670 }
671
672 private renderSessions() {
673 if (!this.user || this.user.sessions.length === 0) {
674 return html`<div class="empty-sessions">No active sessions</div>`;
675 }
676
677 return html`
678 <ul class="session-list">
679 ${this.user.sessions.map(
680 (s) => html`
681 <li class="session-item">
682 <div class="session-info">
683 <div class="session-device">${this.parseUserAgent(s.user_agent)}</div>
684 <div class="session-meta">
685 IP: ${s.ip_address || "Unknown"} •
686 Created: ${this.formatTimestamp(s.created_at)} •
687 Expires: ${this.formatTimestamp(s.expires_at)}
688 </div>
689 </div>
690 <div class="session-actions">
691 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokeSession(s.id)}>
692 Revoke
693 </button>
694 </div>
695 </li>
696 `,
697 )}
698 </ul>
699 `;
700 }
701
702 private renderPasskeys() {
703 if (!this.user || this.user.passkeys.length === 0) {
704 return html`<div class="empty-passkeys">No passkeys registered</div>`;
705 }
706
707 return html`
708 <ul class="passkey-list">
709 ${this.user.passkeys.map(
710 (pk) => html`
711 <li class="passkey-item">
712 <div class="passkey-info">
713 <div class="passkey-name">${pk.name || "Unnamed Passkey"}</div>
714 <div class="passkey-meta">
715 Created: ${this.formatTimestamp(pk.created_at)}
716 ${pk.last_used_at ? ` • Last used: ${this.formatTimestamp(pk.last_used_at)}` : ""}
717 </div>
718 </div>
719 <div class="passkey-actions">
720 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokePasskey(pk.id)}>
721 Revoke
722 </button>
723 </div>
724 </li>
725 `,
726 )}
727 </ul>
728 `;
729 }
730}
731
732declare global {
733 interface HTMLElementTagNameMap {
734 "user-modal": UserModal;
735 }
736}