this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte'
4 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5 import { api, ApiError } from '../lib/api'
6 import { _ } from '../lib/i18n'
7 import { formatDate, formatDateTime } from '../lib/date'
8 import { unsafeAsDid } from '../lib/types/branded'
9 import type { Session } from '../lib/types/api'
10 import { toast } from '../lib/toast.svelte'
11
12 const auth = $derived(getAuthState())
13
14 function getSession(): Session | null {
15 return auth.kind === 'authenticated' ? auth.session : null
16 }
17
18 function isLoading(): boolean {
19 return auth.kind === 'loading'
20 }
21
22 const session = $derived(getSession())
23 const authLoading = $derived(isLoading())
24 const DEFAULT_COLORS = {
25 primaryLight: '#1A1D1D',
26 primaryDark: '#E6E8E8',
27 secondaryLight: '#1A1D1D',
28 secondaryDark: '#E6E8E8',
29 }
30 let loading = $state(true)
31 let stats = $state<{
32 userCount: number
33 repoCount: number
34 recordCount: number
35 blobStorageBytes: number
36 } | null>(null)
37 let usersLoading = $state(false)
38 let users = $state<Array<{
39 did: string
40 handle: string
41 email?: string
42 indexedAt: string
43 emailConfirmedAt?: string
44 deactivatedAt?: string
45 }>>([])
46 let usersCursor = $state<string | undefined>(undefined)
47 let handleSearchQuery = $state('')
48 let showUsers = $state(false)
49 let invitesLoading = $state(false)
50 let invites = $state<Array<{
51 code: string
52 available: number
53 disabled: boolean
54 forAccount: string
55 createdBy: string
56 createdAt: string
57 uses: Array<{ usedBy: string; usedAt: string }>
58 }>>([])
59 let invitesCursor = $state<string | undefined>(undefined)
60 let showInvites = $state(false)
61 let selectedUser = $state<{
62 did: string
63 handle: string
64 email?: string
65 indexedAt: string
66 emailConfirmedAt?: string
67 invitesDisabled?: boolean
68 deactivatedAt?: string
69 } | null>(null)
70 let userDetailLoading = $state(false)
71 let userActionLoading = $state(false)
72 let serverName = $state('')
73 let serverNameInput = $state('')
74 let primaryColor = $state('')
75 let primaryColorInput = $state('')
76 let primaryColorDark = $state('')
77 let primaryColorDarkInput = $state('')
78 let secondaryColor = $state('')
79 let secondaryColorInput = $state('')
80 let secondaryColorDark = $state('')
81 let secondaryColorDarkInput = $state('')
82 let logoCid = $state<string | null>(null)
83 let originalLogoCid = $state<string | null>(null)
84 let logoFile = $state<File | null>(null)
85 let logoPreview = $state<string | null>(null)
86 let serverConfigLoading = $state(false)
87 $effect(() => {
88 if (!authLoading && !session) {
89 navigate(routes.login)
90 } else if (!authLoading && session && !session.isAdmin) {
91 navigate(routes.dashboard)
92 }
93 })
94 $effect(() => {
95 if (session?.isAdmin) {
96 loadStats()
97 loadServerConfig()
98 }
99 })
100 async function loadServerConfig() {
101 try {
102 const config = await api.getServerConfig()
103 serverName = config.serverName
104 serverNameInput = config.serverName
105 primaryColor = config.primaryColor || ''
106 primaryColorInput = config.primaryColor || ''
107 primaryColorDark = config.primaryColorDark || ''
108 primaryColorDarkInput = config.primaryColorDark || ''
109 secondaryColor = config.secondaryColor || ''
110 secondaryColorInput = config.secondaryColor || ''
111 secondaryColorDark = config.secondaryColorDark || ''
112 secondaryColorDarkInput = config.secondaryColorDark || ''
113 logoCid = config.logoCid
114 originalLogoCid = config.logoCid
115 if (config.logoCid) {
116 logoPreview = '/logo'
117 }
118 } catch (e) {
119 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadConfig'))
120 }
121 }
122 async function saveServerConfig(e: Event) {
123 e.preventDefault()
124 if (!session) return
125 serverConfigLoading = true
126 try {
127 let newLogoCid = logoCid
128 if (logoFile) {
129 const result = await api.uploadBlob(session.accessJwt, logoFile)
130 newLogoCid = result.blob.ref.$link
131 }
132 await api.updateServerConfig(session.accessJwt, {
133 serverName: serverNameInput,
134 primaryColor: primaryColorInput,
135 primaryColorDark: primaryColorDarkInput,
136 secondaryColor: secondaryColorInput,
137 secondaryColorDark: secondaryColorDarkInput,
138 logoCid: newLogoCid ?? '',
139 })
140 serverName = serverNameInput
141 primaryColor = primaryColorInput
142 primaryColorDark = primaryColorDarkInput
143 secondaryColor = secondaryColorInput
144 secondaryColorDark = secondaryColorDarkInput
145 logoCid = newLogoCid
146 originalLogoCid = newLogoCid
147 logoFile = null
148 setGlobalServerName(serverNameInput)
149 setGlobalColors({
150 primaryColor: primaryColorInput || null,
151 primaryColorDark: primaryColorDarkInput || null,
152 secondaryColor: secondaryColorInput || null,
153 secondaryColorDark: secondaryColorDarkInput || null,
154 })
155 setGlobalHasLogo(!!newLogoCid)
156 toast.success($_('admin.configSaved'))
157 } catch (e) {
158 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToSaveConfig'))
159 } finally {
160 serverConfigLoading = false
161 }
162 }
163
164 function handleLogoChange(e: Event) {
165 const input = e.target as HTMLInputElement
166 const file = input.files?.[0]
167 if (file) {
168 logoFile = file
169 logoPreview = URL.createObjectURL(file)
170 }
171 }
172
173 function removeLogo() {
174 logoFile = null
175 logoCid = null
176 logoPreview = null
177 }
178
179 function hasConfigChanges(): boolean {
180 const logoChanged = logoFile !== null || logoCid !== originalLogoCid
181 return serverNameInput !== serverName ||
182 primaryColorInput !== primaryColor ||
183 primaryColorDarkInput !== primaryColorDark ||
184 secondaryColorInput !== secondaryColor ||
185 secondaryColorDarkInput !== secondaryColorDark ||
186 logoChanged
187 }
188 async function loadStats() {
189 if (!session) return
190 loading = true
191 try {
192 stats = await api.getServerStats(session.accessJwt)
193 } catch (e) {
194 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadStats'))
195 } finally {
196 loading = false
197 }
198 }
199 async function loadUsers(reset = false) {
200 if (!session) return
201 usersLoading = true
202 if (reset) {
203 users = []
204 usersCursor = undefined
205 }
206 try {
207 const result = await api.searchAccounts(session.accessJwt, {
208 handle: handleSearchQuery || undefined,
209 cursor: reset ? undefined : usersCursor,
210 limit: 25,
211 })
212 users = reset ? result.accounts : [...users, ...result.accounts]
213 usersCursor = result.cursor
214 showUsers = true
215 } catch (e) {
216 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUsers'))
217 } finally {
218 usersLoading = false
219 }
220 }
221 function handleSearch(e: Event) {
222 e.preventDefault()
223 loadUsers(true)
224 }
225 async function loadInvites(reset = false) {
226 if (!session) return
227 invitesLoading = true
228 if (reset) {
229 invites = []
230 invitesCursor = undefined
231 }
232 try {
233 const result = await api.getInviteCodes(session.accessJwt, {
234 cursor: reset ? undefined : invitesCursor,
235 limit: 25,
236 })
237 invites = reset ? result.codes : [...invites, ...result.codes]
238 invitesCursor = result.cursor
239 showInvites = true
240 } catch (e) {
241 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadInvites'))
242 } finally {
243 invitesLoading = false
244 }
245 }
246 async function disableInvite(code: string) {
247 if (!session) return
248 if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return
249 try {
250 await api.disableInviteCodes(session.accessJwt, [code])
251 invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv)
252 toast.success($_('admin.inviteDisabled'))
253 } catch (e) {
254 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDisableInvite'))
255 }
256 }
257 async function selectUser(did: string) {
258 if (!session) return
259 userDetailLoading = true
260 try {
261 selectedUser = await api.getAccountInfo(session.accessJwt, unsafeAsDid(did))
262 } catch (e) {
263 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUserDetails'))
264 } finally {
265 userDetailLoading = false
266 }
267 }
268 function closeUserDetail() {
269 selectedUser = null
270 }
271 async function toggleUserInvites() {
272 if (!session || !selectedUser) return
273 userActionLoading = true
274 try {
275 if (selectedUser.invitesDisabled) {
276 await api.enableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
277 selectedUser = { ...selectedUser, invitesDisabled: false }
278 toast.success($_('admin.invitesEnabled'))
279 } else {
280 await api.disableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
281 selectedUser = { ...selectedUser, invitesDisabled: true }
282 toast.success($_('admin.invitesDisabled'))
283 }
284 } catch (e) {
285 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToUpdateUser'))
286 } finally {
287 userActionLoading = false
288 }
289 }
290 async function deleteUser() {
291 if (!session || !selectedUser) return
292 if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
293 userActionLoading = true
294 try {
295 await api.adminDeleteAccount(session.accessJwt, unsafeAsDid(selectedUser.did))
296 users = users.filter(u => u.did !== selectedUser!.did)
297 selectedUser = null
298 toast.success($_('admin.userDeleted'))
299 } catch (e) {
300 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDeleteUser'))
301 } finally {
302 userActionLoading = false
303 }
304 }
305 function formatBytes(bytes: number): string {
306 if (bytes === 0) return '0 B'
307 const k = 1024
308 const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
309 const i = Math.floor(Math.log(bytes) / Math.log(k))
310 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
311 }
312 function formatNumber(num: number): string {
313 return num.toLocaleString()
314 }
315</script>
316{#if session?.isAdmin}
317 <div class="page">
318 <header>
319 <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
320 <h1>{$_('admin.title')}</h1>
321 </header>
322 {#if loading}
323 <p class="loading">{$_('admin.loading')}</p>
324 {:else}
325 <section>
326 <h2>{$_('admin.serverConfig')}</h2>
327 <form class="config-form" onsubmit={saveServerConfig}>
328 <div class="form-group">
329 <label for="serverName">{$_('admin.serverName')}</label>
330 <input
331 type="text"
332 id="serverName"
333 bind:value={serverNameInput}
334 placeholder={$_('admin.serverNamePlaceholder')}
335 maxlength="100"
336 disabled={serverConfigLoading}
337 />
338 <span class="help-text">{$_('admin.serverNameHelp')}</span>
339 </div>
340
341 <div class="form-group">
342 <label for="serverLogo">{$_('admin.serverLogo')}</label>
343 <div class="logo-upload">
344 {#if logoPreview}
345 <div class="logo-preview">
346 <img src={logoPreview} alt={$_('admin.logoPreview')} />
347 <button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>{$_('admin.removeLogo')}</button>
348 </div>
349 {:else}
350 <input
351 type="file"
352 id="serverLogo"
353 accept="image/*"
354 onchange={handleLogoChange}
355 disabled={serverConfigLoading}
356 />
357 {/if}
358 </div>
359 <span class="help-text">{$_('admin.logoHelp')}</span>
360 </div>
361
362 <h3 class="subsection-title">{$_('admin.themeColors')}</h3>
363 <p class="theme-hint">{$_('admin.themeColorsHint')}</p>
364
365 <div class="color-grid">
366 <div class="color-group">
367 <label for="primaryColor">{$_('admin.primaryLight')}</label>
368 <div class="color-input-row">
369 <input
370 type="color"
371 bind:value={primaryColorInput}
372 disabled={serverConfigLoading}
373 />
374 <input
375 type="text"
376 id="primaryColor"
377 bind:value={primaryColorInput}
378 placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryLight } })}
379 disabled={serverConfigLoading}
380 />
381 </div>
382 </div>
383 <div class="color-group">
384 <label for="primaryColorDark">{$_('admin.primaryDark')}</label>
385 <div class="color-input-row">
386 <input
387 type="color"
388 bind:value={primaryColorDarkInput}
389 disabled={serverConfigLoading}
390 />
391 <input
392 type="text"
393 id="primaryColorDark"
394 bind:value={primaryColorDarkInput}
395 placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryDark } })}
396 disabled={serverConfigLoading}
397 />
398 </div>
399 </div>
400 <div class="color-group">
401 <label for="secondaryColor">{$_('admin.secondaryLight')}</label>
402 <div class="color-input-row">
403 <input
404 type="color"
405 bind:value={secondaryColorInput}
406 disabled={serverConfigLoading}
407 />
408 <input
409 type="text"
410 id="secondaryColor"
411 bind:value={secondaryColorInput}
412 placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryLight } })}
413 disabled={serverConfigLoading}
414 />
415 </div>
416 </div>
417 <div class="color-group">
418 <label for="secondaryColorDark">{$_('admin.secondaryDark')}</label>
419 <div class="color-input-row">
420 <input
421 type="color"
422 bind:value={secondaryColorDarkInput}
423 disabled={serverConfigLoading}
424 />
425 <input
426 type="text"
427 id="secondaryColorDark"
428 bind:value={secondaryColorDarkInput}
429 placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryDark } })}
430 disabled={serverConfigLoading}
431 />
432 </div>
433 </div>
434 </div>
435
436 <button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
437 {serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')}
438 </button>
439 </form>
440 </section>
441 {#if stats}
442 <section>
443 <h2>{$_('admin.serverStats')}</h2>
444 <div class="stats-grid">
445 <div class="stat-card">
446 <div class="stat-value">{formatNumber(stats.userCount)}</div>
447 <div class="stat-label">{$_('admin.users')}</div>
448 </div>
449 <div class="stat-card">
450 <div class="stat-value">{formatNumber(stats.repoCount)}</div>
451 <div class="stat-label">{$_('admin.repos')}</div>
452 </div>
453 <div class="stat-card">
454 <div class="stat-value">{formatNumber(stats.recordCount)}</div>
455 <div class="stat-label">{$_('admin.records')}</div>
456 </div>
457 <div class="stat-card">
458 <div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div>
459 <div class="stat-label">{$_('admin.blobStorage')}</div>
460 </div>
461 </div>
462 <button class="refresh-btn" onclick={loadStats}>{$_('admin.refreshStats')}</button>
463 </section>
464 {/if}
465 <section>
466 <h2>{$_('admin.userManagement')}</h2>
467 <form class="search-form" onsubmit={handleSearch}>
468 <input
469 type="text"
470 bind:value={handleSearchQuery}
471 placeholder={$_('admin.searchPlaceholder')}
472 disabled={usersLoading}
473 />
474 <button type="submit" disabled={usersLoading}>
475 {usersLoading ? $_('admin.loading') : $_('admin.searchUsers')}
476 </button>
477 </form>
478 {#if showUsers}
479 <div class="user-list">
480 {#if users.length === 0}
481 <p class="no-results">{$_('admin.noUsers')}</p>
482 {:else}
483 <table>
484 <thead>
485 <tr>
486 <th>{$_('admin.handle')}</th>
487 <th>{$_('admin.email')}</th>
488 <th>{$_('admin.status')}</th>
489 <th>{$_('admin.created')}</th>
490 </tr>
491 </thead>
492 <tbody>
493 {#each users as user}
494 <tr class="clickable" onclick={() => selectUser(user.did)}>
495 <td class="handle">@{user.handle}</td>
496 <td class="email">{user.email || '-'}</td>
497 <td>
498 {#if user.deactivatedAt}
499 <span class="badge deactivated">{$_('admin.deactivated')}</span>
500 {:else if user.emailConfirmedAt}
501 <span class="badge verified">{$_('admin.verified')}</span>
502 {:else}
503 <span class="badge unverified">{$_('admin.unverified')}</span>
504 {/if}
505 </td>
506 <td class="date">{formatDate(user.indexedAt)}</td>
507 </tr>
508 {/each}
509 </tbody>
510 </table>
511 {#if usersCursor}
512 <button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
513 {usersLoading ? $_('admin.loading') : $_('admin.loadMore')}
514 </button>
515 {/if}
516 {/if}
517 </div>
518 {/if}
519 </section>
520 <section>
521 <h2>{$_('admin.inviteCodes')}</h2>
522 <div class="section-actions">
523 <button onclick={() => loadInvites(true)} disabled={invitesLoading}>
524 {invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
525 </button>
526 </div>
527 {#if showInvites}
528 <div class="invite-list">
529 {#if invites.length === 0}
530 <p class="no-results">{$_('admin.noInvites')}</p>
531 {:else}
532 <table>
533 <thead>
534 <tr>
535 <th>{$_('admin.code')}</th>
536 <th>{$_('admin.available')}</th>
537 <th>{$_('admin.uses')}</th>
538 <th>{$_('admin.status')}</th>
539 <th>{$_('admin.created')}</th>
540 <th>{$_('admin.actions')}</th>
541 </tr>
542 </thead>
543 <tbody>
544 {#each invites as invite}
545 <tr class:disabled-row={invite.disabled}>
546 <td class="code">{invite.code}</td>
547 <td>{invite.available}</td>
548 <td>{invite.uses.length}</td>
549 <td>
550 {#if invite.disabled}
551 <span class="badge deactivated">{$_('admin.disabled')}</span>
552 {:else if invite.available === 0}
553 <span class="badge unverified">{$_('admin.exhausted')}</span>
554 {:else}
555 <span class="badge verified">{$_('admin.active')}</span>
556 {/if}
557 </td>
558 <td class="date">{formatDate(invite.createdAt)}</td>
559 <td>
560 {#if !invite.disabled}
561 <button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
562 {$_('admin.disable')}
563 </button>
564 {:else}
565 <span class="muted">-</span>
566 {/if}
567 </td>
568 </tr>
569 {/each}
570 </tbody>
571 </table>
572 {#if invitesCursor}
573 <button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
574 {invitesLoading ? $_('admin.loading') : $_('admin.loadMore')}
575 </button>
576 {/if}
577 {/if}
578 </div>
579 {/if}
580 </section>
581 {/if}
582 </div>
583 {#if selectedUser}
584 <div class="modal-overlay" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
585 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
586 <div class="modal-header">
587 <h2>{$_('admin.userDetails')}</h2>
588 <button class="close-btn" onclick={closeUserDetail}>×</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>