this repo has no description
1<script lang="ts"> 2 import { getAuthState } from '../lib/auth.svelte' 3 import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte' 4 import { navigate } from '../lib/router.svelte' 5 import { api, ApiError } from '../lib/api' 6 import { _ } from '../lib/i18n' 7 import { formatDate, formatDateTime } from '../lib/date' 8 const auth = getAuthState() 9 let loading = $state(true) 10 let error = $state<string | null>(null) 11 let stats = $state<{ 12 userCount: number 13 repoCount: number 14 recordCount: number 15 blobStorageBytes: number 16 } | null>(null) 17 let usersLoading = $state(false) 18 let usersError = $state<string | null>(null) 19 let users = $state<Array<{ 20 did: string 21 handle: string 22 email?: string 23 indexedAt: string 24 emailConfirmedAt?: string 25 deactivatedAt?: string 26 }>>([]) 27 let usersCursor = $state<string | undefined>(undefined) 28 let handleSearchQuery = $state('') 29 let showUsers = $state(false) 30 let invitesLoading = $state(false) 31 let invitesError = $state<string | null>(null) 32 let invites = $state<Array<{ 33 code: string 34 available: number 35 disabled: boolean 36 forAccount: string 37 createdBy: string 38 createdAt: string 39 uses: Array<{ usedBy: string; usedAt: string }> 40 }>>([]) 41 let invitesCursor = $state<string | undefined>(undefined) 42 let showInvites = $state(false) 43 let selectedUser = $state<{ 44 did: string 45 handle: string 46 email?: string 47 indexedAt: string 48 emailConfirmedAt?: string 49 invitesDisabled?: boolean 50 deactivatedAt?: string 51 } | null>(null) 52 let userDetailLoading = $state(false) 53 let userActionLoading = $state(false) 54 let serverName = $state('') 55 let serverNameInput = $state('') 56 let primaryColor = $state('') 57 let primaryColorInput = $state('') 58 let primaryColorDark = $state('') 59 let primaryColorDarkInput = $state('') 60 let secondaryColor = $state('') 61 let secondaryColorInput = $state('') 62 let secondaryColorDark = $state('') 63 let secondaryColorDarkInput = $state('') 64 let logoCid = $state<string | null>(null) 65 let originalLogoCid = $state<string | null>(null) 66 let logoFile = $state<File | null>(null) 67 let logoPreview = $state<string | null>(null) 68 let serverConfigLoading = $state(false) 69 let serverConfigError = $state<string | null>(null) 70 let serverConfigSuccess = $state(false) 71 $effect(() => { 72 if (!auth.loading && !auth.session) { 73 navigate('/login') 74 } else if (!auth.loading && auth.session && !auth.session.isAdmin) { 75 navigate('/dashboard') 76 } 77 }) 78 $effect(() => { 79 if (auth.session?.isAdmin) { 80 loadStats() 81 loadServerConfig() 82 } 83 }) 84 async function loadServerConfig() { 85 try { 86 const config = await api.getServerConfig() 87 serverName = config.serverName 88 serverNameInput = config.serverName 89 primaryColor = config.primaryColor || '' 90 primaryColorInput = config.primaryColor || '' 91 primaryColorDark = config.primaryColorDark || '' 92 primaryColorDarkInput = config.primaryColorDark || '' 93 secondaryColor = config.secondaryColor || '' 94 secondaryColorInput = config.secondaryColor || '' 95 secondaryColorDark = config.secondaryColorDark || '' 96 secondaryColorDarkInput = config.secondaryColorDark || '' 97 logoCid = config.logoCid 98 originalLogoCid = config.logoCid 99 if (config.logoCid) { 100 logoPreview = '/logo' 101 } 102 } catch (e) { 103 serverConfigError = e instanceof ApiError ? e.message : 'Failed to load server config' 104 } 105 } 106 async function saveServerConfig(e: Event) { 107 e.preventDefault() 108 if (!auth.session) return 109 serverConfigLoading = true 110 serverConfigError = null 111 serverConfigSuccess = false 112 try { 113 let newLogoCid = logoCid 114 if (logoFile) { 115 const result = await api.uploadBlob(auth.session.accessJwt, logoFile) 116 newLogoCid = result.blob.ref.$link 117 } 118 await api.updateServerConfig(auth.session.accessJwt, { 119 serverName: serverNameInput, 120 primaryColor: primaryColorInput, 121 primaryColorDark: primaryColorDarkInput, 122 secondaryColor: secondaryColorInput, 123 secondaryColorDark: secondaryColorDarkInput, 124 logoCid: newLogoCid ?? '', 125 }) 126 serverName = serverNameInput 127 primaryColor = primaryColorInput 128 primaryColorDark = primaryColorDarkInput 129 secondaryColor = secondaryColorInput 130 secondaryColorDark = secondaryColorDarkInput 131 logoCid = newLogoCid 132 originalLogoCid = newLogoCid 133 logoFile = null 134 setGlobalServerName(serverNameInput) 135 setGlobalColors({ 136 primaryColor: primaryColorInput || null, 137 primaryColorDark: primaryColorDarkInput || null, 138 secondaryColor: secondaryColorInput || null, 139 secondaryColorDark: secondaryColorDarkInput || null, 140 }) 141 setGlobalHasLogo(!!newLogoCid) 142 serverConfigSuccess = true 143 setTimeout(() => { serverConfigSuccess = false }, 3000) 144 } catch (e) { 145 serverConfigError = e instanceof ApiError ? e.message : 'Failed to save server config' 146 } finally { 147 serverConfigLoading = false 148 } 149 } 150 151 function handleLogoChange(e: Event) { 152 const input = e.target as HTMLInputElement 153 const file = input.files?.[0] 154 if (file) { 155 logoFile = file 156 logoPreview = URL.createObjectURL(file) 157 } 158 } 159 160 function removeLogo() { 161 logoFile = null 162 logoCid = null 163 logoPreview = null 164 } 165 166 function hasConfigChanges(): boolean { 167 const logoChanged = logoFile !== null || logoCid !== originalLogoCid 168 return serverNameInput !== serverName || 169 primaryColorInput !== primaryColor || 170 primaryColorDarkInput !== primaryColorDark || 171 secondaryColorInput !== secondaryColor || 172 secondaryColorDarkInput !== secondaryColorDark || 173 logoChanged 174 } 175 async function loadStats() { 176 if (!auth.session) return 177 loading = true 178 error = null 179 try { 180 stats = await api.getServerStats(auth.session.accessJwt) 181 } catch (e) { 182 error = e instanceof ApiError ? e.message : 'Failed to load server stats' 183 } finally { 184 loading = false 185 } 186 } 187 async function loadUsers(reset = false) { 188 if (!auth.session) return 189 usersLoading = true 190 usersError = null 191 if (reset) { 192 users = [] 193 usersCursor = undefined 194 } 195 try { 196 const result = await api.searchAccounts(auth.session.accessJwt, { 197 handle: handleSearchQuery || undefined, 198 cursor: reset ? undefined : usersCursor, 199 limit: 25, 200 }) 201 users = reset ? result.accounts : [...users, ...result.accounts] 202 usersCursor = result.cursor 203 showUsers = true 204 } catch (e) { 205 usersError = e instanceof ApiError ? e.message : 'Failed to load users' 206 } finally { 207 usersLoading = false 208 } 209 } 210 function handleSearch(e: Event) { 211 e.preventDefault() 212 loadUsers(true) 213 } 214 async function loadInvites(reset = false) { 215 if (!auth.session) return 216 invitesLoading = true 217 invitesError = null 218 if (reset) { 219 invites = [] 220 invitesCursor = undefined 221 } 222 try { 223 const result = await api.getInviteCodes(auth.session.accessJwt, { 224 cursor: reset ? undefined : invitesCursor, 225 limit: 25, 226 }) 227 invites = reset ? result.codes : [...invites, ...result.codes] 228 invitesCursor = result.cursor 229 showInvites = true 230 } catch (e) { 231 invitesError = e instanceof ApiError ? e.message : 'Failed to load invites' 232 } finally { 233 invitesLoading = false 234 } 235 } 236 async function disableInvite(code: string) { 237 if (!auth.session) return 238 if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return 239 try { 240 await api.disableInviteCodes(auth.session.accessJwt, [code]) 241 invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv) 242 } catch (e) { 243 invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite' 244 } 245 } 246 async function selectUser(did: string) { 247 if (!auth.session) return 248 userDetailLoading = true 249 try { 250 selectedUser = await api.getAccountInfo(auth.session.accessJwt, did) 251 } catch (e) { 252 usersError = e instanceof ApiError ? e.message : 'Failed to load user details' 253 } finally { 254 userDetailLoading = false 255 } 256 } 257 function closeUserDetail() { 258 selectedUser = null 259 } 260 async function toggleUserInvites() { 261 if (!auth.session || !selectedUser) return 262 userActionLoading = true 263 try { 264 if (selectedUser.invitesDisabled) { 265 await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did) 266 selectedUser = { ...selectedUser, invitesDisabled: false } 267 } else { 268 await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did) 269 selectedUser = { ...selectedUser, invitesDisabled: true } 270 } 271 } catch (e) { 272 usersError = e instanceof ApiError ? e.message : 'Failed to update user' 273 } finally { 274 userActionLoading = false 275 } 276 } 277 async function deleteUser() { 278 if (!auth.session || !selectedUser) return 279 if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return 280 userActionLoading = true 281 try { 282 await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did) 283 users = users.filter(u => u.did !== selectedUser!.did) 284 selectedUser = null 285 } catch (e) { 286 usersError = e instanceof ApiError ? e.message : 'Failed to delete user' 287 } finally { 288 userActionLoading = false 289 } 290 } 291 function formatBytes(bytes: number): string { 292 if (bytes === 0) return '0 B' 293 const k = 1024 294 const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] 295 const i = Math.floor(Math.log(bytes) / Math.log(k)) 296 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` 297 } 298 function formatNumber(num: number): string { 299 return num.toLocaleString() 300 } 301</script> 302{#if auth.session?.isAdmin} 303 <div class="page"> 304 <header> 305 <a href="#/dashboard" class="back">&larr; Dashboard</a> 306 <h1>Admin Panel</h1> 307 </header> 308 {#if loading} 309 <p class="loading">Loading...</p> 310 {:else} 311 {#if error} 312 <div class="message error">{error}</div> 313 {/if} 314 <section> 315 <h2>Server Configuration</h2> 316 <form class="config-form" onsubmit={saveServerConfig}> 317 <div class="form-group"> 318 <label for="serverName">Server Name</label> 319 <input 320 type="text" 321 id="serverName" 322 bind:value={serverNameInput} 323 placeholder="My PDS" 324 maxlength="100" 325 disabled={serverConfigLoading} 326 /> 327 <span class="help-text">Displayed in the browser tab and other places</span> 328 </div> 329 330 <div class="form-group"> 331 <label for="serverLogo">Server Logo</label> 332 <div class="logo-upload"> 333 {#if logoPreview} 334 <div class="logo-preview"> 335 <img src={logoPreview} alt="Logo preview" /> 336 <button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>Remove</button> 337 </div> 338 {:else} 339 <input 340 type="file" 341 id="serverLogo" 342 accept="image/*" 343 onchange={handleLogoChange} 344 disabled={serverConfigLoading} 345 /> 346 {/if} 347 </div> 348 <span class="help-text">Used as favicon and shown in the navbar</span> 349 </div> 350 351 <h3 class="subsection-title">Theme Colors</h3> 352 <p class="theme-hint">Leave blank to use default colors.</p> 353 354 <div class="color-grid"> 355 <div class="color-group"> 356 <label for="primaryColor">Primary (Light Mode)</label> 357 <div class="color-input-row"> 358 <input 359 type="color" 360 bind:value={primaryColorInput} 361 disabled={serverConfigLoading} 362 /> 363 <input 364 type="text" 365 id="primaryColor" 366 bind:value={primaryColorInput} 367 placeholder="#2c00ff (default)" 368 disabled={serverConfigLoading} 369 /> 370 </div> 371 </div> 372 <div class="color-group"> 373 <label for="primaryColorDark">Primary (Dark Mode)</label> 374 <div class="color-input-row"> 375 <input 376 type="color" 377 bind:value={primaryColorDarkInput} 378 disabled={serverConfigLoading} 379 /> 380 <input 381 type="text" 382 id="primaryColorDark" 383 bind:value={primaryColorDarkInput} 384 placeholder="#7b6bff (default)" 385 disabled={serverConfigLoading} 386 /> 387 </div> 388 </div> 389 <div class="color-group"> 390 <label for="secondaryColor">Secondary (Light Mode)</label> 391 <div class="color-input-row"> 392 <input 393 type="color" 394 bind:value={secondaryColorInput} 395 disabled={serverConfigLoading} 396 /> 397 <input 398 type="text" 399 id="secondaryColor" 400 bind:value={secondaryColorInput} 401 placeholder="#ff2400 (default)" 402 disabled={serverConfigLoading} 403 /> 404 </div> 405 </div> 406 <div class="color-group"> 407 <label for="secondaryColorDark">Secondary (Dark Mode)</label> 408 <div class="color-input-row"> 409 <input 410 type="color" 411 bind:value={secondaryColorDarkInput} 412 disabled={serverConfigLoading} 413 /> 414 <input 415 type="text" 416 id="secondaryColorDark" 417 bind:value={secondaryColorDarkInput} 418 placeholder="#ff6b5b (default)" 419 disabled={serverConfigLoading} 420 /> 421 </div> 422 </div> 423 </div> 424 425 {#if serverConfigError} 426 <div class="message error">{serverConfigError}</div> 427 {/if} 428 {#if serverConfigSuccess} 429 <div class="message success">Server configuration saved</div> 430 {/if} 431 <button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}> 432 {serverConfigLoading ? 'Saving...' : 'Save Configuration'} 433 </button> 434 </form> 435 </section> 436 {#if stats} 437 <section> 438 <h2>Server Statistics</h2> 439 <div class="stats-grid"> 440 <div class="stat-card"> 441 <div class="stat-value">{formatNumber(stats.userCount)}</div> 442 <div class="stat-label">Users</div> 443 </div> 444 <div class="stat-card"> 445 <div class="stat-value">{formatNumber(stats.repoCount)}</div> 446 <div class="stat-label">Repositories</div> 447 </div> 448 <div class="stat-card"> 449 <div class="stat-value">{formatNumber(stats.recordCount)}</div> 450 <div class="stat-label">Records</div> 451 </div> 452 <div class="stat-card"> 453 <div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div> 454 <div class="stat-label">Blob Storage</div> 455 </div> 456 </div> 457 <button class="refresh-btn" onclick={loadStats}>Refresh Stats</button> 458 </section> 459 {/if} 460 <section> 461 <h2>User Management</h2> 462 <form class="search-form" onsubmit={handleSearch}> 463 <input 464 type="text" 465 bind:value={handleSearchQuery} 466 placeholder="Search by handle (optional)" 467 disabled={usersLoading} 468 /> 469 <button type="submit" disabled={usersLoading}> 470 {usersLoading ? 'Loading...' : 'Search Users'} 471 </button> 472 </form> 473 {#if usersError} 474 <div class="message error">{usersError}</div> 475 {/if} 476 {#if showUsers} 477 <div class="user-list"> 478 {#if users.length === 0} 479 <p class="no-results">No users found</p> 480 {:else} 481 <table> 482 <thead> 483 <tr> 484 <th>Handle</th> 485 <th>Email</th> 486 <th>Status</th> 487 <th>Created</th> 488 </tr> 489 </thead> 490 <tbody> 491 {#each users as user} 492 <tr class="clickable" onclick={() => selectUser(user.did)}> 493 <td class="handle">@{user.handle}</td> 494 <td class="email">{user.email || '-'}</td> 495 <td> 496 {#if user.deactivatedAt} 497 <span class="badge deactivated">Deactivated</span> 498 {:else if user.emailConfirmedAt} 499 <span class="badge verified">Verified</span> 500 {:else} 501 <span class="badge unverified">Unverified</span> 502 {/if} 503 </td> 504 <td class="date">{formatDate(user.indexedAt)}</td> 505 </tr> 506 {/each} 507 </tbody> 508 </table> 509 {#if usersCursor} 510 <button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}> 511 {usersLoading ? 'Loading...' : 'Load More'} 512 </button> 513 {/if} 514 {/if} 515 </div> 516 {/if} 517 </section> 518 <section> 519 <h2>Invite Codes</h2> 520 <div class="section-actions"> 521 <button onclick={() => loadInvites(true)} disabled={invitesLoading}> 522 {invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'} 523 </button> 524 </div> 525 {#if invitesError} 526 <div class="message error">{invitesError}</div> 527 {/if} 528 {#if showInvites} 529 <div class="invite-list"> 530 {#if invites.length === 0} 531 <p class="no-results">No invite codes found</p> 532 {:else} 533 <table> 534 <thead> 535 <tr> 536 <th>Code</th> 537 <th>Available</th> 538 <th>Uses</th> 539 <th>Status</th> 540 <th>Created</th> 541 <th>Actions</th> 542 </tr> 543 </thead> 544 <tbody> 545 {#each invites as invite} 546 <tr class:disabled-row={invite.disabled}> 547 <td class="code">{invite.code}</td> 548 <td>{invite.available}</td> 549 <td>{invite.uses.length}</td> 550 <td> 551 {#if invite.disabled} 552 <span class="badge deactivated">Disabled</span> 553 {:else if invite.available === 0} 554 <span class="badge unverified">Exhausted</span> 555 {:else} 556 <span class="badge verified">Active</span> 557 {/if} 558 </td> 559 <td class="date">{formatDate(invite.createdAt)}</td> 560 <td> 561 {#if !invite.disabled} 562 <button class="action-btn danger" onclick={() => disableInvite(invite.code)}> 563 Disable 564 </button> 565 {:else} 566 <span class="muted">-</span> 567 {/if} 568 </td> 569 </tr> 570 {/each} 571 </tbody> 572 </table> 573 {#if invitesCursor} 574 <button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}> 575 {invitesLoading ? 'Loading...' : 'Load More'} 576 </button> 577 {/if} 578 {/if} 579 </div> 580 {/if} 581 </section> 582 {/if} 583 </div> 584 {#if selectedUser} 585 <div class="modal-overlay" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation"> 586 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> 587 <div class="modal-header"> 588 <h2>User Details</h2> 589 <button class="close-btn" onclick={closeUserDetail}>&times;</button> 590 </div> 591 {#if userDetailLoading} 592 <p class="loading">Loading...</p> 593 {:else} 594 <div class="modal-body"> 595 <dl class="user-details"> 596 <dt>Handle</dt> 597 <dd>@{selectedUser.handle}</dd> 598 <dt>DID</dt> 599 <dd class="mono">{selectedUser.did}</dd> 600 <dt>Email</dt> 601 <dd>{selectedUser.email || '-'}</dd> 602 <dt>Status</dt> 603 <dd> 604 {#if selectedUser.deactivatedAt} 605 <span class="badge deactivated">Deactivated</span> 606 {:else if selectedUser.emailConfirmedAt} 607 <span class="badge verified">Verified</span> 608 {:else} 609 <span class="badge unverified">Unverified</span> 610 {/if} 611 </dd> 612 <dt>Created</dt> 613 <dd>{formatDateTime(selectedUser.indexedAt)}</dd> 614 <dt>Invites</dt> 615 <dd> 616 {#if selectedUser.invitesDisabled} 617 <span class="badge deactivated">Disabled</span> 618 {:else} 619 <span class="badge verified">Enabled</span> 620 {/if} 621 </dd> 622 </dl> 623 <div class="modal-actions"> 624 <button 625 class="action-btn" 626 onclick={toggleUserInvites} 627 disabled={userActionLoading} 628 > 629 {selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'} 630 </button> 631 <button 632 class="action-btn danger" 633 onclick={deleteUser} 634 disabled={userActionLoading} 635 > 636 Delete Account 637 </button> 638 </div> 639 </div> 640 {/if} 641 </div> 642 </div> 643 {/if} 644{:else if auth.loading} 645 <div class="loading">Loading...</div> 646{/if} 647<style> 648 .page { 649 max-width: var(--width-lg); 650 margin: 0 auto; 651 padding: var(--space-7); 652 } 653 654 header { 655 margin-bottom: var(--space-7); 656 } 657 658 .back { 659 color: var(--text-secondary); 660 text-decoration: none; 661 font-size: var(--text-sm); 662 } 663 664 .back:hover { 665 color: var(--accent); 666 } 667 668 h1 { 669 margin: var(--space-2) 0 0 0; 670 } 671 672 .loading { 673 text-align: center; 674 color: var(--text-secondary); 675 padding: var(--space-7); 676 } 677 678 .message { 679 padding: var(--space-3); 680 border-radius: var(--radius-md); 681 margin-bottom: var(--space-4); 682 } 683 684 .message.error { 685 background: var(--error-bg); 686 border: 1px solid var(--error-border); 687 color: var(--error-text); 688 } 689 690 .message.success { 691 background: var(--success-bg); 692 border: 1px solid var(--success-border); 693 color: var(--success-text); 694 } 695 696 .config-form { 697 max-width: 500px; 698 } 699 700 .form-group { 701 margin-bottom: var(--space-4); 702 } 703 704 .form-group label { 705 display: block; 706 font-weight: var(--font-medium); 707 margin-bottom: var(--space-2); 708 font-size: var(--text-sm); 709 } 710 711 .form-group input { 712 width: 100%; 713 padding: var(--space-2) var(--space-3); 714 border: 1px solid var(--border-color); 715 border-radius: var(--radius-md); 716 font-size: var(--text-sm); 717 background: var(--bg-input); 718 color: var(--text-primary); 719 } 720 721 .form-group input:focus { 722 outline: none; 723 border-color: var(--accent); 724 } 725 726 .help-text { 727 display: block; 728 font-size: var(--text-xs); 729 color: var(--text-secondary); 730 margin-top: var(--space-1); 731 } 732 733 .config-form button { 734 padding: var(--space-2) var(--space-4); 735 background: var(--accent); 736 color: var(--text-inverse); 737 border: none; 738 border-radius: var(--radius-md); 739 cursor: pointer; 740 font-size: var(--text-sm); 741 } 742 743 .config-form button:hover:not(:disabled) { 744 background: var(--accent-hover); 745 } 746 747 .config-form button:disabled { 748 opacity: 0.6; 749 cursor: not-allowed; 750 } 751 752 .subsection-title { 753 font-size: var(--text-sm); 754 font-weight: var(--font-semibold); 755 color: var(--text-primary); 756 margin: var(--space-5) 0 var(--space-2) 0; 757 padding-top: var(--space-4); 758 border-top: 1px solid var(--border-color); 759 } 760 761 .theme-hint { 762 font-size: var(--text-xs); 763 color: var(--text-secondary); 764 margin-bottom: var(--space-4); 765 } 766 767 .color-grid { 768 display: grid; 769 grid-template-columns: 1fr 1fr; 770 gap: var(--space-4); 771 margin-bottom: var(--space-4); 772 } 773 774 @media (max-width: 500px) { 775 .color-grid { 776 grid-template-columns: 1fr; 777 } 778 } 779 780 .color-group label { 781 display: block; 782 font-size: var(--text-xs); 783 font-weight: var(--font-medium); 784 color: var(--text-secondary); 785 margin-bottom: var(--space-1); 786 } 787 788 .color-group input[type="text"] { 789 width: 100%; 790 } 791 792 .logo-upload { 793 margin-top: var(--space-2); 794 } 795 796 .logo-preview { 797 display: flex; 798 align-items: center; 799 gap: var(--space-3); 800 } 801 802 .logo-preview img { 803 width: 48px; 804 height: 48px; 805 object-fit: contain; 806 border-radius: var(--radius-md); 807 border: 1px solid var(--border-color); 808 background: var(--bg-input); 809 } 810 811 .remove-logo { 812 background: transparent; 813 color: var(--error-text); 814 border: 1px solid var(--error-border); 815 padding: var(--space-1) var(--space-2); 816 font-size: var(--text-xs); 817 } 818 819 .remove-logo:hover:not(:disabled) { 820 background: var(--error-bg); 821 } 822 823 section { 824 background: var(--bg-secondary); 825 padding: var(--space-6); 826 border-radius: var(--radius-xl); 827 margin-bottom: var(--space-6); 828 } 829 830 section h2 { 831 margin: 0 0 var(--space-4) 0; 832 font-size: var(--text-lg); 833 } 834 835 .stats-grid { 836 display: grid; 837 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 838 gap: var(--space-4); 839 margin-bottom: var(--space-4); 840 } 841 842 .stat-card { 843 background: var(--bg-card); 844 border: 1px solid var(--border-color); 845 border-radius: var(--radius-xl); 846 padding: var(--space-4); 847 text-align: center; 848 } 849 850 .stat-value { 851 font-size: var(--text-xl); 852 font-weight: var(--font-semibold); 853 color: var(--accent); 854 } 855 856 .stat-label { 857 font-size: var(--text-sm); 858 color: var(--text-secondary); 859 margin-top: var(--space-1); 860 } 861 862 .refresh-btn { 863 padding: var(--space-2) var(--space-4); 864 background: transparent; 865 border: 1px solid var(--border-color); 866 border-radius: var(--radius-md); 867 cursor: pointer; 868 color: var(--text-primary); 869 } 870 871 .refresh-btn:hover { 872 background: var(--bg-card); 873 border-color: var(--accent); 874 } 875 876 .search-form { 877 display: flex; 878 gap: var(--space-2); 879 margin-bottom: var(--space-4); 880 } 881 882 .search-form input { 883 flex: 1; 884 padding: var(--space-2) var(--space-3); 885 border: 1px solid var(--border-color); 886 border-radius: var(--radius-md); 887 font-size: var(--text-sm); 888 background: var(--bg-input); 889 color: var(--text-primary); 890 } 891 892 .search-form input:focus { 893 outline: none; 894 border-color: var(--accent); 895 } 896 897 .search-form button { 898 padding: var(--space-2) var(--space-4); 899 background: var(--accent); 900 color: var(--text-inverse); 901 border: none; 902 border-radius: var(--radius-md); 903 cursor: pointer; 904 font-size: var(--text-sm); 905 } 906 907 .search-form button:hover:not(:disabled) { 908 background: var(--accent-hover); 909 } 910 911 .search-form button:disabled { 912 opacity: 0.6; 913 cursor: not-allowed; 914 } 915 916 .user-list { 917 margin-top: var(--space-4); 918 } 919 920 .no-results { 921 color: var(--text-secondary); 922 text-align: center; 923 padding: var(--space-4); 924 } 925 926 table { 927 width: 100%; 928 border-collapse: collapse; 929 font-size: var(--text-sm); 930 } 931 932 th, td { 933 padding: var(--space-3) var(--space-2); 934 text-align: left; 935 border-bottom: 1px solid var(--border-color); 936 } 937 938 th { 939 font-weight: var(--font-semibold); 940 color: var(--text-secondary); 941 font-size: var(--text-xs); 942 text-transform: uppercase; 943 letter-spacing: 0.05em; 944 } 945 946 .handle { 947 font-weight: var(--font-medium); 948 } 949 950 .email { 951 color: var(--text-secondary); 952 } 953 954 .date { 955 color: var(--text-secondary); 956 font-size: var(--text-xs); 957 } 958 959 .badge { 960 display: inline-block; 961 padding: 2px var(--space-2); 962 border-radius: var(--radius-md); 963 font-size: var(--text-xs); 964 } 965 966 .badge.verified { 967 background: var(--success-bg); 968 color: var(--success-text); 969 } 970 971 .badge.unverified { 972 background: var(--warning-bg); 973 color: var(--warning-text); 974 } 975 976 .badge.deactivated { 977 background: var(--error-bg); 978 color: var(--error-text); 979 } 980 981 .load-more { 982 display: block; 983 width: 100%; 984 padding: var(--space-3); 985 margin-top: var(--space-4); 986 background: transparent; 987 border: 1px solid var(--border-color); 988 border-radius: var(--radius-md); 989 cursor: pointer; 990 color: var(--text-primary); 991 font-size: var(--text-sm); 992 } 993 994 .load-more:hover:not(:disabled) { 995 background: var(--bg-card); 996 border-color: var(--accent); 997 } 998 999 .load-more:disabled { 1000 opacity: 0.6; 1001 cursor: not-allowed; 1002 } 1003 1004 .section-actions { 1005 margin-bottom: var(--space-4); 1006 } 1007 1008 .section-actions button { 1009 padding: var(--space-2) var(--space-4); 1010 background: var(--accent); 1011 color: var(--text-inverse); 1012 border: none; 1013 border-radius: var(--radius-md); 1014 cursor: pointer; 1015 font-size: var(--text-sm); 1016 } 1017 1018 .section-actions button:hover:not(:disabled) { 1019 background: var(--accent-hover); 1020 } 1021 1022 .section-actions button:disabled { 1023 opacity: 0.6; 1024 cursor: not-allowed; 1025 } 1026 1027 .invite-list { 1028 margin-top: var(--space-4); 1029 } 1030 1031 .code { 1032 font-family: monospace; 1033 font-size: var(--text-xs); 1034 } 1035 1036 .disabled-row { 1037 opacity: 0.5; 1038 } 1039 1040 .action-btn { 1041 padding: var(--space-1) var(--space-2); 1042 font-size: var(--text-xs); 1043 border: none; 1044 border-radius: var(--radius-md); 1045 cursor: pointer; 1046 } 1047 1048 .action-btn.danger { 1049 background: var(--error-text); 1050 color: var(--text-inverse); 1051 } 1052 1053 .action-btn.danger:hover { 1054 background: #900; 1055 } 1056 1057 .muted { 1058 color: var(--text-muted); 1059 } 1060 1061 .clickable { 1062 cursor: pointer; 1063 } 1064 1065 .clickable:hover { 1066 background: var(--bg-card); 1067 } 1068 1069 .modal-overlay { 1070 position: fixed; 1071 top: 0; 1072 left: 0; 1073 right: 0; 1074 bottom: 0; 1075 background: rgba(0, 0, 0, 0.5); 1076 display: flex; 1077 align-items: center; 1078 justify-content: center; 1079 z-index: 1000; 1080 } 1081 1082 .modal { 1083 background: var(--bg-card); 1084 border-radius: var(--radius-xl); 1085 max-width: 500px; 1086 width: 90%; 1087 max-height: 90vh; 1088 overflow-y: auto; 1089 } 1090 1091 .modal-header { 1092 display: flex; 1093 justify-content: space-between; 1094 align-items: center; 1095 padding: var(--space-4) var(--space-6); 1096 border-bottom: 1px solid var(--border-color); 1097 } 1098 1099 .modal-header h2 { 1100 margin: 0; 1101 font-size: var(--text-lg); 1102 } 1103 1104 .close-btn { 1105 background: none; 1106 border: none; 1107 font-size: var(--text-xl); 1108 cursor: pointer; 1109 color: var(--text-secondary); 1110 padding: 0; 1111 line-height: 1; 1112 } 1113 1114 .close-btn:hover { 1115 color: var(--text-primary); 1116 } 1117 1118 .modal-body { 1119 padding: var(--space-6); 1120 } 1121 1122 .user-details { 1123 display: grid; 1124 grid-template-columns: auto 1fr; 1125 gap: var(--space-2) var(--space-4); 1126 margin: 0 0 var(--space-6) 0; 1127 } 1128 1129 .user-details dt { 1130 font-weight: var(--font-medium); 1131 color: var(--text-secondary); 1132 } 1133 1134 .user-details dd { 1135 margin: 0; 1136 } 1137 1138 .mono { 1139 font-family: monospace; 1140 font-size: var(--text-xs); 1141 word-break: break-all; 1142 } 1143 1144 .modal-actions { 1145 display: flex; 1146 gap: var(--space-2); 1147 flex-wrap: wrap; 1148 } 1149 1150 .modal-actions .action-btn { 1151 padding: var(--space-2) var(--space-4); 1152 border: 1px solid var(--border-color); 1153 border-radius: var(--radius-md); 1154 background: transparent; 1155 cursor: pointer; 1156 font-size: var(--text-sm); 1157 color: var(--text-primary); 1158 } 1159 1160 .modal-actions .action-btn:hover:not(:disabled) { 1161 background: var(--bg-secondary); 1162 } 1163 1164 .modal-actions .action-btn:disabled { 1165 opacity: 0.6; 1166 cursor: not-allowed; 1167 } 1168 1169 .modal-actions .action-btn.danger { 1170 border-color: var(--error-text); 1171 color: var(--error-text); 1172 } 1173 1174 .modal-actions .action-btn.danger:hover:not(:disabled) { 1175 background: var(--error-bg); 1176 } 1177</style>