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