this repo has no description
at main 34 kB view raw
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 .config-form { 678 max-width: 500px; 679 } 680 681 .form-group { 682 margin-bottom: var(--space-4); 683 } 684 685 .form-group label { 686 display: block; 687 font-weight: var(--font-medium); 688 margin-bottom: var(--space-2); 689 font-size: var(--text-sm); 690 } 691 692 .form-group input { 693 width: 100%; 694 padding: var(--space-2) var(--space-3); 695 border: 1px solid var(--border-color); 696 border-radius: var(--radius-md); 697 font-size: var(--text-sm); 698 background: var(--bg-input); 699 color: var(--text-primary); 700 } 701 702 .form-group input:focus { 703 outline: none; 704 border-color: var(--accent); 705 } 706 707 .help-text { 708 display: block; 709 font-size: var(--text-xs); 710 color: var(--text-secondary); 711 margin-top: var(--space-1); 712 } 713 714 .config-form button { 715 padding: var(--space-2) var(--space-4); 716 background: var(--accent); 717 color: var(--text-inverse); 718 border: none; 719 border-radius: var(--radius-md); 720 cursor: pointer; 721 font-size: var(--text-sm); 722 } 723 724 .config-form button:hover:not(:disabled) { 725 background: var(--accent-hover); 726 } 727 728 .config-form button:disabled { 729 opacity: 0.6; 730 cursor: not-allowed; 731 } 732 733 .subsection-title { 734 font-size: var(--text-sm); 735 font-weight: var(--font-semibold); 736 color: var(--text-primary); 737 margin: var(--space-5) 0 var(--space-2) 0; 738 padding-top: var(--space-4); 739 border-top: 1px solid var(--border-color); 740 } 741 742 .theme-hint { 743 font-size: var(--text-xs); 744 color: var(--text-secondary); 745 margin-bottom: var(--space-4); 746 } 747 748 .color-grid { 749 display: grid; 750 grid-template-columns: 1fr 1fr; 751 gap: var(--space-4); 752 margin-bottom: var(--space-4); 753 } 754 755 @media (max-width: 500px) { 756 .color-grid { 757 grid-template-columns: 1fr; 758 } 759 } 760 761 .color-group label { 762 display: block; 763 font-size: var(--text-xs); 764 font-weight: var(--font-medium); 765 color: var(--text-secondary); 766 margin-bottom: var(--space-1); 767 } 768 769 .color-group input[type="text"] { 770 width: 100%; 771 } 772 773 .logo-upload { 774 margin-top: var(--space-2); 775 } 776 777 .logo-preview { 778 display: flex; 779 align-items: center; 780 gap: var(--space-3); 781 } 782 783 .logo-preview img { 784 width: 48px; 785 height: 48px; 786 object-fit: contain; 787 border-radius: var(--radius-md); 788 border: 1px solid var(--border-color); 789 background: var(--bg-input); 790 } 791 792 .remove-logo { 793 background: transparent; 794 color: var(--error-text); 795 border: 1px solid var(--error-border); 796 padding: var(--space-1) var(--space-2); 797 font-size: var(--text-xs); 798 } 799 800 .remove-logo:hover:not(:disabled) { 801 background: var(--error-bg); 802 } 803 804 section { 805 background: var(--bg-secondary); 806 padding: var(--space-6); 807 border-radius: var(--radius-xl); 808 margin-bottom: var(--space-6); 809 } 810 811 section h2 { 812 margin: 0 0 var(--space-4) 0; 813 font-size: var(--text-lg); 814 } 815 816 .stats-grid { 817 display: grid; 818 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 819 gap: var(--space-4); 820 margin-bottom: var(--space-4); 821 } 822 823 .stat-card { 824 background: var(--bg-card); 825 border: 1px solid var(--border-color); 826 border-radius: var(--radius-xl); 827 padding: var(--space-4); 828 text-align: center; 829 } 830 831 .stat-value { 832 font-size: var(--text-xl); 833 font-weight: var(--font-semibold); 834 color: var(--accent); 835 } 836 837 .stat-label { 838 font-size: var(--text-sm); 839 color: var(--text-secondary); 840 margin-top: var(--space-1); 841 } 842 843 .refresh-btn { 844 padding: var(--space-2) var(--space-4); 845 background: transparent; 846 border: 1px solid var(--border-color); 847 border-radius: var(--radius-md); 848 cursor: pointer; 849 color: var(--text-primary); 850 } 851 852 .refresh-btn:hover { 853 background: var(--bg-card); 854 border-color: var(--accent); 855 } 856 857 .search-form { 858 display: flex; 859 gap: var(--space-2); 860 margin-bottom: var(--space-4); 861 } 862 863 .search-form input { 864 flex: 1; 865 padding: var(--space-2) var(--space-3); 866 border: 1px solid var(--border-color); 867 border-radius: var(--radius-md); 868 font-size: var(--text-sm); 869 background: var(--bg-input); 870 color: var(--text-primary); 871 } 872 873 .search-form input:focus { 874 outline: none; 875 border-color: var(--accent); 876 } 877 878 .search-form button { 879 padding: var(--space-2) var(--space-4); 880 background: var(--accent); 881 color: var(--text-inverse); 882 border: none; 883 border-radius: var(--radius-md); 884 cursor: pointer; 885 font-size: var(--text-sm); 886 } 887 888 .search-form button:hover:not(:disabled) { 889 background: var(--accent-hover); 890 } 891 892 .search-form button:disabled { 893 opacity: 0.6; 894 cursor: not-allowed; 895 } 896 897 .user-list { 898 margin-top: var(--space-4); 899 } 900 901 .no-results { 902 color: var(--text-secondary); 903 text-align: center; 904 padding: var(--space-4); 905 } 906 907 table { 908 width: 100%; 909 border-collapse: collapse; 910 font-size: var(--text-sm); 911 } 912 913 th, td { 914 padding: var(--space-3) var(--space-2); 915 text-align: left; 916 border-bottom: 1px solid var(--border-color); 917 } 918 919 th { 920 font-weight: var(--font-semibold); 921 color: var(--text-secondary); 922 font-size: var(--text-xs); 923 text-transform: uppercase; 924 letter-spacing: 0.05em; 925 } 926 927 .handle { 928 font-weight: var(--font-medium); 929 } 930 931 .email { 932 color: var(--text-secondary); 933 } 934 935 .date { 936 color: var(--text-secondary); 937 font-size: var(--text-xs); 938 } 939 940 .badge { 941 display: inline-block; 942 padding: 2px var(--space-2); 943 border-radius: var(--radius-md); 944 font-size: var(--text-xs); 945 } 946 947 .badge.verified { 948 background: var(--success-bg); 949 color: var(--success-text); 950 } 951 952 .badge.unverified { 953 background: var(--warning-bg); 954 color: var(--warning-text); 955 } 956 957 .badge.deactivated { 958 background: var(--error-bg); 959 color: var(--error-text); 960 } 961 962 .load-more { 963 display: block; 964 width: 100%; 965 padding: var(--space-3); 966 margin-top: var(--space-4); 967 background: transparent; 968 border: 1px solid var(--border-color); 969 border-radius: var(--radius-md); 970 cursor: pointer; 971 color: var(--text-primary); 972 font-size: var(--text-sm); 973 } 974 975 .load-more:hover:not(:disabled) { 976 background: var(--bg-card); 977 border-color: var(--accent); 978 } 979 980 .load-more:disabled { 981 opacity: 0.6; 982 cursor: not-allowed; 983 } 984 985 .section-actions { 986 margin-bottom: var(--space-4); 987 } 988 989 .section-actions button { 990 padding: var(--space-2) var(--space-4); 991 background: var(--accent); 992 color: var(--text-inverse); 993 border: none; 994 border-radius: var(--radius-md); 995 cursor: pointer; 996 font-size: var(--text-sm); 997 } 998 999 .section-actions button:hover:not(:disabled) { 1000 background: var(--accent-hover); 1001 } 1002 1003 .section-actions button:disabled { 1004 opacity: 0.6; 1005 cursor: not-allowed; 1006 } 1007 1008 .invite-list { 1009 margin-top: var(--space-4); 1010 } 1011 1012 .code { 1013 font-family: monospace; 1014 font-size: var(--text-xs); 1015 } 1016 1017 .disabled-row { 1018 opacity: 0.5; 1019 } 1020 1021 .action-btn { 1022 padding: var(--space-1) var(--space-2); 1023 font-size: var(--text-xs); 1024 border: none; 1025 border-radius: var(--radius-md); 1026 cursor: pointer; 1027 } 1028 1029 .action-btn.danger { 1030 background: var(--error-text); 1031 color: var(--text-inverse); 1032 } 1033 1034 .action-btn.danger:hover { 1035 background: #900; 1036 } 1037 1038 .muted { 1039 color: var(--text-muted); 1040 } 1041 1042 .clickable { 1043 cursor: pointer; 1044 } 1045 1046 .clickable:hover { 1047 background: var(--bg-card); 1048 } 1049 1050 .modal-overlay { 1051 position: fixed; 1052 top: 0; 1053 left: 0; 1054 right: 0; 1055 bottom: 0; 1056 background: rgba(0, 0, 0, 0.5); 1057 display: flex; 1058 align-items: center; 1059 justify-content: center; 1060 z-index: 1000; 1061 } 1062 1063 .modal { 1064 background: var(--bg-card); 1065 border-radius: var(--radius-xl); 1066 max-width: 500px; 1067 width: 90%; 1068 max-height: 90vh; 1069 overflow-y: auto; 1070 } 1071 1072 .modal-header { 1073 display: flex; 1074 justify-content: space-between; 1075 align-items: center; 1076 padding: var(--space-4) var(--space-6); 1077 border-bottom: 1px solid var(--border-color); 1078 } 1079 1080 .modal-header h2 { 1081 margin: 0; 1082 font-size: var(--text-lg); 1083 } 1084 1085 .close-btn { 1086 background: none; 1087 border: none; 1088 font-size: var(--text-xl); 1089 cursor: pointer; 1090 color: var(--text-secondary); 1091 padding: 0; 1092 line-height: 1; 1093 } 1094 1095 .close-btn:hover { 1096 color: var(--text-primary); 1097 } 1098 1099 .modal-body { 1100 padding: var(--space-6); 1101 } 1102 1103 .user-details { 1104 display: grid; 1105 grid-template-columns: auto 1fr; 1106 gap: var(--space-2) var(--space-4); 1107 margin: 0 0 var(--space-6) 0; 1108 } 1109 1110 .user-details dt { 1111 font-weight: var(--font-medium); 1112 color: var(--text-secondary); 1113 } 1114 1115 .user-details dd { 1116 margin: 0; 1117 } 1118 1119 .mono { 1120 font-family: monospace; 1121 font-size: var(--text-xs); 1122 word-break: break-all; 1123 } 1124 1125 .modal-actions { 1126 display: flex; 1127 gap: var(--space-2); 1128 flex-wrap: wrap; 1129 } 1130 1131 .modal-actions .action-btn { 1132 padding: var(--space-2) var(--space-4); 1133 border: 1px solid var(--border-color); 1134 border-radius: var(--radius-md); 1135 background: transparent; 1136 cursor: pointer; 1137 font-size: var(--text-sm); 1138 color: var(--text-primary); 1139 } 1140 1141 .modal-actions .action-btn:hover:not(:disabled) { 1142 background: var(--bg-secondary); 1143 } 1144 1145 .modal-actions .action-btn:disabled { 1146 opacity: 0.6; 1147 cursor: not-allowed; 1148 } 1149 1150 .modal-actions .action-btn.danger { 1151 border-color: var(--error-text); 1152 color: var(--error-text); 1153 } 1154 1155 .modal-actions .action-btn.danger:hover:not(:disabled) { 1156 background: var(--error-bg); 1157 } 1158</style>