🪻 distributed transcription service thistle.dunkirk.sh
at main 19 kB view raw
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[type="email"]') as HTMLInputElement; 413 const checkbox = form.querySelector( 414 'input[type="checkbox"]', 415 ) as HTMLInputElement; 416 const email = input.value.trim(); 417 const skipVerification = checkbox?.checked || false; 418 419 if (!email || !email.includes("@")) { 420 alert("Please enter a valid email"); 421 return; 422 } 423 424 const submitBtn = form.querySelector( 425 'button[type="submit"]', 426 ) as HTMLButtonElement; 427 submitBtn.disabled = true; 428 submitBtn.textContent = "Updating..."; 429 430 try { 431 const res = await fetch(`/api/admin/users/${this.userId}/email`, { 432 method: "PUT", 433 headers: { "Content-Type": "application/json" }, 434 body: JSON.stringify({ email, skipVerification }), 435 }); 436 437 if (!res.ok) { 438 const data = await res.json(); 439 throw new Error(data.error || "Failed to update email"); 440 } 441 442 const data = await res.json(); 443 alert(data.message || "Email updated successfully"); 444 await this.loadUserDetails(); 445 this.dispatchEvent( 446 new CustomEvent("user-updated", { bubbles: true, composed: true }), 447 ); 448 } catch (error) { 449 alert(error instanceof Error ? error.message : "Failed to update email"); 450 } finally { 451 submitBtn.disabled = false; 452 submitBtn.textContent = "Update Email"; 453 } 454 } 455 456 private async handleChangePassword(e: Event) { 457 e.preventDefault(); 458 459 if ( 460 !confirm( 461 "Send a password reset email to this user? They will receive a link to set a new password.", 462 ) 463 ) { 464 return; 465 } 466 467 const form = e.target as HTMLFormElement; 468 const submitBtn = form.querySelector( 469 'button[type="submit"]', 470 ) as HTMLButtonElement; 471 submitBtn.disabled = true; 472 submitBtn.textContent = "Sending..."; 473 474 try { 475 const res = await fetch( 476 `/api/admin/users/${this.userId}/password-reset`, 477 { 478 method: "POST", 479 headers: { "Content-Type": "application/json" }, 480 }, 481 ); 482 483 if (!res.ok) { 484 const data = await res.json(); 485 throw new Error(data.error || "Failed to send password reset email"); 486 } 487 488 alert( 489 "Password reset email sent successfully. The user will receive a link to set a new password.", 490 ); 491 } catch (err) { 492 this.error = 493 err instanceof Error 494 ? err.message 495 : "Failed to send password reset email"; 496 } finally { 497 submitBtn.disabled = false; 498 submitBtn.textContent = "Send Reset Email"; 499 } 500 } 501 502 private async handleLogoutAll() { 503 if ( 504 !confirm( 505 "Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.", 506 ) 507 ) { 508 return; 509 } 510 511 try { 512 const res = await fetch(`/api/admin/users/${this.userId}/sessions`, { 513 method: "DELETE", 514 }); 515 516 if (!res.ok) { 517 throw new Error("Failed to logout all devices"); 518 } 519 520 alert("User logged out from all devices"); 521 await this.loadUserDetails(); 522 } catch { 523 alert("Failed to logout all devices"); 524 } 525 } 526 527 private async handleRevokeSession(sessionId: string) { 528 if ( 529 !confirm( 530 "Revoke this session? The user will be logged out of this device.", 531 ) 532 ) { 533 return; 534 } 535 536 try { 537 const res = await fetch( 538 `/api/admin/users/${this.userId}/sessions/${sessionId}`, 539 { 540 method: "DELETE", 541 }, 542 ); 543 544 if (!res.ok) { 545 throw new Error("Failed to revoke session"); 546 } 547 548 await this.loadUserDetails(); 549 } catch { 550 alert("Failed to revoke session"); 551 } 552 } 553 554 private async handleRevokePasskey(passkeyId: string) { 555 if ( 556 !confirm( 557 "Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.", 558 ) 559 ) { 560 return; 561 } 562 563 try { 564 const res = await fetch( 565 `/api/admin/users/${this.userId}/passkeys/${passkeyId}`, 566 { 567 method: "DELETE", 568 }, 569 ); 570 571 if (!res.ok) { 572 throw new Error("Failed to revoke passkey"); 573 } 574 575 await this.loadUserDetails(); 576 } catch { 577 alert("Failed to revoke passkey"); 578 } 579 } 580 581 override render() { 582 return html` 583 <div class="modal-content" @click=${(e: Event) => e.stopPropagation()}> 584 <div class="modal-header"> 585 <h2 class="modal-title">User Details</h2> 586 <button class="modal-close" @click=${this.close} aria-label="Close">&times;</button> 587 </div> 588 <div class="modal-body"> 589 ${this.loading ? html`<div class="loading">Loading...</div>` : ""} 590 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 591 ${this.user ? this.renderUserDetails() : ""} 592 </div> 593 </div> 594 `; 595 } 596 597 private renderUserDetails() { 598 if (!this.user) return ""; 599 600 return html` 601 <div class="detail-section"> 602 <h3 class="detail-section-title">User Information</h3> 603 <div class="detail-row"> 604 <span class="detail-label">Email</span> 605 <span class="detail-value">${this.user.email}</span> 606 </div> 607 <div class="detail-row"> 608 <span class="detail-label">Name</span> 609 <span class="detail-value">${this.user.name || "Not set"}</span> 610 </div> 611 <div class="detail-row"> 612 <span class="detail-label">Role</span> 613 <span class="detail-value">${this.user.role}</span> 614 </div> 615 <div class="detail-row"> 616 <span class="detail-label">Joined</span> 617 <span class="detail-value">${this.formatTimestamp(this.user.created_at)}</span> 618 </div> 619 <div class="detail-row"> 620 <span class="detail-label">Last Login</span> 621 <span class="detail-value">${this.user.last_login ? this.formatTimestamp(this.user.last_login) : "Never"}</span> 622 </div> 623 <div class="detail-row"> 624 <span class="detail-label">Transcriptions</span> 625 <span class="detail-value">${this.user.transcriptionCount}</span> 626 </div> 627 <div class="detail-row"> 628 <span class="detail-label">Password Status</span> 629 <span class="password-status ${this.user.hasPassword ? "has-password" : "no-password"}"> 630 ${this.user.hasPassword ? "Has password" : "No password (passkey only)"} 631 </span> 632 </div> 633 </div> 634 635 <div class="detail-section"> 636 <h3 class="detail-section-title">Change Name</h3> 637 <form @submit=${this.handleChangeName}> 638 <div class="form-group"> 639 <label class="form-label" for="new-name">New Name</label> 640 <input type="text" id="new-name" class="form-input" placeholder="Enter new name" value=${this.user.name || ""}> 641 </div> 642 <button type="submit" class="btn btn-primary">Update Name</button> 643 </form> 644 </div> 645 646 <div class="detail-section"> 647 <h3 class="detail-section-title">Change Email</h3> 648 <form @submit=${this.handleChangeEmail}> 649 <div class="form-group"> 650 <label class="form-label" for="new-email">New Email</label> 651 <input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}> 652 </div> 653 <div class="form-group" style="margin-top: 0.5rem;"> 654 <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem;"> 655 <input type="checkbox" id="skip-verification" style="cursor: pointer;"> 656 <span>Skip verification (use if user is locked out of email)</span> 657 </label> 658 </div> 659 <button type="submit" class="btn btn-primary">Update Email</button> 660 </form> 661 </div> 662 663 <div class="detail-section"> 664 <h3 class="detail-section-title">Password Reset</h3> 665 <p class="info-text">Send a password reset email to this user. They will receive a secure link to set a new password.</p> 666 <form @submit=${this.handleChangePassword}> 667 <button type="submit" class="btn btn-primary">Send Reset Email</button> 668 </form> 669 </div> 670 671 <div class="detail-section"> 672 <h3 class="detail-section-title">Active Sessions</h3> 673 <div class="section-actions"> 674 <span class="detail-label">${this.user.sessions.length} active session${this.user.sessions.length !== 1 ? "s" : ""}</span> 675 <button @click=${this.handleLogoutAll} class="btn btn-danger btn-small" ?disabled=${this.user.sessions.length === 0}> 676 Logout All Devices 677 </button> 678 </div> 679 ${this.renderSessions()} 680 </div> 681 682 <div class="detail-section"> 683 <h3 class="detail-section-title">Passkeys</h3> 684 ${this.renderPasskeys()} 685 </div> 686 `; 687 } 688 689 private renderSessions() { 690 if (!this.user || this.user.sessions.length === 0) { 691 return html`<div class="empty-sessions">No active sessions</div>`; 692 } 693 694 return html` 695 <ul class="session-list"> 696 ${this.user.sessions.map( 697 (s) => html` 698 <li class="session-item"> 699 <div class="session-info"> 700 <div class="session-device">${this.parseUserAgent(s.user_agent)}</div> 701 <div class="session-meta"> 702 IP: ${s.ip_address || "Unknown"}703 Created: ${this.formatTimestamp(s.created_at)}704 Expires: ${this.formatTimestamp(s.expires_at)} 705 </div> 706 </div> 707 <div class="session-actions"> 708 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokeSession(s.id)}> 709 Revoke 710 </button> 711 </div> 712 </li> 713 `, 714 )} 715 </ul> 716 `; 717 } 718 719 private renderPasskeys() { 720 if (!this.user || this.user.passkeys.length === 0) { 721 return html`<div class="empty-passkeys">No passkeys registered</div>`; 722 } 723 724 return html` 725 <ul class="passkey-list"> 726 ${this.user.passkeys.map( 727 (pk) => html` 728 <li class="passkey-item"> 729 <div class="passkey-info"> 730 <div class="passkey-name">${pk.name || "Unnamed Passkey"}</div> 731 <div class="passkey-meta"> 732 Created: ${this.formatTimestamp(pk.created_at)} 733 ${pk.last_used_at ? ` • Last used: ${this.formatTimestamp(pk.last_used_at)}` : ""} 734 </div> 735 </div> 736 <div class="passkey-actions"> 737 <button class="btn btn-danger btn-small" @click=${() => this.handleRevokePasskey(pk.id)}> 738 Revoke 739 </button> 740 </div> 741 </li> 742 `, 743 )} 744 </ul> 745 `; 746 } 747} 748 749declare global { 750 interface HTMLElementTagNameMap { 751 "user-modal": UserModal; 752 } 753}