🪻 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">&times;</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}