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