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