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