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