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