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