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