🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: move user modal to components

+712 -718
+703
src/components/user-modal.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + interface Session { 5 + id: string; 6 + user_agent: string; 7 + ip_address: string; 8 + created_at: number; 9 + expires_at: number; 10 + } 11 + 12 + interface Passkey { 13 + id: string; 14 + name: string; 15 + created_at: number; 16 + last_used_at: number | null; 17 + } 18 + 19 + interface 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") 33 + export 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 + 699 + declare global { 700 + interface HTMLElementTagNameMap { 701 + "user-modal": UserModal; 702 + } 703 + }
+9 -718
src/pages/admin.html
··· 282 282 background: rgba(0, 0, 0, 0.04); 283 283 } 284 284 285 - .modal { 286 - display: none; 287 - position: fixed; 288 - top: 0; 289 - left: 0; 290 - right: 0; 291 - bottom: 0; 292 - background: rgba(0, 0, 0, 0.5); 293 - z-index: 1000; 294 - align-items: center; 295 - justify-content: center; 296 - padding: 2rem; 297 - } 298 - 299 - .modal.active { 300 - display: flex; 301 - } 302 - 303 - .modal-content { 304 - background: var(--background); 305 - border-radius: 8px; 306 - max-width: 40rem; 307 - width: 100%; 308 - max-height: 80vh; 309 - overflow-y: auto; 310 - box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3); 311 - } 312 - 313 - .modal-header { 314 - padding: 1.5rem; 315 - border-bottom: 2px solid var(--secondary); 316 - display: flex; 317 - justify-content: space-between; 318 - align-items: center; 319 - } 320 - 321 - .modal-title { 322 - font-size: 1.5rem; 323 - font-weight: 600; 324 - color: var(--text); 325 - margin: 0; 326 - } 327 - 328 - .modal-close { 329 - background: transparent; 330 - border: none; 331 - font-size: 1.5rem; 332 - cursor: pointer; 333 - color: var(--text); 334 - padding: 0; 335 - width: 2rem; 336 - height: 2rem; 337 - display: flex; 338 - align-items: center; 339 - justify-content: center; 340 - border-radius: 4px; 341 - transition: background 0.2s; 342 - } 343 - 344 - .modal-close:hover { 345 - background: var(--secondary); 346 - } 347 - 348 - .modal-body { 349 - padding: 1.5rem; 350 - } 351 - 352 - .detail-section { 353 - margin-bottom: 2rem; 354 - } 355 - 356 - .detail-section:last-child { 357 - margin-bottom: 0; 358 - } 359 - 360 - .detail-section-title { 361 - font-size: 1.125rem; 362 - font-weight: 600; 363 - color: var(--text); 364 - margin-bottom: 1rem; 365 - padding-bottom: 0.5rem; 366 - border-bottom: 2px solid var(--secondary); 367 - } 368 - 369 - .detail-row { 370 - display: flex; 371 - justify-content: space-between; 372 - align-items: center; 373 - padding: 0.75rem 0; 374 - border-bottom: 1px solid var(--secondary); 375 - } 376 - 377 - .detail-row:last-child { 378 - border-bottom: none; 379 - } 380 - 381 - .detail-label { 382 - font-weight: 500; 383 - color: var(--text); 384 - } 385 - 386 - .detail-value { 387 - color: var(--text); 388 - opacity: 0.8; 389 - } 390 - 391 - .form-group { 392 - margin-bottom: 1rem; 393 - } 394 - 395 - .form-label { 396 - display: block; 397 - font-weight: 500; 398 - color: var(--text); 399 - margin-bottom: 0.5rem; 400 - } 401 - 402 - .form-input { 403 - width: 100%; 404 - padding: 0.5rem 0.75rem; 405 - border: 2px solid var(--secondary); 406 - border-radius: 4px; 407 - font-size: 1rem; 408 - font-family: inherit; 409 - background: var(--background); 410 - color: var(--text); 411 - } 412 - 413 - .form-input:focus { 414 - outline: none; 415 - border-color: var(--primary); 416 - } 417 - 418 - .btn { 419 - padding: 0.5rem 1rem; 420 - border: none; 421 - border-radius: 4px; 422 - font-size: 1rem; 423 - font-weight: 500; 424 - font-family: inherit; 425 - cursor: pointer; 426 - transition: all 0.2s; 427 - } 428 - 429 - .btn-primary { 430 - background: var(--primary); 431 - color: white; 432 - } 433 - 434 - .btn-primary:hover { 435 - background: var(--gunmetal); 436 - } 437 - 438 - .btn-primary:disabled { 439 - opacity: 0.5; 440 - cursor: not-allowed; 441 - } 442 - 443 - .btn-danger { 444 - background: #dc2626; 445 - color: white; 446 - } 447 - 448 - .btn-danger:hover { 449 - background: #b91c1c; 450 - } 451 - 452 - .btn-danger:disabled { 453 - opacity: 0.5; 454 - cursor: not-allowed; 455 - } 456 - 457 - .passkey-list { 458 - list-style: none; 459 - padding: 0; 460 - margin: 0; 461 - } 462 - 463 - .passkey-item { 464 - display: flex; 465 - justify-content: space-between; 466 - align-items: center; 467 - padding: 0.75rem; 468 - border: 2px solid var(--secondary); 469 - border-radius: 4px; 470 - margin-bottom: 0.5rem; 471 - } 472 - 473 - .passkey-item:last-child { 474 - margin-bottom: 0; 475 - } 476 - 477 - .passkey-info { 478 - flex: 1; 479 - } 480 - 481 - .passkey-name { 482 - font-weight: 500; 483 - color: var(--text); 484 - margin-bottom: 0.25rem; 485 - } 486 - 487 - .passkey-meta { 488 - font-size: 0.875rem; 489 - color: var(--text); 490 - opacity: 0.6; 491 - } 492 - 493 - .passkey-actions { 494 - display: flex; 495 - gap: 0.5rem; 496 - } 497 - 498 - .btn-small { 499 - padding: 0.25rem 0.75rem; 500 - font-size: 0.875rem; 501 - } 502 - 503 - .empty-passkeys { 504 - text-align: center; 505 - padding: 2rem; 506 - color: var(--text); 507 - opacity: 0.6; 508 - background: rgba(0, 0, 0, 0.02); 509 - border-radius: 4px; 510 - } 511 - 512 - .password-status { 513 - display: inline-block; 514 - padding: 0.25rem 0.75rem; 515 - border-radius: 4px; 516 - font-size: 0.875rem; 517 - font-weight: 500; 518 - } 519 - 520 - .password-status.has-password { 521 - background: #dcfce7; 522 - color: #166534; 523 - } 524 - 525 - .password-status.no-password { 526 - background: #fee2e2; 527 - color: #991b1b; 528 - } 529 - 530 285 .search { 531 286 width: 100%; 532 287 max-width: 30rem; ··· 570 325 content: '▼'; 571 326 opacity: 1; 572 327 } 573 - 574 - .session-list { 575 - list-style: none; 576 - padding: 0; 577 - margin: 0; 578 - } 579 - 580 - .session-item { 581 - display: flex; 582 - justify-content: space-between; 583 - align-items: center; 584 - padding: 0.75rem; 585 - border: 2px solid var(--secondary); 586 - border-radius: 4px; 587 - margin-bottom: 0.5rem; 588 - } 589 - 590 - .session-item:last-child { 591 - margin-bottom: 0; 592 - } 593 - 594 - .session-info { 595 - flex: 1; 596 - } 597 - 598 - .session-device { 599 - font-weight: 500; 600 - color: var(--text); 601 - margin-bottom: 0.25rem; 602 - } 603 - 604 - .session-meta { 605 - font-size: 0.875rem; 606 - color: var(--text); 607 - opacity: 0.6; 608 - } 609 - 610 - .session-actions { 611 - display: flex; 612 - gap: 0.5rem; 613 - } 614 - 615 - .empty-sessions { 616 - text-align: center; 617 - padding: 2rem; 618 - color: var(--text); 619 - opacity: 0.6; 620 - background: rgba(0, 0, 0, 0.02); 621 - border-radius: 4px; 622 - } 623 - 624 - .section-actions { 625 - display: flex; 626 - justify-content: space-between; 627 - align-items: center; 628 - margin-bottom: 1rem; 629 - } 630 328 </style> 631 329 </head> 632 330 ··· 686 384 </div> 687 385 </main> 688 386 689 - <div id="user-modal" class="modal"> 690 - <div class="modal-content"> 691 - <div class="modal-header"> 692 - <h2 class="modal-title">User Details</h2> 693 - <button class="modal-close" aria-label="Close">&times;</button> 694 - </div> 695 - <div class="modal-body"> 696 - <div class="detail-section"> 697 - <h3 class="detail-section-title">User Information</h3> 698 - <div class="detail-row"> 699 - <span class="detail-label">Email</span> 700 - <span class="detail-value" id="modal-email">-</span> 701 - </div> 702 - <div class="detail-row"> 703 - <span class="detail-label">Name</span> 704 - <span class="detail-value" id="modal-name">-</span> 705 - </div> 706 - <div class="detail-row"> 707 - <span class="detail-label">Role</span> 708 - <span class="detail-value" id="modal-role">-</span> 709 - </div> 710 - <div class="detail-row"> 711 - <span class="detail-label">Joined</span> 712 - <span class="detail-value" id="modal-joined">-</span> 713 - </div> 714 - <div class="detail-row"> 715 - <span class="detail-label">Last Login</span> 716 - <span class="detail-value" id="modal-last-login">-</span> 717 - </div> 718 - <div class="detail-row"> 719 - <span class="detail-label">Transcriptions</span> 720 - <span class="detail-value" id="modal-transcription-count">-</span> 721 - </div> 722 - <div class="detail-row"> 723 - <span class="detail-label">Password Status</span> 724 - <span id="modal-password-status">-</span> 725 - </div> 726 - </div> 727 - 728 - <div class="detail-section"> 729 - <h3 class="detail-section-title">Change Name</h3> 730 - <form id="change-name-form"> 731 - <div class="form-group"> 732 - <label class="form-label" for="new-name">New Name</label> 733 - <input type="text" id="new-name" class="form-input" placeholder="Enter new name"> 734 - </div> 735 - <button type="submit" class="btn btn-primary">Update Name</button> 736 - </form> 737 - </div> 738 - 739 - <div class="detail-section"> 740 - <h3 class="detail-section-title">Change Email</h3> 741 - <form id="change-email-form"> 742 - <div class="form-group"> 743 - <label class="form-label" for="new-email">New Email</label> 744 - <input type="email" id="new-email" class="form-input" placeholder="Enter new email"> 745 - </div> 746 - <button type="submit" class="btn btn-primary">Update Email</button> 747 - </form> 748 - </div> 749 - 750 - <div class="detail-section"> 751 - <h3 class="detail-section-title">Change Password</h3> 752 - <form id="change-password-form"> 753 - <div class="form-group"> 754 - <label class="form-label" for="new-password">New Password</label> 755 - <input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)"> 756 - </div> 757 - <button type="submit" class="btn btn-primary">Update Password</button> 758 - </form> 759 - </div> 760 - 761 - <div class="detail-section"> 762 - <h3 class="detail-section-title">Active Sessions</h3> 763 - <div class="section-actions"> 764 - <span class="detail-label" id="session-count">0 active sessions</span> 765 - <button id="logout-all-btn" class="btn btn-danger btn-small">Logout All Devices</button> 766 - </div> 767 - <div id="sessions-container"> 768 - <div class="loading">Loading sessions...</div> 769 - </div> 770 - </div> 771 - 772 - <div class="detail-section"> 773 - <h3 class="detail-section-title">Passkeys</h3> 774 - <div id="passkeys-container"> 775 - <div class="loading">Loading passkeys...</div> 776 - </div> 777 - </div> 778 - </div> 779 - </div> 780 - </div> 387 + <user-modal id="user-modal"></user-modal> 781 388 782 389 <script type="module" src="../components/auth.ts"></script> 390 + <script type="module" src="../components/user-modal.ts"></script> 783 391 <script type="module"> 784 392 const errorMessage = document.getElementById('error-message'); 785 393 const loading = document.getElementById('loading'); ··· 787 395 const transcriptionsTable = document.getElementById('transcriptions-table'); 788 396 const usersTable = document.getElementById('users-table'); 789 397 const userModal = document.getElementById('user-modal'); 790 - const modalClose = userModal.querySelector('.modal-close'); 791 398 792 399 let currentUserEmail = null; 793 - let currentModalUserId = null; 794 400 let allUsers = []; 795 401 let userSortKey = 'created_at'; 796 402 let userSortDirection = 'desc'; ··· 820 426 return date.toLocaleString(); 821 427 } 822 428 823 - function parseUserAgent(userAgent) { 824 - if (!userAgent) return '🖥️ Unknown Device'; 825 - if (userAgent.includes('iPhone')) return '📱 iPhone'; 826 - if (userAgent.includes('iPad')) return '📱 iPad'; 827 - if (userAgent.includes('Android')) return '📱 Android'; 828 - if (userAgent.includes('Mac')) return '💻 Mac'; 829 - if (userAgent.includes('Windows')) return '💻 Windows'; 830 - if (userAgent.includes('Linux')) return '💻 Linux'; 831 - return '🖥️ Unknown Device'; 832 - } 833 - 834 429 // Modal functions 835 430 function openUserModal(userId) { 836 - currentModalUserId = userId; 837 - userModal.classList.add('active'); 838 - loadUserDetails(userId); 431 + userModal.setAttribute('open', ''); 432 + userModal.userId = userId; 839 433 } 840 434 841 435 function closeUserModal() { 842 - userModal.classList.remove('active'); 843 - currentModalUserId = null; 844 - } 845 - 846 - async function loadUserDetails(userId) { 847 - try { 848 - const res = await fetch(`/api/admin/users/${userId}/details`); 849 - if (!res.ok) { 850 - throw new Error('Failed to load user details'); 851 - } 852 - 853 - const user = await res.json(); 854 - 855 - document.getElementById('modal-email').textContent = user.email; 856 - document.getElementById('modal-name').textContent = user.name || 'Not set'; 857 - document.getElementById('modal-role').textContent = user.role; 858 - document.getElementById('modal-joined').textContent = formatTimestamp(user.created_at); 859 - document.getElementById('modal-last-login').textContent = user.last_login ? formatTimestamp(user.last_login) : 'Never'; 860 - document.getElementById('modal-transcription-count').textContent = user.transcriptionCount; 861 - 862 - const passwordStatus = document.getElementById('modal-password-status'); 863 - if (user.hasPassword) { 864 - passwordStatus.innerHTML = '<span class="password-status has-password">Has password</span>'; 865 - } else { 866 - passwordStatus.innerHTML = '<span class="password-status no-password">No password (passkey only)</span>'; 867 - } 868 - 869 - document.getElementById('new-name').value = user.name || ''; 870 - document.getElementById('new-email').value = user.email; 871 - 872 - renderSessions(user.sessions, userId); 873 - renderPasskeys(user.passkeys, userId); 874 - } catch { 875 - alert('Failed to load user details'); 876 - closeUserModal(); 877 - } 878 - } 879 - 880 - function renderSessions(sessions, userId) { 881 - const container = document.getElementById('sessions-container'); 882 - const sessionCount = document.getElementById('session-count'); 883 - const logoutAllBtn = document.getElementById('logout-all-btn'); 884 - 885 - sessionCount.textContent = `${sessions.length} active session${sessions.length !== 1 ? 's' : ''}`; 886 - 887 - if (sessions.length === 0) { 888 - container.innerHTML = '<div class="empty-sessions">No active sessions</div>'; 889 - logoutAllBtn.disabled = true; 890 - return; 891 - } 892 - 893 - logoutAllBtn.disabled = false; 894 - 895 - const list = document.createElement('ul'); 896 - list.className = 'session-list'; 897 - list.innerHTML = sessions.map(s => ` 898 - <li class="session-item"> 899 - <div class="session-info"> 900 - <div class="session-device">${parseUserAgent(s.user_agent)}</div> 901 - <div class="session-meta"> 902 - IP: ${s.ip_address || 'Unknown'} • 903 - Created: ${formatTimestamp(s.created_at)} • 904 - Expires: ${formatTimestamp(s.expires_at)} 905 - </div> 906 - </div> 907 - <div class="session-actions"> 908 - <button class="btn btn-danger btn-small revoke-session-btn" data-session-id="${s.id}" data-user-id="${userId}"> 909 - Revoke 910 - </button> 911 - </div> 912 - </li> 913 - `).join(''); 914 - 915 - container.innerHTML = ''; 916 - container.appendChild(list); 917 - 918 - // Add revoke event listeners 919 - list.querySelectorAll('.revoke-session-btn').forEach(btn => { 920 - btn.addEventListener('click', async (e) => { 921 - const button = e.target; 922 - const sessionId = button.dataset.sessionId; 923 - const userId = button.dataset.userId; 924 - 925 - if (!confirm('Revoke this session? The user will be logged out of this device.')) { 926 - return; 927 - } 928 - 929 - button.disabled = true; 930 - button.textContent = 'Revoking...'; 931 - 932 - try { 933 - const res = await fetch(`/api/admin/users/${userId}/sessions/${sessionId}`, { 934 - method: 'DELETE' 935 - }); 936 - 937 - if (!res.ok) { 938 - throw new Error('Failed to revoke session'); 939 - } 940 - 941 - await loadUserDetails(userId); 942 - } catch { 943 - alert('Failed to revoke session'); 944 - button.disabled = false; 945 - button.textContent = 'Revoke'; 946 - } 947 - }); 948 - }); 436 + userModal.removeAttribute('open'); 437 + userModal.userId = null; 949 438 } 950 439 951 - function renderPasskeys(passkeys, userId) { 952 - const container = document.getElementById('passkeys-container'); 953 - 954 - if (passkeys.length === 0) { 955 - container.innerHTML = '<div class="empty-passkeys">No passkeys registered</div>'; 956 - return; 957 - } 958 - 959 - const list = document.createElement('ul'); 960 - list.className = 'passkey-list'; 961 - list.innerHTML = passkeys.map(pk => ` 962 - <li class="passkey-item"> 963 - <div class="passkey-info"> 964 - <div class="passkey-name">${pk.name || 'Unnamed Passkey'}</div> 965 - <div class="passkey-meta"> 966 - Created: ${formatTimestamp(pk.created_at)} 967 - ${pk.last_used_at ? ` • Last used: ${formatTimestamp(pk.last_used_at)}` : ''} 968 - </div> 969 - </div> 970 - <div class="passkey-actions"> 971 - <button class="btn btn-danger btn-small revoke-passkey-btn" data-passkey-id="${pk.id}" data-user-id="${userId}"> 972 - Revoke 973 - </button> 974 - </div> 975 - </li> 976 - `).join(''); 977 - 978 - container.innerHTML = ''; 979 - container.appendChild(list); 980 - 981 - list.querySelectorAll('.revoke-passkey-btn').forEach(btn => { 982 - btn.addEventListener('click', async (e) => { 983 - const button = e.target; 984 - const passkeyId = button.dataset.passkeyId; 985 - const userId = button.dataset.userId; 986 - 987 - if (!confirm('Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.')) { 988 - return; 989 - } 990 - 991 - button.disabled = true; 992 - button.textContent = 'Revoking...'; 993 - 994 - try { 995 - const res = await fetch(`/api/admin/users/${userId}/passkeys/${passkeyId}`, { 996 - method: 'DELETE' 997 - }); 998 - 999 - if (!res.ok) { 1000 - throw new Error('Failed to revoke passkey'); 1001 - } 1002 - 1003 - await loadUserDetails(userId); 1004 - } catch { 1005 - alert('Failed to revoke passkey'); 1006 - button.disabled = false; 1007 - button.textContent = 'Revoke'; 1008 - } 1009 - }); 1010 - }); 1011 - } 1012 - 1013 - modalClose.addEventListener('click', closeUserModal); 440 + // Listen for modal close and user update events 441 + userModal.addEventListener('close', closeUserModal); 442 + userModal.addEventListener('user-updated', () => loadData()); 1014 443 userModal.addEventListener('click', (e) => { 1015 444 if (e.target === userModal) { 1016 445 closeUserModal(); 1017 - } 1018 - }); 1019 - 1020 - document.getElementById('change-name-form').addEventListener('submit', async (e) => { 1021 - e.preventDefault(); 1022 - const name = document.getElementById('new-name').value.trim(); 1023 - 1024 - if (!name) { 1025 - alert('Please enter a name'); 1026 - return; 1027 - } 1028 - 1029 - const submitBtn = e.target.querySelector('button[type="submit"]'); 1030 - submitBtn.disabled = true; 1031 - submitBtn.textContent = 'Updating...'; 1032 - 1033 - try { 1034 - const res = await fetch(`/api/admin/users/${currentModalUserId}/name`, { 1035 - method: 'PUT', 1036 - headers: {'Content-Type': 'application/json'}, 1037 - body: JSON.stringify({name}) 1038 - }); 1039 - 1040 - if (!res.ok) { 1041 - throw new Error('Failed to update name'); 1042 - } 1043 - 1044 - alert('Name updated successfully'); 1045 - await loadUserDetails(currentModalUserId); 1046 - await loadData(); 1047 - } catch { 1048 - alert('Failed to update name'); 1049 - } finally { 1050 - submitBtn.disabled = false; 1051 - submitBtn.textContent = 'Update Name'; 1052 - } 1053 - }); 1054 - 1055 - document.getElementById('change-email-form').addEventListener('submit', async (e) => { 1056 - e.preventDefault(); 1057 - const email = document.getElementById('new-email').value.trim(); 1058 - 1059 - if (!email || !email.includes('@')) { 1060 - alert('Please enter a valid email'); 1061 - return; 1062 - } 1063 - 1064 - const submitBtn = e.target.querySelector('button[type="submit"]'); 1065 - submitBtn.disabled = true; 1066 - submitBtn.textContent = 'Updating...'; 1067 - 1068 - try { 1069 - const res = await fetch(`/api/admin/users/${currentModalUserId}/email`, { 1070 - method: 'PUT', 1071 - headers: {'Content-Type': 'application/json'}, 1072 - body: JSON.stringify({email}) 1073 - }); 1074 - 1075 - if (!res.ok) { 1076 - const data = await res.json(); 1077 - throw new Error(data.error || 'Failed to update email'); 1078 - } 1079 - 1080 - alert('Email updated successfully'); 1081 - await loadUserDetails(currentModalUserId); 1082 - await loadData(); 1083 - } catch (error) { 1084 - alert(error.message || 'Failed to update email'); 1085 - } finally { 1086 - submitBtn.disabled = false; 1087 - submitBtn.textContent = 'Update Email'; 1088 - } 1089 - }); 1090 - 1091 - document.getElementById('change-password-form').addEventListener('submit', async (e) => { 1092 - e.preventDefault(); 1093 - const password = document.getElementById('new-password').value; 1094 - 1095 - if (password.length < 8) { 1096 - alert('Password must be at least 8 characters'); 1097 - return; 1098 - } 1099 - 1100 - if (!confirm('Are you sure you want to change this user\'s password? This will log them out of all devices.')) { 1101 - return; 1102 - } 1103 - 1104 - const submitBtn = e.target.querySelector('button[type="submit"]'); 1105 - submitBtn.disabled = true; 1106 - submitBtn.textContent = 'Updating...'; 1107 - 1108 - try { 1109 - const res = await fetch(`/api/admin/users/${currentModalUserId}/password`, { 1110 - method: 'PUT', 1111 - headers: {'Content-Type': 'application/json'}, 1112 - body: JSON.stringify({password}) 1113 - }); 1114 - 1115 - if (!res.ok) { 1116 - throw new Error('Failed to update password'); 1117 - } 1118 - 1119 - alert('Password updated successfully. User has been logged out of all devices.'); 1120 - document.getElementById('new-password').value = ''; 1121 - await loadUserDetails(currentModalUserId); 1122 - } catch { 1123 - alert('Failed to update password'); 1124 - } finally { 1125 - submitBtn.disabled = false; 1126 - submitBtn.textContent = 'Update Password'; 1127 - } 1128 - }); 1129 - 1130 - document.getElementById('logout-all-btn').addEventListener('click', async (e) => { 1131 - if (!confirm('Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.')) { 1132 - return; 1133 - } 1134 - 1135 - const button = e.target; 1136 - button.disabled = true; 1137 - button.textContent = 'Logging out...'; 1138 - 1139 - try { 1140 - const res = await fetch(`/api/admin/users/${currentModalUserId}/sessions`, { 1141 - method: 'DELETE' 1142 - }); 1143 - 1144 - if (!res.ok) { 1145 - throw new Error('Failed to logout all devices'); 1146 - } 1147 - 1148 - alert('User logged out from all devices'); 1149 - await loadUserDetails(currentModalUserId); 1150 - } catch { 1151 - alert('Failed to logout all devices'); 1152 - } finally { 1153 - button.disabled = false; 1154 - button.textContent = 'Logout All Devices'; 1155 446 } 1156 447 }); 1157 448