this repo has no description
1<script lang="ts"> 2 import { getAuthState } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 const auth = getAuthState() 6 let loading = $state(true) 7 let error = $state<string | null>(null) 8 let stats = $state<{ 9 userCount: number 10 repoCount: number 11 recordCount: number 12 blobStorageBytes: number 13 } | null>(null) 14 let usersLoading = $state(false) 15 let usersError = $state<string | null>(null) 16 let users = $state<Array<{ 17 did: string 18 handle: string 19 email?: string 20 indexedAt: string 21 emailConfirmedAt?: string 22 deactivatedAt?: string 23 }>>([]) 24 let usersCursor = $state<string | undefined>(undefined) 25 let handleSearchQuery = $state('') 26 let showUsers = $state(false) 27 let invitesLoading = $state(false) 28 let invitesError = $state<string | null>(null) 29 let invites = $state<Array<{ 30 code: string 31 available: number 32 disabled: boolean 33 forAccount: string 34 createdBy: string 35 createdAt: string 36 uses: Array<{ usedBy: string; usedAt: string }> 37 }>>([]) 38 let invitesCursor = $state<string | undefined>(undefined) 39 let showInvites = $state(false) 40 let selectedUser = $state<{ 41 did: string 42 handle: string 43 email?: string 44 indexedAt: string 45 emailConfirmedAt?: string 46 invitesDisabled?: boolean 47 deactivatedAt?: string 48 } | null>(null) 49 let userDetailLoading = $state(false) 50 let userActionLoading = $state(false) 51 $effect(() => { 52 if (!auth.loading && !auth.session) { 53 navigate('/login') 54 } else if (!auth.loading && auth.session && !auth.session.isAdmin) { 55 navigate('/dashboard') 56 } 57 }) 58 $effect(() => { 59 if (auth.session?.isAdmin) { 60 loadStats() 61 } 62 }) 63 async function loadStats() { 64 if (!auth.session) return 65 loading = true 66 error = null 67 try { 68 stats = await api.getServerStats(auth.session.accessJwt) 69 } catch (e) { 70 error = e instanceof ApiError ? e.message : 'Failed to load server stats' 71 } finally { 72 loading = false 73 } 74 } 75 async function loadUsers(reset = false) { 76 if (!auth.session) return 77 usersLoading = true 78 usersError = null 79 if (reset) { 80 users = [] 81 usersCursor = undefined 82 } 83 try { 84 const result = await api.searchAccounts(auth.session.accessJwt, { 85 handle: handleSearchQuery || undefined, 86 cursor: reset ? undefined : usersCursor, 87 limit: 25, 88 }) 89 users = reset ? result.accounts : [...users, ...result.accounts] 90 usersCursor = result.cursor 91 showUsers = true 92 } catch (e) { 93 usersError = e instanceof ApiError ? e.message : 'Failed to load users' 94 } finally { 95 usersLoading = false 96 } 97 } 98 function handleSearch(e: Event) { 99 e.preventDefault() 100 loadUsers(true) 101 } 102 async function loadInvites(reset = false) { 103 if (!auth.session) return 104 invitesLoading = true 105 invitesError = null 106 if (reset) { 107 invites = [] 108 invitesCursor = undefined 109 } 110 try { 111 const result = await api.getInviteCodes(auth.session.accessJwt, { 112 cursor: reset ? undefined : invitesCursor, 113 limit: 25, 114 }) 115 invites = reset ? result.codes : [...invites, ...result.codes] 116 invitesCursor = result.cursor 117 showInvites = true 118 } catch (e) { 119 invitesError = e instanceof ApiError ? e.message : 'Failed to load invites' 120 } finally { 121 invitesLoading = false 122 } 123 } 124 async function disableInvite(code: string) { 125 if (!auth.session) return 126 if (!confirm(`Disable invite code ${code}?`)) return 127 try { 128 await api.disableInviteCodes(auth.session.accessJwt, [code]) 129 invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv) 130 } catch (e) { 131 invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite' 132 } 133 } 134 async function selectUser(did: string) { 135 if (!auth.session) return 136 userDetailLoading = true 137 try { 138 selectedUser = await api.getAccountInfo(auth.session.accessJwt, did) 139 } catch (e) { 140 usersError = e instanceof ApiError ? e.message : 'Failed to load user details' 141 } finally { 142 userDetailLoading = false 143 } 144 } 145 function closeUserDetail() { 146 selectedUser = null 147 } 148 async function toggleUserInvites() { 149 if (!auth.session || !selectedUser) return 150 userActionLoading = true 151 try { 152 if (selectedUser.invitesDisabled) { 153 await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did) 154 selectedUser = { ...selectedUser, invitesDisabled: false } 155 } else { 156 await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did) 157 selectedUser = { ...selectedUser, invitesDisabled: true } 158 } 159 } catch (e) { 160 usersError = e instanceof ApiError ? e.message : 'Failed to update user' 161 } finally { 162 userActionLoading = false 163 } 164 } 165 async function deleteUser() { 166 if (!auth.session || !selectedUser) return 167 if (!confirm(`Delete account @${selectedUser.handle}? This cannot be undone.`)) return 168 userActionLoading = true 169 try { 170 await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did) 171 users = users.filter(u => u.did !== selectedUser!.did) 172 selectedUser = null 173 } catch (e) { 174 usersError = e instanceof ApiError ? e.message : 'Failed to delete user' 175 } finally { 176 userActionLoading = false 177 } 178 } 179 function formatBytes(bytes: number): string { 180 if (bytes === 0) return '0 B' 181 const k = 1024 182 const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] 183 const i = Math.floor(Math.log(bytes) / Math.log(k)) 184 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` 185 } 186 function formatNumber(num: number): string { 187 return num.toLocaleString() 188 } 189</script> 190{#if auth.session?.isAdmin} 191 <div class="page"> 192 <header> 193 <a href="#/dashboard" class="back">&larr; Dashboard</a> 194 <h1>Admin Panel</h1> 195 </header> 196 {#if loading} 197 <p class="loading">Loading...</p> 198 {:else} 199 {#if error} 200 <div class="message error">{error}</div> 201 {/if} 202 {#if stats} 203 <section> 204 <h2>Server Statistics</h2> 205 <div class="stats-grid"> 206 <div class="stat-card"> 207 <div class="stat-value">{formatNumber(stats.userCount)}</div> 208 <div class="stat-label">Users</div> 209 </div> 210 <div class="stat-card"> 211 <div class="stat-value">{formatNumber(stats.repoCount)}</div> 212 <div class="stat-label">Repositories</div> 213 </div> 214 <div class="stat-card"> 215 <div class="stat-value">{formatNumber(stats.recordCount)}</div> 216 <div class="stat-label">Records</div> 217 </div> 218 <div class="stat-card"> 219 <div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div> 220 <div class="stat-label">Blob Storage</div> 221 </div> 222 </div> 223 <button class="refresh-btn" onclick={loadStats}>Refresh Stats</button> 224 </section> 225 {/if} 226 <section> 227 <h2>User Management</h2> 228 <form class="search-form" onsubmit={handleSearch}> 229 <input 230 type="text" 231 bind:value={handleSearchQuery} 232 placeholder="Search by handle (optional)" 233 disabled={usersLoading} 234 /> 235 <button type="submit" disabled={usersLoading}> 236 {usersLoading ? 'Loading...' : 'Search Users'} 237 </button> 238 </form> 239 {#if usersError} 240 <div class="message error">{usersError}</div> 241 {/if} 242 {#if showUsers} 243 <div class="user-list"> 244 {#if users.length === 0} 245 <p class="no-results">No users found</p> 246 {:else} 247 <table> 248 <thead> 249 <tr> 250 <th>Handle</th> 251 <th>Email</th> 252 <th>Status</th> 253 <th>Created</th> 254 </tr> 255 </thead> 256 <tbody> 257 {#each users as user} 258 <tr class="clickable" onclick={() => selectUser(user.did)}> 259 <td class="handle">@{user.handle}</td> 260 <td class="email">{user.email || '-'}</td> 261 <td> 262 {#if user.deactivatedAt} 263 <span class="badge deactivated">Deactivated</span> 264 {:else if user.emailConfirmedAt} 265 <span class="badge verified">Verified</span> 266 {:else} 267 <span class="badge unverified">Unverified</span> 268 {/if} 269 </td> 270 <td class="date">{new Date(user.indexedAt).toLocaleDateString()}</td> 271 </tr> 272 {/each} 273 </tbody> 274 </table> 275 {#if usersCursor} 276 <button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}> 277 {usersLoading ? 'Loading...' : 'Load More'} 278 </button> 279 {/if} 280 {/if} 281 </div> 282 {/if} 283 </section> 284 <section> 285 <h2>Invite Codes</h2> 286 <div class="section-actions"> 287 <button onclick={() => loadInvites(true)} disabled={invitesLoading}> 288 {invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'} 289 </button> 290 </div> 291 {#if invitesError} 292 <div class="message error">{invitesError}</div> 293 {/if} 294 {#if showInvites} 295 <div class="invite-list"> 296 {#if invites.length === 0} 297 <p class="no-results">No invite codes found</p> 298 {:else} 299 <table> 300 <thead> 301 <tr> 302 <th>Code</th> 303 <th>Available</th> 304 <th>Uses</th> 305 <th>Status</th> 306 <th>Created</th> 307 <th>Actions</th> 308 </tr> 309 </thead> 310 <tbody> 311 {#each invites as invite} 312 <tr class:disabled-row={invite.disabled}> 313 <td class="code">{invite.code}</td> 314 <td>{invite.available}</td> 315 <td>{invite.uses.length}</td> 316 <td> 317 {#if invite.disabled} 318 <span class="badge deactivated">Disabled</span> 319 {:else if invite.available === 0} 320 <span class="badge unverified">Exhausted</span> 321 {:else} 322 <span class="badge verified">Active</span> 323 {/if} 324 </td> 325 <td class="date">{new Date(invite.createdAt).toLocaleDateString()}</td> 326 <td> 327 {#if !invite.disabled} 328 <button class="action-btn danger" onclick={() => disableInvite(invite.code)}> 329 Disable 330 </button> 331 {:else} 332 <span class="muted">-</span> 333 {/if} 334 </td> 335 </tr> 336 {/each} 337 </tbody> 338 </table> 339 {#if invitesCursor} 340 <button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}> 341 {invitesLoading ? 'Loading...' : 'Load More'} 342 </button> 343 {/if} 344 {/if} 345 </div> 346 {/if} 347 </section> 348 {/if} 349 </div> 350 {#if selectedUser} 351 <div class="modal-overlay" onclick={closeUserDetail} role="presentation"> 352 <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true"> 353 <div class="modal-header"> 354 <h2>User Details</h2> 355 <button class="close-btn" onclick={closeUserDetail}>&times;</button> 356 </div> 357 {#if userDetailLoading} 358 <p class="loading">Loading...</p> 359 {:else} 360 <div class="modal-body"> 361 <dl class="user-details"> 362 <dt>Handle</dt> 363 <dd>@{selectedUser.handle}</dd> 364 <dt>DID</dt> 365 <dd class="mono">{selectedUser.did}</dd> 366 <dt>Email</dt> 367 <dd>{selectedUser.email || '-'}</dd> 368 <dt>Status</dt> 369 <dd> 370 {#if selectedUser.deactivatedAt} 371 <span class="badge deactivated">Deactivated</span> 372 {:else if selectedUser.emailConfirmedAt} 373 <span class="badge verified">Verified</span> 374 {:else} 375 <span class="badge unverified">Unverified</span> 376 {/if} 377 </dd> 378 <dt>Created</dt> 379 <dd>{new Date(selectedUser.indexedAt).toLocaleString()}</dd> 380 <dt>Invites</dt> 381 <dd> 382 {#if selectedUser.invitesDisabled} 383 <span class="badge deactivated">Disabled</span> 384 {:else} 385 <span class="badge verified">Enabled</span> 386 {/if} 387 </dd> 388 </dl> 389 <div class="modal-actions"> 390 <button 391 class="action-btn" 392 onclick={toggleUserInvites} 393 disabled={userActionLoading} 394 > 395 {selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'} 396 </button> 397 <button 398 class="action-btn danger" 399 onclick={deleteUser} 400 disabled={userActionLoading} 401 > 402 Delete Account 403 </button> 404 </div> 405 </div> 406 {/if} 407 </div> 408 </div> 409 {/if} 410{:else if auth.loading} 411 <div class="loading">Loading...</div> 412{/if} 413<style> 414 .page { 415 max-width: 800px; 416 margin: 0 auto; 417 padding: 2rem; 418 } 419 header { 420 margin-bottom: 2rem; 421 } 422 .back { 423 color: var(--text-secondary); 424 text-decoration: none; 425 font-size: 0.875rem; 426 } 427 .back:hover { 428 color: var(--accent); 429 } 430 h1 { 431 margin: 0.5rem 0 0 0; 432 } 433 .loading { 434 text-align: center; 435 color: var(--text-secondary); 436 padding: 2rem; 437 } 438 .message { 439 padding: 0.75rem; 440 border-radius: 4px; 441 margin-bottom: 1rem; 442 } 443 .message.error { 444 background: var(--error-bg); 445 border: 1px solid var(--error-border); 446 color: var(--error-text); 447 } 448 section { 449 background: var(--bg-secondary); 450 padding: 1.5rem; 451 border-radius: 8px; 452 margin-bottom: 1.5rem; 453 } 454 section h2 { 455 margin: 0 0 1rem 0; 456 font-size: 1.25rem; 457 } 458 .stats-grid { 459 display: grid; 460 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 461 gap: 1rem; 462 margin-bottom: 1rem; 463 } 464 .stat-card { 465 background: var(--bg-card); 466 border: 1px solid var(--border-color); 467 border-radius: 8px; 468 padding: 1rem; 469 text-align: center; 470 } 471 .stat-value { 472 font-size: 1.5rem; 473 font-weight: 600; 474 color: var(--accent); 475 } 476 .stat-label { 477 font-size: 0.875rem; 478 color: var(--text-secondary); 479 margin-top: 0.25rem; 480 } 481 .refresh-btn { 482 padding: 0.5rem 1rem; 483 background: transparent; 484 border: 1px solid var(--border-color); 485 border-radius: 4px; 486 cursor: pointer; 487 color: var(--text-primary); 488 } 489 .refresh-btn:hover { 490 background: var(--bg-card); 491 border-color: var(--accent); 492 } 493 .search-form { 494 display: flex; 495 gap: 0.5rem; 496 margin-bottom: 1rem; 497 } 498 .search-form input { 499 flex: 1; 500 padding: 0.5rem 0.75rem; 501 border: 1px solid var(--border-color); 502 border-radius: 4px; 503 font-size: 0.875rem; 504 background: var(--bg-input); 505 color: var(--text-primary); 506 } 507 .search-form input:focus { 508 outline: none; 509 border-color: var(--accent); 510 } 511 .search-form button { 512 padding: 0.5rem 1rem; 513 background: var(--accent); 514 color: white; 515 border: none; 516 border-radius: 4px; 517 cursor: pointer; 518 font-size: 0.875rem; 519 } 520 .search-form button:hover:not(:disabled) { 521 background: var(--accent-hover); 522 } 523 .search-form button:disabled { 524 opacity: 0.6; 525 cursor: not-allowed; 526 } 527 .user-list { 528 margin-top: 1rem; 529 } 530 .no-results { 531 color: var(--text-secondary); 532 text-align: center; 533 padding: 1rem; 534 } 535 table { 536 width: 100%; 537 border-collapse: collapse; 538 font-size: 0.875rem; 539 } 540 th, td { 541 padding: 0.75rem 0.5rem; 542 text-align: left; 543 border-bottom: 1px solid var(--border-color); 544 } 545 th { 546 font-weight: 600; 547 color: var(--text-secondary); 548 font-size: 0.75rem; 549 text-transform: uppercase; 550 letter-spacing: 0.05em; 551 } 552 .handle { 553 font-weight: 500; 554 } 555 .email { 556 color: var(--text-secondary); 557 } 558 .date { 559 color: var(--text-secondary); 560 font-size: 0.75rem; 561 } 562 .badge { 563 display: inline-block; 564 padding: 0.125rem 0.5rem; 565 border-radius: 4px; 566 font-size: 0.75rem; 567 } 568 .badge.verified { 569 background: var(--success-bg); 570 color: var(--success-text); 571 } 572 .badge.unverified { 573 background: var(--warning-bg); 574 color: var(--warning-text); 575 } 576 .badge.deactivated { 577 background: var(--error-bg); 578 color: var(--error-text); 579 } 580 .load-more { 581 display: block; 582 width: 100%; 583 padding: 0.75rem; 584 margin-top: 1rem; 585 background: transparent; 586 border: 1px solid var(--border-color); 587 border-radius: 4px; 588 cursor: pointer; 589 color: var(--text-primary); 590 font-size: 0.875rem; 591 } 592 .load-more:hover:not(:disabled) { 593 background: var(--bg-card); 594 border-color: var(--accent); 595 } 596 .load-more:disabled { 597 opacity: 0.6; 598 cursor: not-allowed; 599 } 600 .section-actions { 601 margin-bottom: 1rem; 602 } 603 .section-actions button { 604 padding: 0.5rem 1rem; 605 background: var(--accent); 606 color: white; 607 border: none; 608 border-radius: 4px; 609 cursor: pointer; 610 font-size: 0.875rem; 611 } 612 .section-actions button:hover:not(:disabled) { 613 background: var(--accent-hover); 614 } 615 .section-actions button:disabled { 616 opacity: 0.6; 617 cursor: not-allowed; 618 } 619 .invite-list { 620 margin-top: 1rem; 621 } 622 .code { 623 font-family: monospace; 624 font-size: 0.75rem; 625 } 626 .disabled-row { 627 opacity: 0.5; 628 } 629 .action-btn { 630 padding: 0.25rem 0.5rem; 631 font-size: 0.75rem; 632 border: none; 633 border-radius: 4px; 634 cursor: pointer; 635 } 636 .action-btn.danger { 637 background: var(--error-text); 638 color: white; 639 } 640 .action-btn.danger:hover { 641 background: #900; 642 } 643 .muted { 644 color: var(--text-muted); 645 } 646 .clickable { 647 cursor: pointer; 648 } 649 .clickable:hover { 650 background: var(--bg-card); 651 } 652 .modal-overlay { 653 position: fixed; 654 top: 0; 655 left: 0; 656 right: 0; 657 bottom: 0; 658 background: rgba(0, 0, 0, 0.5); 659 display: flex; 660 align-items: center; 661 justify-content: center; 662 z-index: 1000; 663 } 664 .modal { 665 background: var(--bg-card); 666 border-radius: 8px; 667 max-width: 500px; 668 width: 90%; 669 max-height: 90vh; 670 overflow-y: auto; 671 } 672 .modal-header { 673 display: flex; 674 justify-content: space-between; 675 align-items: center; 676 padding: 1rem 1.5rem; 677 border-bottom: 1px solid var(--border-color); 678 } 679 .modal-header h2 { 680 margin: 0; 681 font-size: 1.25rem; 682 } 683 .close-btn { 684 background: none; 685 border: none; 686 font-size: 1.5rem; 687 cursor: pointer; 688 color: var(--text-secondary); 689 padding: 0; 690 line-height: 1; 691 } 692 .close-btn:hover { 693 color: var(--text-primary); 694 } 695 .modal-body { 696 padding: 1.5rem; 697 } 698 .user-details { 699 display: grid; 700 grid-template-columns: auto 1fr; 701 gap: 0.5rem 1rem; 702 margin: 0 0 1.5rem 0; 703 } 704 .user-details dt { 705 font-weight: 500; 706 color: var(--text-secondary); 707 } 708 .user-details dd { 709 margin: 0; 710 } 711 .mono { 712 font-family: monospace; 713 font-size: 0.75rem; 714 word-break: break-all; 715 } 716 .modal-actions { 717 display: flex; 718 gap: 0.5rem; 719 flex-wrap: wrap; 720 } 721 .modal-actions .action-btn { 722 padding: 0.5rem 1rem; 723 border: 1px solid var(--border-color); 724 border-radius: 4px; 725 background: transparent; 726 cursor: pointer; 727 font-size: 0.875rem; 728 color: var(--text-primary); 729 } 730 .modal-actions .action-btn:hover:not(:disabled) { 731 background: var(--bg-secondary); 732 } 733 .modal-actions .action-btn:disabled { 734 opacity: 0.6; 735 cursor: not-allowed; 736 } 737 .modal-actions .action-btn.danger { 738 border-color: var(--error-text); 739 color: var(--error-text); 740 } 741 .modal-actions .action-btn.danger:hover:not(:disabled) { 742 background: var(--error-bg); 743 } 744</style>