🪻 distributed transcription service
thistle.dunkirk.sh
1import { LitElement, html, css } 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 = err instanceof Error ? err.message : "Failed to load user details";
331 this.user = null;
332 } finally {
333 this.loading = false;
334 }
335 }
336
337 private close() {
338 this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true }));
339 }
340
341 private formatTimestamp(timestamp: number) {
342 const date = new Date(timestamp * 1000);
343 return date.toLocaleString();
344 }
345
346 private parseUserAgent(userAgent: string) {
347 if (!userAgent) return "🖥️ Unknown Device";
348 if (userAgent.includes("iPhone")) return "📱 iPhone";
349 if (userAgent.includes("iPad")) return "📱 iPad";
350 if (userAgent.includes("Android")) return "📱 Android";
351 if (userAgent.includes("Mac")) return "💻 Mac";
352 if (userAgent.includes("Windows")) return "💻 Windows";
353 if (userAgent.includes("Linux")) return "💻 Linux";
354 return "🖥️ Unknown Device";
355 }
356
357 private async handleChangeName(e: Event) {
358 e.preventDefault();
359 const form = e.target as HTMLFormElement;
360 const input = form.querySelector("input") as HTMLInputElement;
361 const name = input.value.trim();
362
363 if (!name) {
364 alert("Please enter a name");
365 return;
366 }
367
368 const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
369 submitBtn.disabled = true;
370 submitBtn.textContent = "Updating...";
371
372 try {
373 const res = await fetch(`/api/admin/users/${this.userId}/name`, {
374 method: "PUT",
375 headers: { "Content-Type": "application/json" },
376 body: JSON.stringify({ name }),
377 });
378
379 if (!res.ok) {
380 throw new Error("Failed to update name");
381 }
382
383 alert("Name updated successfully");
384 await this.loadUserDetails();
385 this.dispatchEvent(new CustomEvent("user-updated", { bubbles: true, composed: true }));
386 } catch {
387 alert("Failed to update name");
388 } finally {
389 submitBtn.disabled = false;
390 submitBtn.textContent = "Update Name";
391 }
392 }
393
394 private async handleChangeEmail(e: Event) {
395 e.preventDefault();
396 const form = e.target as HTMLFormElement;
397 const input = form.querySelector("input") as HTMLInputElement;
398 const email = input.value.trim();
399
400 if (!email || !email.includes("@")) {
401 alert("Please enter a valid email");
402 return;
403 }
404
405 const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
406 submitBtn.disabled = true;
407 submitBtn.textContent = "Updating...";
408
409 try {
410 const res = await fetch(`/api/admin/users/${this.userId}/email`, {
411 method: "PUT",
412 headers: { "Content-Type": "application/json" },
413 body: JSON.stringify({ email }),
414 });
415
416 if (!res.ok) {
417 const data = await res.json();
418 throw new Error(data.error || "Failed to update email");
419 }
420
421 alert("Email updated successfully");
422 await this.loadUserDetails();
423 this.dispatchEvent(new CustomEvent("user-updated", { bubbles: true, composed: true }));
424 } catch (error) {
425 alert(error instanceof Error ? error.message : "Failed to update email");
426 } finally {
427 submitBtn.disabled = false;
428 submitBtn.textContent = "Update Email";
429 }
430 }
431
432 private async handleChangePassword(e: Event) {
433 e.preventDefault();
434 const form = e.target as HTMLFormElement;
435 const input = form.querySelector("input") as HTMLInputElement;
436 const password = input.value;
437
438 if (password.length < 8) {
439 alert("Password must be at least 8 characters");
440 return;
441 }
442
443 if (!confirm("Are you sure you want to change this user's password? This will log them out of all devices.")) {
444 return;
445 }
446
447 const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
448 submitBtn.disabled = true;
449 submitBtn.textContent = "Updating...";
450
451 try {
452 const res = await fetch(`/api/admin/users/${this.userId}/password`, {
453 method: "PUT",
454 headers: { "Content-Type": "application/json" },
455 body: JSON.stringify({ password }),
456 });
457
458 if (!res.ok) {
459 throw new Error("Failed to update password");
460 }
461
462 alert("Password updated successfully. User has been logged out of all devices.");
463 input.value = "";
464 await this.loadUserDetails();
465 } catch {
466 alert("Failed to update password");
467 } finally {
468 submitBtn.disabled = false;
469 submitBtn.textContent = "Update Password";
470 }
471 }
472
473 private async handleLogoutAll() {
474 if (!confirm("Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.")) {
475 return;
476 }
477
478 try {
479 const res = await fetch(`/api/admin/users/${this.userId}/sessions`, {
480 method: "DELETE",
481 });
482
483 if (!res.ok) {
484 throw new Error("Failed to logout all devices");
485 }
486
487 alert("User logged out from all devices");
488 await this.loadUserDetails();
489 } catch {
490 alert("Failed to logout all devices");
491 }
492 }
493
494 private async handleRevokeSession(sessionId: string) {
495 if (!confirm("Revoke this session? The user will be logged out of this device.")) {
496 return;
497 }
498
499 try {
500 const res = await fetch(`/api/admin/users/${this.userId}/sessions/${sessionId}`, {
501 method: "DELETE",
502 });
503
504 if (!res.ok) {
505 throw new Error("Failed to revoke session");
506 }
507
508 await this.loadUserDetails();
509 } catch {
510 alert("Failed to revoke session");
511 }
512 }
513
514 private async handleRevokePasskey(passkeyId: string) {
515 if (!confirm("Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.")) {
516 return;
517 }
518
519 try {
520 const res = await fetch(`/api/admin/users/${this.userId}/passkeys/${passkeyId}`, {
521 method: "DELETE",
522 });
523
524 if (!res.ok) {
525 throw new Error("Failed to revoke passkey");
526 }
527
528 await this.loadUserDetails();
529 } catch {
530 alert("Failed to revoke passkey");
531 }
532 }
533
534 override render() {
535 return html`
536 <div class="modal-content" @click=${(e: Event) => e.stopPropagation()}>
537 <div class="modal-header">
538 <h2 class="modal-title">User Details</h2>
539 <button class="modal-close" @click=${this.close} aria-label="Close">×</button>
540 </div>
541 <div class="modal-body">
542 ${this.loading ? html`<div class="loading">Loading...</div>` : ""}
543 ${this.error ? html`<div class="error">${this.error}</div>` : ""}
544 ${this.user ? this.renderUserDetails() : ""}
545 </div>
546 </div>
547 `;
548 }
549
550 private renderUserDetails() {
551 if (!this.user) return "";
552
553 return html`
554 <div class="detail-section">
555 <h3 class="detail-section-title">User Information</h3>
556 <div class="detail-row">
557 <span class="detail-label">Email</span>
558 <span class="detail-value">${this.user.email}</span>
559 </div>
560 <div class="detail-row">
561 <span class="detail-label">Name</span>
562 <span class="detail-value">${this.user.name || "Not set"}</span>
563 </div>
564 <div class="detail-row">
565 <span class="detail-label">Role</span>
566 <span class="detail-value">${this.user.role}</span>
567 </div>
568 <div class="detail-row">
569 <span class="detail-label">Joined</span>
570 <span class="detail-value">${this.formatTimestamp(this.user.created_at)}</span>
571 </div>
572 <div class="detail-row">
573 <span class="detail-label">Last Login</span>
574 <span class="detail-value">${this.user.last_login ? this.formatTimestamp(this.user.last_login) : "Never"}</span>
575 </div>
576 <div class="detail-row">
577 <span class="detail-label">Transcriptions</span>
578 <span class="detail-value">${this.user.transcriptionCount}</span>
579 </div>
580 <div class="detail-row">
581 <span class="detail-label">Password Status</span>
582 <span class="password-status ${this.user.hasPassword ? "has-password" : "no-password"}">
583 ${this.user.hasPassword ? "Has password" : "No password (passkey only)"}
584 </span>
585 </div>
586 </div>
587
588 <div class="detail-section">
589 <h3 class="detail-section-title">Change Name</h3>
590 <form @submit=${this.handleChangeName}>
591 <div class="form-group">
592 <label class="form-label" for="new-name">New Name</label>
593 <input type="text" id="new-name" class="form-input" placeholder="Enter new name" value=${this.user.name || ""}>
594 </div>
595 <button type="submit" class="btn btn-primary">Update Name</button>
596 </form>
597 </div>
598
599 <div class="detail-section">
600 <h3 class="detail-section-title">Change Email</h3>
601 <form @submit=${this.handleChangeEmail}>
602 <div class="form-group">
603 <label class="form-label" for="new-email">New Email</label>
604 <input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>
605 </div>
606 <button type="submit" class="btn btn-primary">Update Email</button>
607 </form>
608 </div>
609
610 <div class="detail-section">
611 <h3 class="detail-section-title">Change Password</h3>
612 <form @submit=${this.handleChangePassword}>
613 <div class="form-group">
614 <label class="form-label" for="new-password">New Password</label>
615 <input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)">
616 </div>
617 <button type="submit" class="btn btn-primary">Update Password</button>
618 </form>
619 </div>
620
621 <div class="detail-section">
622 <h3 class="detail-section-title">Active Sessions</h3>
623 <div class="section-actions">
624 <span class="detail-label">${this.user.sessions.length} active session${this.user.sessions.length !== 1 ? "s" : ""}</span>
625 <button @click=${this.handleLogoutAll} class="btn btn-danger btn-small" ?disabled=${this.user.sessions.length === 0}>
626 Logout All Devices
627 </button>
628 </div>
629 ${this.renderSessions()}
630 </div>
631
632 <div class="detail-section">
633 <h3 class="detail-section-title">Passkeys</h3>
634 ${this.renderPasskeys()}
635 </div>
636 `;
637 }
638
639 private renderSessions() {
640 if (!this.user || this.user.sessions.length === 0) {
641 return html`<div class="empty-sessions">No active sessions</div>`;
642 }
643
644 return html`
645 <ul class="session-list">
646 ${this.user.sessions.map(
647 (s) => html`
648 <li class="session-item">
649 <div class="session-info">
650 <div class="session-device">${this.parseUserAgent(s.user_agent)}</div>
651 <div class="session-meta">
652 IP: ${s.ip_address || "Unknown"} •
653 Created: ${this.formatTimestamp(s.created_at)} •
654 Expires: ${this.formatTimestamp(s.expires_at)}
655 </div>
656 </div>
657 <div class="session-actions">
658 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokeSession(s.id)}>
659 Revoke
660 </button>
661 </div>
662 </li>
663 `,
664 )}
665 </ul>
666 `;
667 }
668
669 private renderPasskeys() {
670 if (!this.user || this.user.passkeys.length === 0) {
671 return html`<div class="empty-passkeys">No passkeys registered</div>`;
672 }
673
674 return html`
675 <ul class="passkey-list">
676 ${this.user.passkeys.map(
677 (pk) => html`
678 <li class="passkey-item">
679 <div class="passkey-info">
680 <div class="passkey-name">${pk.name || "Unnamed Passkey"}</div>
681 <div class="passkey-meta">
682 Created: ${this.formatTimestamp(pk.created_at)}
683 ${pk.last_used_at ? ` • Last used: ${this.formatTimestamp(pk.last_used_at)}` : ""}
684 </div>
685 </div>
686 <div class="passkey-actions">
687 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokePasskey(pk.id)}>
688 Revoke
689 </button>
690 </div>
691 </li>
692 `,
693 )}
694 </ul>
695 `;
696 }
697}
698
699declare global {
700 interface HTMLElementTagNameMap {
701 "user-modal": UserModal;
702 }
703}