馃 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3 4interface User { 5 id: number; 6 email: string; 7 name: string | null; 8 avatar: string; 9 role: "user" | "admin"; 10 transcription_count: number; 11 last_login: number | null; 12 created_at: number; 13 subscription_status: string | null; 14 subscription_id: string | null; 15} 16 17@customElement("admin-users") 18export class AdminUsers extends LitElement { 19 @state() users: User[] = []; 20 @state() searchQuery = ""; 21 @state() isLoading = true; 22 @state() error: string | null = null; 23 @state() currentUserEmail: string | null = null; 24 @state() revokingSubscriptions = new Set<number>(); 25 @state() syncingSubscriptions = new Set<number>(); 26 27 static override styles = css` 28 :host { 29 display: block; 30 } 31 32 .error-banner { 33 background: #fecaca; 34 border: 2px solid rgba(220, 38, 38, 0.8); 35 border-radius: 6px; 36 padding: 1rem; 37 margin-bottom: 1.5rem; 38 color: #dc2626; 39 font-weight: 500; 40 } 41 42 .search-box { 43 width: 100%; 44 max-width: 30rem; 45 margin-bottom: 1.5rem; 46 padding: 0.75rem 1rem; 47 border: 2px solid var(--secondary); 48 border-radius: 4px; 49 font-size: 1rem; 50 background: var(--background); 51 color: var(--text); 52 } 53 54 .search-box:focus { 55 outline: none; 56 border-color: var(--primary); 57 } 58 59 .loading, 60 .empty-state { 61 text-align: center; 62 padding: 3rem; 63 color: var(--paynes-gray); 64 } 65 66 .error { 67 background: color-mix(in srgb, red 10%, transparent); 68 border: 1px solid red; 69 color: red; 70 padding: 1rem; 71 border-radius: 4px; 72 margin-bottom: 1rem; 73 } 74 75 .users-grid { 76 display: grid; 77 gap: 1rem; 78 } 79 80 .user-card { 81 background: var(--background); 82 border: 2px solid var(--secondary); 83 border-radius: 8px; 84 padding: 1.5rem; 85 cursor: pointer; 86 transition: border-color 0.2s; 87 } 88 89 .user-card:hover { 90 border-color: var(--primary); 91 } 92 93 .user-card.system { 94 cursor: default; 95 opacity: 0.8; 96 } 97 98 .user-card.system:hover { 99 border-color: var(--secondary); 100 } 101 102 .card-header { 103 display: flex; 104 justify-content: space-between; 105 align-items: flex-start; 106 margin-bottom: 1rem; 107 } 108 109 .user-info { 110 display: flex; 111 align-items: center; 112 gap: 1rem; 113 } 114 115 .user-avatar { 116 width: 3rem; 117 height: 3rem; 118 border-radius: 50%; 119 } 120 121 .user-details { 122 flex: 1; 123 } 124 125 .user-name { 126 font-size: 1.125rem; 127 font-weight: 600; 128 color: var(--text); 129 margin-bottom: 0.25rem; 130 } 131 132 .user-email { 133 font-size: 0.875rem; 134 color: var(--paynes-gray); 135 } 136 137 .admin-badge { 138 background: var(--accent); 139 color: var(--white); 140 padding: 0.5rem 1rem; 141 border-radius: 4px; 142 font-size: 0.75rem; 143 font-weight: 600; 144 text-transform: uppercase; 145 } 146 147 .system-badge { 148 background: var(--paynes-gray); 149 color: var(--white); 150 padding: 0.5rem 1rem; 151 border-radius: 4px; 152 font-size: 0.75rem; 153 font-weight: 600; 154 text-transform: uppercase; 155 } 156 157 .meta-row { 158 display: flex; 159 gap: 2rem; 160 flex-wrap: wrap; 161 margin-bottom: 1rem; 162 } 163 164 .meta-item { 165 display: flex; 166 flex-direction: column; 167 gap: 0.25rem; 168 } 169 170 .meta-label { 171 font-size: 0.75rem; 172 font-weight: 600; 173 text-transform: uppercase; 174 color: var(--paynes-gray); 175 letter-spacing: 0.05em; 176 } 177 178 .meta-value { 179 font-size: 0.875rem; 180 color: var(--text); 181 } 182 183 .timestamp { 184 color: var(--paynes-gray); 185 font-size: 0.875rem; 186 } 187 188 .actions { 189 display: flex; 190 gap: 0.75rem; 191 align-items: center; 192 flex-wrap: wrap; 193 } 194 195 .role-select { 196 padding: 0.5rem 0.75rem; 197 border: 2px solid var(--secondary); 198 border-radius: 4px; 199 font-size: 0.875rem; 200 background: var(--background); 201 color: var(--text); 202 cursor: pointer; 203 font-weight: 600; 204 } 205 206 .role-select:focus { 207 outline: none; 208 border-color: var(--primary); 209 } 210 211 .delete-btn { 212 background: transparent; 213 border: 2px solid #dc2626; 214 color: #dc2626; 215 padding: 0.5rem 1rem; 216 border-radius: 4px; 217 cursor: pointer; 218 font-size: 0.875rem; 219 font-weight: 600; 220 transition: all 0.2s; 221 } 222 223 .delete-btn:hover:not(:disabled) { 224 background: #dc2626; 225 color: var(--white); 226 } 227 228 .delete-btn:disabled { 229 opacity: 0.5; 230 cursor: not-allowed; 231 } 232 233 .revoke-btn { 234 background: transparent; 235 border: 2px solid var(--accent); 236 color: var(--accent); 237 padding: 0.5rem 1rem; 238 border-radius: 4px; 239 cursor: pointer; 240 font-size: 0.875rem; 241 font-weight: 600; 242 transition: all 0.2s; 243 } 244 245 .revoke-btn:hover:not(:disabled) { 246 background: var(--accent); 247 color: var(--white); 248 } 249 250 .revoke-btn:disabled { 251 opacity: 0.5; 252 cursor: not-allowed; 253 } 254 255 .sync-btn { 256 background: transparent; 257 border: 2px solid var(--primary); 258 color: var(--primary); 259 padding: 0.5rem 1rem; 260 border-radius: 4px; 261 cursor: pointer; 262 font-size: 0.875rem; 263 font-weight: 600; 264 transition: all 0.2s; 265 } 266 267 .sync-btn:hover:not(:disabled) { 268 background: var(--primary); 269 color: var(--white); 270 } 271 272 .sync-btn:disabled { 273 opacity: 0.5; 274 cursor: not-allowed; 275 } 276 277 .subscription-badge { 278 background: var(--primary); 279 color: var(--white); 280 padding: 0.25rem 0.5rem; 281 border-radius: 4px; 282 font-size: 0.75rem; 283 font-weight: 600; 284 text-transform: uppercase; 285 } 286 287 .subscription-badge.active { 288 background: var(--primary); 289 color: var(--white); 290 } 291 292 .subscription-badge.none { 293 background: var(--secondary); 294 color: var(--paynes-gray); 295 } 296 `; 297 298 override async connectedCallback() { 299 super.connectedCallback(); 300 await this.getCurrentUser(); 301 await this.loadUsers(); 302 } 303 304 private async getCurrentUser() { 305 try { 306 const response = await fetch("/api/auth/me"); 307 if (response.ok) { 308 const user = await response.json(); 309 this.currentUserEmail = user.email; 310 } 311 } catch { 312 // Silent fail 313 } 314 } 315 316 private async loadUsers() { 317 this.isLoading = true; 318 this.error = null; 319 320 try { 321 const response = await fetch("/api/admin/users"); 322 if (!response.ok) { 323 const data = await response.json(); 324 throw new Error(data.error || "Failed to load users"); 325 } 326 327 this.users = await response.json(); 328 } catch (err) { 329 this.error = 330 err instanceof Error 331 ? err.message 332 : "Failed to load users. Please try again."; 333 } finally { 334 this.isLoading = false; 335 } 336 } 337 338 private async handleRoleChange( 339 userId: number, 340 email: string, 341 newRole: "user" | "admin", 342 oldRole: "user" | "admin", 343 event: Event, 344 ) { 345 const select = event.target as HTMLSelectElement; 346 347 const isDemotingSelf = 348 email === this.currentUserEmail && 349 oldRole === "admin" && 350 newRole === "user"; 351 352 if (isDemotingSelf) { 353 if ( 354 !confirm( 355 "鈿狅笍 WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?", 356 ) 357 ) { 358 select.value = oldRole; 359 return; 360 } 361 362 if ( 363 !confirm( 364 "鈿狅笍 FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?", 365 ) 366 ) { 367 select.value = oldRole; 368 return; 369 } 370 } else { 371 if (!confirm(`Change user role to ${newRole}?`)) { 372 select.value = oldRole; 373 return; 374 } 375 } 376 377 try { 378 const response = await fetch(`/api/admin/users/${userId}/role`, { 379 method: "PUT", 380 headers: { "Content-Type": "application/json" }, 381 body: JSON.stringify({ role: newRole }), 382 }); 383 384 if (!response.ok) { 385 const data = await response.json(); 386 throw new Error(data.error || "Failed to update role"); 387 } 388 389 if (isDemotingSelf) { 390 window.location.href = "/"; 391 } else { 392 await this.loadUsers(); 393 } 394 } catch (err) { 395 this.error = 396 err instanceof Error ? err.message : "Failed to update user role"; 397 select.value = oldRole; 398 } 399 } 400 401 @state() deleteState: { 402 id: number; 403 type: "user" | "revoke"; 404 clicks: number; 405 timeout: number | null; 406 } | null = null; 407 408 private handleDeleteClick(userId: number, event: Event) { 409 event.stopPropagation(); 410 411 // If this is a different item or timeout expired, reset 412 if ( 413 !this.deleteState || 414 this.deleteState.id !== userId || 415 this.deleteState.type !== "user" 416 ) { 417 // Clear any existing timeout 418 if (this.deleteState?.timeout) { 419 clearTimeout(this.deleteState.timeout); 420 } 421 422 // Set first click 423 const timeout = window.setTimeout(() => { 424 this.deleteState = null; 425 }, 1000); 426 427 this.deleteState = { id: userId, type: "user", clicks: 1, timeout }; 428 return; 429 } 430 431 // Increment clicks 432 const newClicks = this.deleteState.clicks + 1; 433 434 // Clear existing timeout 435 if (this.deleteState.timeout) { 436 clearTimeout(this.deleteState.timeout); 437 } 438 439 // Third click - actually delete 440 if (newClicks === 3) { 441 this.deleteState = null; 442 this.performDeleteUser(userId); 443 return; 444 } 445 446 // Second click - reset timeout 447 const timeout = window.setTimeout(() => { 448 this.deleteState = null; 449 }, 1000); 450 451 this.deleteState = { id: userId, type: "user", clicks: newClicks, timeout }; 452 } 453 454 private async performDeleteUser(userId: number) { 455 this.error = null; 456 try { 457 const response = await fetch(`/api/admin/users/${userId}`, { 458 method: "DELETE", 459 }); 460 461 if (!response.ok) { 462 const data = await response.json(); 463 throw new Error(data.error || "Failed to delete user"); 464 } 465 466 // Remove user from local array instead of reloading 467 this.users = this.users.filter((u) => u.id !== userId); 468 this.dispatchEvent(new CustomEvent("user-deleted")); 469 } catch (err) { 470 this.error = 471 err instanceof Error 472 ? err.message 473 : "Failed to delete user. Please try again."; 474 } 475 } 476 477 private handleRevokeClick( 478 userId: number, 479 email: string, 480 subscriptionId: string, 481 event: Event, 482 ) { 483 event.stopPropagation(); 484 485 // If this is a different item or timeout expired, reset 486 if ( 487 !this.deleteState || 488 this.deleteState.id !== userId || 489 this.deleteState.type !== "revoke" 490 ) { 491 // Clear any existing timeout 492 if (this.deleteState?.timeout) { 493 clearTimeout(this.deleteState.timeout); 494 } 495 496 // Set first click 497 const timeout = window.setTimeout(() => { 498 this.deleteState = null; 499 }, 1000); 500 501 this.deleteState = { id: userId, type: "revoke", clicks: 1, timeout }; 502 return; 503 } 504 505 // Increment clicks 506 const newClicks = this.deleteState.clicks + 1; 507 508 // Clear existing timeout 509 if (this.deleteState.timeout) { 510 clearTimeout(this.deleteState.timeout); 511 } 512 513 // Third click - actually revoke 514 if (newClicks === 3) { 515 this.deleteState = null; 516 this.performRevokeSubscription(userId, email, subscriptionId); 517 return; 518 } 519 520 // Second click - reset timeout 521 const timeout = window.setTimeout(() => { 522 this.deleteState = null; 523 }, 1000); 524 525 this.deleteState = { 526 id: userId, 527 type: "revoke", 528 clicks: newClicks, 529 timeout, 530 }; 531 } 532 533 private async performRevokeSubscription( 534 userId: number, 535 _email: string, 536 subscriptionId: string, 537 ) { 538 this.revokingSubscriptions.add(userId); 539 this.requestUpdate(); 540 this.error = null; 541 542 try { 543 const response = await fetch(`/api/admin/users/${userId}/subscription`, { 544 method: "DELETE", 545 headers: { "Content-Type": "application/json" }, 546 body: JSON.stringify({ subscriptionId }), 547 }); 548 549 if (!response.ok) { 550 const data = await response.json(); 551 throw new Error(data.error || "Failed to revoke subscription"); 552 } 553 554 await this.loadUsers(); 555 } catch (err) { 556 this.error = 557 err instanceof Error ? err.message : "Failed to revoke subscription"; 558 this.revokingSubscriptions.delete(userId); 559 } 560 } 561 562 private async handleSyncSubscription(userId: number, event: Event) { 563 event.stopPropagation(); 564 565 this.syncingSubscriptions.add(userId); 566 this.requestUpdate(); 567 this.error = null; 568 569 try { 570 const response = await fetch(`/api/admin/users/${userId}/subscription`, { 571 method: "PUT", 572 headers: { "Content-Type": "application/json" }, 573 }); 574 575 if (!response.ok) { 576 const data = await response.json(); 577 // Don't show error if there's just no subscription 578 if (response.status !== 404) { 579 this.error = data.error || "Failed to sync subscription"; 580 } 581 return; 582 } 583 584 await this.loadUsers(); 585 } finally { 586 this.syncingSubscriptions.delete(userId); 587 this.requestUpdate(); 588 } 589 } 590 591 private getDeleteButtonText(userId: number, type: "user" | "revoke"): string { 592 if ( 593 !this.deleteState || 594 this.deleteState.id !== userId || 595 this.deleteState.type !== type 596 ) { 597 return type === "user" ? "Delete User" : "Revoke Subscription"; 598 } 599 600 if (this.deleteState.clicks === 1) { 601 return "Are you sure?"; 602 } 603 604 if (this.deleteState.clicks === 2) { 605 return "Final warning!"; 606 } 607 608 return type === "user" ? "Delete User" : "Revoke Subscription"; 609 } 610 611 private handleCardClick(userId: number, event: Event) { 612 // Don't open modal for ghost user 613 if (userId === 0) { 614 return; 615 } 616 617 // Don't open modal if clicking on delete button, revoke button, sync button, or role select 618 if ( 619 (event.target as HTMLElement).closest(".delete-btn") || 620 (event.target as HTMLElement).closest(".revoke-btn") || 621 (event.target as HTMLElement).closest(".sync-btn") || 622 (event.target as HTMLElement).closest(".role-select") 623 ) { 624 return; 625 } 626 this.dispatchEvent( 627 new CustomEvent("open-user", { 628 detail: { id: userId }, 629 }), 630 ); 631 } 632 633 private formatTimestamp(timestamp: number | null): string { 634 if (!timestamp) return "Never"; 635 const date = new Date(timestamp * 1000); 636 return date.toLocaleString(); 637 } 638 639 private get filteredUsers() { 640 const query = this.searchQuery.toLowerCase(); 641 642 // Filter users based on search query 643 let filtered = this.users.filter( 644 (u) => 645 u.email.toLowerCase().includes(query) || 646 u.name?.toLowerCase().includes(query), 647 ); 648 649 // Hide ghost user unless specifically searched for 650 if ( 651 !query.includes("deleted") && 652 !query.includes("ghost") && 653 !query.includes("system") 654 ) { 655 filtered = filtered.filter((u) => u.id !== 0); 656 } 657 658 return filtered; 659 } 660 661 override render() { 662 if (this.isLoading) { 663 return html`<div class="loading">Loading users...</div>`; 664 } 665 666 if (this.error) { 667 return html` 668 <div class="error-banner">${this.error}</div> 669 <button @click=${this.loadUsers}>Retry</button> 670 `; 671 } 672 673 const filtered = this.filteredUsers; 674 675 return html` 676 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 677 678 <input 679 type="text" 680 class="search-box" 681 placeholder="Search by name or email..." 682 .value=${this.searchQuery} 683 @input=${(e: Event) => { 684 this.searchQuery = (e.target as HTMLInputElement).value; 685 }} 686 /> 687 688 ${ 689 filtered.length === 0 690 ? html`<div class="empty-state">No users found</div>` 691 : html` 692 <div class="users-grid"> 693 ${filtered.map( 694 (u) => html` 695 <div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}> 696 <div class="card-header"> 697 <div class="user-info"> 698 <img 699 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 700 alt="Avatar" 701 class="user-avatar" 702 /> 703 <div class="user-details"> 704 <div class="user-name">${u.name || "Anonymous"}</div> 705 <div class="user-email">${u.email}</div> 706 </div> 707 </div> 708 ${ 709 u.id === 0 710 ? html`<span class="system-badge">System</span>` 711 : u.role === "admin" 712 ? html`<span class="admin-badge">Admin</span>` 713 : "" 714 } 715 </div> 716 717 <div class="meta-row"> 718 <div class="meta-item"> 719 <div class="meta-label">Transcriptions</div> 720 <div class="meta-value">${u.transcription_count}</div> 721 </div> 722 <div class="meta-item"> 723 <div class="meta-label">Subscription</div> 724 <div class="meta-value"> 725 ${ 726 u.subscription_status 727 ? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>` 728 : html`<span class="subscription-badge none">None</span>` 729 } 730 </div> 731 </div> 732 <div class="meta-item"> 733 <div class="meta-label">Last Login</div> 734 <div class="meta-value timestamp"> 735 ${this.formatTimestamp(u.last_login)} 736 </div> 737 </div> 738 <div class="meta-item"> 739 <div class="meta-label">Joined</div> 740 <div class="meta-value timestamp"> 741 ${this.formatTimestamp(u.created_at)} 742 </div> 743 </div> 744 </div> 745 746 <div class="actions"> 747 ${ 748 u.id === 0 749 ? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>` 750 : html` 751 <select 752 class="role-select" 753 .value=${u.role} 754 @change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)} 755 > 756 <option value="user">User</option> 757 <option value="admin">Admin</option> 758 </select> 759 <button 760 class="sync-btn" 761 ?disabled=${this.syncingSubscriptions.has(u.id)} 762 @click=${(e: Event) => this.handleSyncSubscription(u.id, e)} 763 title="Sync subscription status from Polar" 764 > 765 ${this.syncingSubscriptions.has(u.id) ? "Syncing..." : "馃攧 Sync"} 766 </button> 767 <button 768 class="revoke-btn" 769 ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)} 770 @click=${(e: Event) => { 771 if (u.subscription_id) { 772 this.handleRevokeClick( 773 u.id, 774 u.email, 775 u.subscription_id, 776 e, 777 ); 778 } 779 }} 780 > 781 ${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")} 782 </button> 783 <button class="delete-btn" @click=${(e: Event) => this.handleDeleteClick(u.id, e)}> 784 ${this.getDeleteButtonText(u.id, "user")} 785 </button> 786 ` 787 } 788 </div> 789 </div> 790 `, 791 )} 792 </div> 793 ` 794 } 795 `; 796 } 797}