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