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