🪻 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 .session-list, .passkey-list {
237 list-style: none;
238 padding: 0;
239 margin: 0;
240 }
241
242 .session-item, .passkey-item {
243 display: flex;
244 justify-content: space-between;
245 align-items: center;
246 padding: 0.75rem;
247 border: 2px solid var(--secondary);
248 border-radius: 4px;
249 margin-bottom: 0.5rem;
250 }
251
252 .session-item:last-child, .passkey-item:last-child {
253 margin-bottom: 0;
254 }
255
256 .session-info, .passkey-info {
257 flex: 1;
258 }
259
260 .session-device, .passkey-name {
261 font-weight: 500;
262 color: var(--text);
263 margin-bottom: 0.25rem;
264 }
265
266 .session-meta, .passkey-meta {
267 font-size: 0.875rem;
268 color: var(--text);
269 opacity: 0.6;
270 }
271
272 .session-actions, .passkey-actions {
273 display: flex;
274 gap: 0.5rem;
275 }
276
277 .empty-sessions, .empty-passkeys {
278 text-align: center;
279 padding: 2rem;
280 color: var(--text);
281 opacity: 0.6;
282 background: rgba(0, 0, 0, 0.02);
283 border-radius: 4px;
284 }
285
286 .section-actions {
287 display: flex;
288 justify-content: space-between;
289 align-items: center;
290 margin-bottom: 1rem;
291 }
292
293 .loading, .error {
294 text-align: center;
295 padding: 2rem;
296 }
297
298 .error {
299 color: #dc2626;
300 }
301 `;
302
303 override connectedCallback() {
304 super.connectedCallback();
305 if (this.userId) {
306 this.loadUserDetails();
307 }
308 }
309
310 override updated(changedProperties: Map<string, unknown>) {
311 if (changedProperties.has("userId") && this.userId) {
312 this.loadUserDetails();
313 }
314 }
315
316 private async loadUserDetails() {
317 if (!this.userId) return;
318
319 this.loading = true;
320 this.error = null;
321
322 try {
323 const res = await fetch(`/api/admin/users/${this.userId}/details`);
324 if (!res.ok) {
325 throw new Error("Failed to load user details");
326 }
327
328 this.user = await res.json();
329 } catch (err) {
330 this.error =
331 err instanceof Error ? err.message : "Failed to load user details";
332 this.user = null;
333 } finally {
334 this.loading = false;
335 }
336 }
337
338 private close() {
339 this.dispatchEvent(
340 new CustomEvent("close", { bubbles: true, composed: true }),
341 );
342 }
343
344 private formatTimestamp(timestamp: number) {
345 const date = new Date(timestamp * 1000);
346 return date.toLocaleString();
347 }
348
349 private parseUserAgent(userAgent: string) {
350 if (!userAgent) return "🖥️ Unknown Device";
351 if (userAgent.includes("iPhone")) return "📱 iPhone";
352 if (userAgent.includes("iPad")) return "📱 iPad";
353 if (userAgent.includes("Android")) return "📱 Android";
354 if (userAgent.includes("Mac")) return "💻 Mac";
355 if (userAgent.includes("Windows")) return "💻 Windows";
356 if (userAgent.includes("Linux")) return "💻 Linux";
357 return "🖥️ Unknown Device";
358 }
359
360 private async handleChangeName(e: Event) {
361 e.preventDefault();
362 const form = e.target as HTMLFormElement;
363 const input = form.querySelector("input") as HTMLInputElement;
364 const name = input.value.trim();
365
366 if (!name) {
367 alert("Please enter a name");
368 return;
369 }
370
371 const submitBtn = form.querySelector(
372 'button[type="submit"]',
373 ) as HTMLButtonElement;
374 submitBtn.disabled = true;
375 submitBtn.textContent = "Updating...";
376
377 try {
378 const res = await fetch(`/api/admin/users/${this.userId}/name`, {
379 method: "PUT",
380 headers: { "Content-Type": "application/json" },
381 body: JSON.stringify({ name }),
382 });
383
384 if (!res.ok) {
385 throw new Error("Failed to update name");
386 }
387
388 alert("Name updated successfully");
389 await this.loadUserDetails();
390 this.dispatchEvent(
391 new CustomEvent("user-updated", { bubbles: true, composed: true }),
392 );
393 } catch {
394 alert("Failed to update name");
395 } finally {
396 submitBtn.disabled = false;
397 submitBtn.textContent = "Update Name";
398 }
399 }
400
401 private async handleChangeEmail(e: Event) {
402 e.preventDefault();
403 const form = e.target as HTMLFormElement;
404 const input = form.querySelector("input") as HTMLInputElement;
405 const email = input.value.trim();
406
407 if (!email || !email.includes("@")) {
408 alert("Please enter a valid email");
409 return;
410 }
411
412 const submitBtn = form.querySelector(
413 'button[type="submit"]',
414 ) as HTMLButtonElement;
415 submitBtn.disabled = true;
416 submitBtn.textContent = "Updating...";
417
418 try {
419 const res = await fetch(`/api/admin/users/${this.userId}/email`, {
420 method: "PUT",
421 headers: { "Content-Type": "application/json" },
422 body: JSON.stringify({ email }),
423 });
424
425 if (!res.ok) {
426 const data = await res.json();
427 throw new Error(data.error || "Failed to update email");
428 }
429
430 alert("Email updated successfully");
431 await this.loadUserDetails();
432 this.dispatchEvent(
433 new CustomEvent("user-updated", { bubbles: true, composed: true }),
434 );
435 } catch (error) {
436 alert(error instanceof Error ? error.message : "Failed to update email");
437 } finally {
438 submitBtn.disabled = false;
439 submitBtn.textContent = "Update Email";
440 }
441 }
442
443 private async handleChangePassword(e: Event) {
444 e.preventDefault();
445 const form = e.target as HTMLFormElement;
446 const input = form.querySelector("input") as HTMLInputElement;
447 const password = input.value;
448
449 if (password.length < 8) {
450 alert("Password must be at least 8 characters");
451 return;
452 }
453
454 if (
455 !confirm(
456 "Are you sure you want to change this user's password? This will log them out of all devices.",
457 )
458 ) {
459 return;
460 }
461
462 const submitBtn = form.querySelector(
463 'button[type="submit"]',
464 ) as HTMLButtonElement;
465 submitBtn.disabled = true;
466 submitBtn.textContent = "Updating...";
467
468 try {
469 const res = await fetch(`/api/admin/users/${this.userId}/password`, {
470 method: "PUT",
471 headers: { "Content-Type": "application/json" },
472 body: JSON.stringify({ password }),
473 });
474
475 if (!res.ok) {
476 throw new Error("Failed to update password");
477 }
478
479 alert(
480 "Password updated successfully. User has been logged out of all devices.",
481 );
482 input.value = "";
483 await this.loadUserDetails();
484 } catch {
485 alert("Failed to update password");
486 } finally {
487 submitBtn.disabled = false;
488 submitBtn.textContent = "Update Password";
489 }
490 }
491
492 private async handleLogoutAll() {
493 if (
494 !confirm(
495 "Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.",
496 )
497 ) {
498 return;
499 }
500
501 try {
502 const res = await fetch(`/api/admin/users/${this.userId}/sessions`, {
503 method: "DELETE",
504 });
505
506 if (!res.ok) {
507 throw new Error("Failed to logout all devices");
508 }
509
510 alert("User logged out from all devices");
511 await this.loadUserDetails();
512 } catch {
513 alert("Failed to logout all devices");
514 }
515 }
516
517 private async handleRevokeSession(sessionId: string) {
518 if (
519 !confirm(
520 "Revoke this session? The user will be logged out of this device.",
521 )
522 ) {
523 return;
524 }
525
526 try {
527 const res = await fetch(
528 `/api/admin/users/${this.userId}/sessions/${sessionId}`,
529 {
530 method: "DELETE",
531 },
532 );
533
534 if (!res.ok) {
535 throw new Error("Failed to revoke session");
536 }
537
538 await this.loadUserDetails();
539 } catch {
540 alert("Failed to revoke session");
541 }
542 }
543
544 private async handleRevokePasskey(passkeyId: string) {
545 if (
546 !confirm(
547 "Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.",
548 )
549 ) {
550 return;
551 }
552
553 try {
554 const res = await fetch(
555 `/api/admin/users/${this.userId}/passkeys/${passkeyId}`,
556 {
557 method: "DELETE",
558 },
559 );
560
561 if (!res.ok) {
562 throw new Error("Failed to revoke passkey");
563 }
564
565 await this.loadUserDetails();
566 } catch {
567 alert("Failed to revoke passkey");
568 }
569 }
570
571 override render() {
572 return html`
573 <div class="modal-content" @click=${(e: Event) => e.stopPropagation()}>
574 <div class="modal-header">
575 <h2 class="modal-title">User Details</h2>
576 <button class="modal-close" @click=${this.close} aria-label="Close">×</button>
577 </div>
578 <div class="modal-body">
579 ${this.loading ? html`<div class="loading">Loading...</div>` : ""}
580 ${this.error ? html`<div class="error">${this.error}</div>` : ""}
581 ${this.user ? this.renderUserDetails() : ""}
582 </div>
583 </div>
584 `;
585 }
586
587 private renderUserDetails() {
588 if (!this.user) return "";
589
590 return html`
591 <div class="detail-section">
592 <h3 class="detail-section-title">User Information</h3>
593 <div class="detail-row">
594 <span class="detail-label">Email</span>
595 <span class="detail-value">${this.user.email}</span>
596 </div>
597 <div class="detail-row">
598 <span class="detail-label">Name</span>
599 <span class="detail-value">${this.user.name || "Not set"}</span>
600 </div>
601 <div class="detail-row">
602 <span class="detail-label">Role</span>
603 <span class="detail-value">${this.user.role}</span>
604 </div>
605 <div class="detail-row">
606 <span class="detail-label">Joined</span>
607 <span class="detail-value">${this.formatTimestamp(this.user.created_at)}</span>
608 </div>
609 <div class="detail-row">
610 <span class="detail-label">Last Login</span>
611 <span class="detail-value">${this.user.last_login ? this.formatTimestamp(this.user.last_login) : "Never"}</span>
612 </div>
613 <div class="detail-row">
614 <span class="detail-label">Transcriptions</span>
615 <span class="detail-value">${this.user.transcriptionCount}</span>
616 </div>
617 <div class="detail-row">
618 <span class="detail-label">Password Status</span>
619 <span class="password-status ${this.user.hasPassword ? "has-password" : "no-password"}">
620 ${this.user.hasPassword ? "Has password" : "No password (passkey only)"}
621 </span>
622 </div>
623 </div>
624
625 <div class="detail-section">
626 <h3 class="detail-section-title">Change Name</h3>
627 <form @submit=${this.handleChangeName}>
628 <div class="form-group">
629 <label class="form-label" for="new-name">New Name</label>
630 <input type="text" id="new-name" class="form-input" placeholder="Enter new name" value=${this.user.name || ""}>
631 </div>
632 <button type="submit" class="btn btn-primary">Update Name</button>
633 </form>
634 </div>
635
636 <div class="detail-section">
637 <h3 class="detail-section-title">Change Email</h3>
638 <form @submit=${this.handleChangeEmail}>
639 <div class="form-group">
640 <label class="form-label" for="new-email">New Email</label>
641 <input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>
642 </div>
643 <button type="submit" class="btn btn-primary">Update Email</button>
644 </form>
645 </div>
646
647 <div class="detail-section">
648 <h3 class="detail-section-title">Change Password</h3>
649 <form @submit=${this.handleChangePassword}>
650 <div class="form-group">
651 <label class="form-label" for="new-password">New Password</label>
652 <input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)">
653 </div>
654 <button type="submit" class="btn btn-primary">Update Password</button>
655 </form>
656 </div>
657
658 <div class="detail-section">
659 <h3 class="detail-section-title">Active Sessions</h3>
660 <div class="section-actions">
661 <span class="detail-label">${this.user.sessions.length} active session${this.user.sessions.length !== 1 ? "s" : ""}</span>
662 <button @click=${this.handleLogoutAll} class="btn btn-danger btn-small" ?disabled=${this.user.sessions.length === 0}>
663 Logout All Devices
664 </button>
665 </div>
666 ${this.renderSessions()}
667 </div>
668
669 <div class="detail-section">
670 <h3 class="detail-section-title">Passkeys</h3>
671 ${this.renderPasskeys()}
672 </div>
673 `;
674 }
675
676 private renderSessions() {
677 if (!this.user || this.user.sessions.length === 0) {
678 return html`<div class="empty-sessions">No active sessions</div>`;
679 }
680
681 return html`
682 <ul class="session-list">
683 ${this.user.sessions.map(
684 (s) => html`
685 <li class="session-item">
686 <div class="session-info">
687 <div class="session-device">${this.parseUserAgent(s.user_agent)}</div>
688 <div class="session-meta">
689 IP: ${s.ip_address || "Unknown"} •
690 Created: ${this.formatTimestamp(s.created_at)} •
691 Expires: ${this.formatTimestamp(s.expires_at)}
692 </div>
693 </div>
694 <div class="session-actions">
695 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokeSession(s.id)}>
696 Revoke
697 </button>
698 </div>
699 </li>
700 `,
701 )}
702 </ul>
703 `;
704 }
705
706 private renderPasskeys() {
707 if (!this.user || this.user.passkeys.length === 0) {
708 return html`<div class="empty-passkeys">No passkeys registered</div>`;
709 }
710
711 return html`
712 <ul class="passkey-list">
713 ${this.user.passkeys.map(
714 (pk) => html`
715 <li class="passkey-item">
716 <div class="passkey-info">
717 <div class="passkey-name">${pk.name || "Unnamed Passkey"}</div>
718 <div class="passkey-meta">
719 Created: ${this.formatTimestamp(pk.created_at)}
720 ${pk.last_used_at ? ` • Last used: ${this.formatTimestamp(pk.last_used_at)}` : ""}
721 </div>
722 </div>
723 <div class="passkey-actions">
724 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokePasskey(pk.id)}>
725 Revoke
726 </button>
727 </div>
728 </li>
729 `,
730 )}
731 </ul>
732 `;
733 }
734}
735
736declare global {
737 interface HTMLElementTagNameMap {
738 "user-modal": UserModal;
739 }
740}