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