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