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