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