馃 distributed transcription service thistle.dunkirk.sh
at main 20 kB view raw
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 const result = await response.json(); 328 this.users = result.data || result; 329 } catch (err) { 330 this.error = 331 err instanceof Error 332 ? err.message 333 : "Failed to load users. Please try again."; 334 } finally { 335 this.isLoading = false; 336 } 337 } 338 339 private async handleRoleChange( 340 userId: number, 341 email: string, 342 newRole: "user" | "admin", 343 oldRole: "user" | "admin", 344 event: Event, 345 ) { 346 const select = event.target as HTMLSelectElement; 347 348 const isDemotingSelf = 349 email === this.currentUserEmail && 350 oldRole === "admin" && 351 newRole === "user"; 352 353 if (isDemotingSelf) { 354 if ( 355 !confirm( 356 "鈿狅笍 WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?", 357 ) 358 ) { 359 select.value = oldRole; 360 return; 361 } 362 363 if ( 364 !confirm( 365 "鈿狅笍 FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?", 366 ) 367 ) { 368 select.value = oldRole; 369 return; 370 } 371 } else { 372 if (!confirm(`Change user role to ${newRole}?`)) { 373 select.value = oldRole; 374 return; 375 } 376 } 377 378 try { 379 const response = await fetch(`/api/admin/users/${userId}/role`, { 380 method: "PUT", 381 headers: { "Content-Type": "application/json" }, 382 body: JSON.stringify({ role: newRole }), 383 }); 384 385 if (!response.ok) { 386 const data = await response.json(); 387 throw new Error(data.error || "Failed to update role"); 388 } 389 390 if (isDemotingSelf) { 391 window.location.href = "/"; 392 } else { 393 await this.loadUsers(); 394 } 395 } catch (err) { 396 this.error = 397 err instanceof Error ? err.message : "Failed to update user role"; 398 select.value = oldRole; 399 } 400 } 401 402 @state() deleteState: { 403 id: number; 404 type: "user" | "revoke"; 405 clicks: number; 406 timeout: number | null; 407 } | null = null; 408 409 private handleDeleteClick(userId: number, event: Event) { 410 event.stopPropagation(); 411 412 // If this is a different item or timeout expired, reset 413 if ( 414 !this.deleteState || 415 this.deleteState.id !== userId || 416 this.deleteState.type !== "user" 417 ) { 418 // Clear any existing timeout 419 if (this.deleteState?.timeout) { 420 clearTimeout(this.deleteState.timeout); 421 } 422 423 // Set first click 424 const timeout = window.setTimeout(() => { 425 this.deleteState = null; 426 }, 1000); 427 428 this.deleteState = { id: userId, type: "user", clicks: 1, timeout }; 429 return; 430 } 431 432 // Increment clicks 433 const newClicks = this.deleteState.clicks + 1; 434 435 // Clear existing timeout 436 if (this.deleteState.timeout) { 437 clearTimeout(this.deleteState.timeout); 438 } 439 440 // Third click - actually delete 441 if (newClicks === 3) { 442 this.deleteState = null; 443 this.performDeleteUser(userId); 444 return; 445 } 446 447 // Second click - reset timeout 448 const timeout = window.setTimeout(() => { 449 this.deleteState = null; 450 }, 1000); 451 452 this.deleteState = { id: userId, type: "user", clicks: newClicks, timeout }; 453 } 454 455 private async performDeleteUser(userId: number) { 456 this.error = null; 457 try { 458 const response = await fetch(`/api/admin/users/${userId}`, { 459 method: "DELETE", 460 }); 461 462 if (!response.ok) { 463 const data = await response.json(); 464 throw new Error(data.error || "Failed to delete user"); 465 } 466 467 // Remove user from local array instead of reloading 468 this.users = this.users.filter((u) => u.id !== userId); 469 this.dispatchEvent(new CustomEvent("user-deleted")); 470 } catch (err) { 471 this.error = 472 err instanceof Error 473 ? err.message 474 : "Failed to delete user. Please try again."; 475 } 476 } 477 478 private handleRevokeClick( 479 userId: number, 480 email: string, 481 subscriptionId: string, 482 event: Event, 483 ) { 484 event.stopPropagation(); 485 486 // If this is a different item or timeout expired, reset 487 if ( 488 !this.deleteState || 489 this.deleteState.id !== userId || 490 this.deleteState.type !== "revoke" 491 ) { 492 // Clear any existing timeout 493 if (this.deleteState?.timeout) { 494 clearTimeout(this.deleteState.timeout); 495 } 496 497 // Set first click 498 const timeout = window.setTimeout(() => { 499 this.deleteState = null; 500 }, 1000); 501 502 this.deleteState = { id: userId, type: "revoke", clicks: 1, timeout }; 503 return; 504 } 505 506 // Increment clicks 507 const newClicks = this.deleteState.clicks + 1; 508 509 // Clear existing timeout 510 if (this.deleteState.timeout) { 511 clearTimeout(this.deleteState.timeout); 512 } 513 514 // Third click - actually revoke 515 if (newClicks === 3) { 516 this.deleteState = null; 517 this.performRevokeSubscription(userId, email, subscriptionId); 518 return; 519 } 520 521 // Second click - reset timeout 522 const timeout = window.setTimeout(() => { 523 this.deleteState = null; 524 }, 1000); 525 526 this.deleteState = { 527 id: userId, 528 type: "revoke", 529 clicks: newClicks, 530 timeout, 531 }; 532 } 533 534 private async performRevokeSubscription( 535 userId: number, 536 _email: string, 537 subscriptionId: string, 538 ) { 539 this.revokingSubscriptions.add(userId); 540 this.requestUpdate(); 541 this.error = null; 542 543 try { 544 const response = await fetch(`/api/admin/users/${userId}/subscription`, { 545 method: "DELETE", 546 headers: { "Content-Type": "application/json" }, 547 body: JSON.stringify({ subscriptionId }), 548 }); 549 550 if (!response.ok) { 551 const data = await response.json(); 552 throw new Error(data.error || "Failed to revoke subscription"); 553 } 554 555 await this.loadUsers(); 556 } catch (err) { 557 this.error = 558 err instanceof Error ? err.message : "Failed to revoke subscription"; 559 this.revokingSubscriptions.delete(userId); 560 } 561 } 562 563 private async handleSyncSubscription(userId: number, event: Event) { 564 event.stopPropagation(); 565 566 this.syncingSubscriptions.add(userId); 567 this.requestUpdate(); 568 this.error = null; 569 570 try { 571 const response = await fetch(`/api/admin/users/${userId}/subscription`, { 572 method: "PUT", 573 headers: { "Content-Type": "application/json" }, 574 }); 575 576 if (!response.ok) { 577 const data = await response.json(); 578 // Don't show error if there's just no subscription 579 if (response.status !== 404) { 580 this.error = data.error || "Failed to sync subscription"; 581 } 582 return; 583 } 584 585 await this.loadUsers(); 586 } finally { 587 this.syncingSubscriptions.delete(userId); 588 this.requestUpdate(); 589 } 590 } 591 592 private getDeleteButtonText(userId: number, type: "user" | "revoke"): string { 593 if ( 594 !this.deleteState || 595 this.deleteState.id !== userId || 596 this.deleteState.type !== type 597 ) { 598 return type === "user" ? "Delete User" : "Revoke Subscription"; 599 } 600 601 if (this.deleteState.clicks === 1) { 602 return "Are you sure?"; 603 } 604 605 if (this.deleteState.clicks === 2) { 606 return "Final warning!"; 607 } 608 609 return type === "user" ? "Delete User" : "Revoke Subscription"; 610 } 611 612 private handleCardClick(userId: number, event: Event) { 613 // Don't open modal for ghost user 614 if (userId === 0) { 615 return; 616 } 617 618 // Don't open modal if clicking on delete button, revoke button, sync button, or role select 619 if ( 620 (event.target as HTMLElement).closest(".delete-btn") || 621 (event.target as HTMLElement).closest(".revoke-btn") || 622 (event.target as HTMLElement).closest(".sync-btn") || 623 (event.target as HTMLElement).closest(".role-select") 624 ) { 625 return; 626 } 627 this.dispatchEvent( 628 new CustomEvent("open-user", { 629 detail: { id: userId }, 630 }), 631 ); 632 } 633 634 private formatTimestamp(timestamp: number | null): string { 635 if (!timestamp) return "Never"; 636 const date = new Date(timestamp * 1000); 637 return date.toLocaleString(); 638 } 639 640 private get filteredUsers() { 641 const query = this.searchQuery.toLowerCase(); 642 643 // Filter users based on search query 644 let filtered = this.users.filter( 645 (u) => 646 u.email.toLowerCase().includes(query) || 647 u.name?.toLowerCase().includes(query), 648 ); 649 650 // Hide ghost user unless specifically searched for 651 if ( 652 !query.includes("deleted") && 653 !query.includes("ghost") && 654 !query.includes("system") 655 ) { 656 filtered = filtered.filter((u) => u.id !== 0); 657 } 658 659 return filtered; 660 } 661 662 override render() { 663 if (this.isLoading) { 664 return html`<div class="loading">Loading users...</div>`; 665 } 666 667 if (this.error) { 668 return html` 669 <div class="error-banner">${this.error}</div> 670 <button @click=${this.loadUsers}>Retry</button> 671 `; 672 } 673 674 const filtered = this.filteredUsers; 675 676 return html` 677 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 678 679 <input 680 type="text" 681 class="search-box" 682 placeholder="Search by name or email..." 683 .value=${this.searchQuery} 684 @input=${(e: Event) => { 685 this.searchQuery = (e.target as HTMLInputElement).value; 686 }} 687 /> 688 689 ${ 690 filtered.length === 0 691 ? html`<div class="empty-state">No users found</div>` 692 : html` 693 <div class="users-grid"> 694 ${filtered.map( 695 (u) => html` 696 <div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}> 697 <div class="card-header"> 698 <div class="user-info"> 699 <img 700 src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 701 alt="Avatar" 702 class="user-avatar" 703 /> 704 <div class="user-details"> 705 <div class="user-name">${u.name || "Anonymous"}</div> 706 <div class="user-email">${u.email}</div> 707 </div> 708 </div> 709 ${ 710 u.id === 0 711 ? html`<span class="system-badge">System</span>` 712 : u.role === "admin" 713 ? html`<span class="admin-badge">Admin</span>` 714 : "" 715 } 716 </div> 717 718 <div class="meta-row"> 719 <div class="meta-item"> 720 <div class="meta-label">Transcriptions</div> 721 <div class="meta-value">${u.transcription_count}</div> 722 </div> 723 <div class="meta-item"> 724 <div class="meta-label">Subscription</div> 725 <div class="meta-value"> 726 ${ 727 u.subscription_status 728 ? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>` 729 : html`<span class="subscription-badge none">None</span>` 730 } 731 </div> 732 </div> 733 <div class="meta-item"> 734 <div class="meta-label">Last Login</div> 735 <div class="meta-value timestamp"> 736 ${this.formatTimestamp(u.last_login)} 737 </div> 738 </div> 739 <div class="meta-item"> 740 <div class="meta-label">Joined</div> 741 <div class="meta-value timestamp"> 742 ${this.formatTimestamp(u.created_at)} 743 </div> 744 </div> 745 </div> 746 747 <div class="actions"> 748 ${ 749 u.id === 0 750 ? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>` 751 : html` 752 <select 753 class="role-select" 754 .value=${u.role} 755 @change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)} 756 > 757 <option value="user">User</option> 758 <option value="admin">Admin</option> 759 </select> 760 <button 761 class="sync-btn" 762 ?disabled=${this.syncingSubscriptions.has(u.id)} 763 @click=${(e: Event) => this.handleSyncSubscription(u.id, e)} 764 title="Sync subscription status from Polar" 765 > 766 ${this.syncingSubscriptions.has(u.id) ? "Syncing..." : "馃攧 Sync"} 767 </button> 768 <button 769 class="revoke-btn" 770 ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)} 771 @click=${(e: Event) => { 772 if (u.subscription_id) { 773 this.handleRevokeClick( 774 u.id, 775 u.email, 776 u.subscription_id, 777 e, 778 ); 779 } 780 }} 781 > 782 ${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")} 783 </button> 784 <button class="delete-btn" @click=${(e: Event) => this.handleDeleteClick(u.id, e)}> 785 ${this.getDeleteButtonText(u.id, "user")} 786 </button> 787 ` 788 } 789 </div> 790 </div> 791 `, 792 )} 793 </div> 794 ` 795 } 796 `; 797 } 798}