this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5 const auth = getAuthState()
6 let loading = $state(true)
7 let error = $state<string | null>(null)
8 let stats = $state<{
9 userCount: number
10 repoCount: number
11 recordCount: number
12 blobStorageBytes: number
13 } | null>(null)
14 let usersLoading = $state(false)
15 let usersError = $state<string | null>(null)
16 let users = $state<Array<{
17 did: string
18 handle: string
19 email?: string
20 indexedAt: string
21 emailConfirmedAt?: string
22 deactivatedAt?: string
23 }>>([])
24 let usersCursor = $state<string | undefined>(undefined)
25 let handleSearchQuery = $state('')
26 let showUsers = $state(false)
27 let invitesLoading = $state(false)
28 let invitesError = $state<string | null>(null)
29 let invites = $state<Array<{
30 code: string
31 available: number
32 disabled: boolean
33 forAccount: string
34 createdBy: string
35 createdAt: string
36 uses: Array<{ usedBy: string; usedAt: string }>
37 }>>([])
38 let invitesCursor = $state<string | undefined>(undefined)
39 let showInvites = $state(false)
40 let selectedUser = $state<{
41 did: string
42 handle: string
43 email?: string
44 indexedAt: string
45 emailConfirmedAt?: string
46 invitesDisabled?: boolean
47 deactivatedAt?: string
48 } | null>(null)
49 let userDetailLoading = $state(false)
50 let userActionLoading = $state(false)
51 $effect(() => {
52 if (!auth.loading && !auth.session) {
53 navigate('/login')
54 } else if (!auth.loading && auth.session && !auth.session.isAdmin) {
55 navigate('/dashboard')
56 }
57 })
58 $effect(() => {
59 if (auth.session?.isAdmin) {
60 loadStats()
61 }
62 })
63 async function loadStats() {
64 if (!auth.session) return
65 loading = true
66 error = null
67 try {
68 stats = await api.getServerStats(auth.session.accessJwt)
69 } catch (e) {
70 error = e instanceof ApiError ? e.message : 'Failed to load server stats'
71 } finally {
72 loading = false
73 }
74 }
75 async function loadUsers(reset = false) {
76 if (!auth.session) return
77 usersLoading = true
78 usersError = null
79 if (reset) {
80 users = []
81 usersCursor = undefined
82 }
83 try {
84 const result = await api.searchAccounts(auth.session.accessJwt, {
85 handle: handleSearchQuery || undefined,
86 cursor: reset ? undefined : usersCursor,
87 limit: 25,
88 })
89 users = reset ? result.accounts : [...users, ...result.accounts]
90 usersCursor = result.cursor
91 showUsers = true
92 } catch (e) {
93 usersError = e instanceof ApiError ? e.message : 'Failed to load users'
94 } finally {
95 usersLoading = false
96 }
97 }
98 function handleSearch(e: Event) {
99 e.preventDefault()
100 loadUsers(true)
101 }
102 async function loadInvites(reset = false) {
103 if (!auth.session) return
104 invitesLoading = true
105 invitesError = null
106 if (reset) {
107 invites = []
108 invitesCursor = undefined
109 }
110 try {
111 const result = await api.getInviteCodes(auth.session.accessJwt, {
112 cursor: reset ? undefined : invitesCursor,
113 limit: 25,
114 })
115 invites = reset ? result.codes : [...invites, ...result.codes]
116 invitesCursor = result.cursor
117 showInvites = true
118 } catch (e) {
119 invitesError = e instanceof ApiError ? e.message : 'Failed to load invites'
120 } finally {
121 invitesLoading = false
122 }
123 }
124 async function disableInvite(code: string) {
125 if (!auth.session) return
126 if (!confirm(`Disable invite code ${code}?`)) return
127 try {
128 await api.disableInviteCodes(auth.session.accessJwt, [code])
129 invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv)
130 } catch (e) {
131 invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite'
132 }
133 }
134 async function selectUser(did: string) {
135 if (!auth.session) return
136 userDetailLoading = true
137 try {
138 selectedUser = await api.getAccountInfo(auth.session.accessJwt, did)
139 } catch (e) {
140 usersError = e instanceof ApiError ? e.message : 'Failed to load user details'
141 } finally {
142 userDetailLoading = false
143 }
144 }
145 function closeUserDetail() {
146 selectedUser = null
147 }
148 async function toggleUserInvites() {
149 if (!auth.session || !selectedUser) return
150 userActionLoading = true
151 try {
152 if (selectedUser.invitesDisabled) {
153 await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did)
154 selectedUser = { ...selectedUser, invitesDisabled: false }
155 } else {
156 await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did)
157 selectedUser = { ...selectedUser, invitesDisabled: true }
158 }
159 } catch (e) {
160 usersError = e instanceof ApiError ? e.message : 'Failed to update user'
161 } finally {
162 userActionLoading = false
163 }
164 }
165 async function deleteUser() {
166 if (!auth.session || !selectedUser) return
167 if (!confirm(`Delete account @${selectedUser.handle}? This cannot be undone.`)) return
168 userActionLoading = true
169 try {
170 await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did)
171 users = users.filter(u => u.did !== selectedUser!.did)
172 selectedUser = null
173 } catch (e) {
174 usersError = e instanceof ApiError ? e.message : 'Failed to delete user'
175 } finally {
176 userActionLoading = false
177 }
178 }
179 function formatBytes(bytes: number): string {
180 if (bytes === 0) return '0 B'
181 const k = 1024
182 const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
183 const i = Math.floor(Math.log(bytes) / Math.log(k))
184 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
185 }
186 function formatNumber(num: number): string {
187 return num.toLocaleString()
188 }
189</script>
190{#if auth.session?.isAdmin}
191 <div class="page">
192 <header>
193 <a href="#/dashboard" class="back">← Dashboard</a>
194 <h1>Admin Panel</h1>
195 </header>
196 {#if loading}
197 <p class="loading">Loading...</p>
198 {:else}
199 {#if error}
200 <div class="message error">{error}</div>
201 {/if}
202 {#if stats}
203 <section>
204 <h2>Server Statistics</h2>
205 <div class="stats-grid">
206 <div class="stat-card">
207 <div class="stat-value">{formatNumber(stats.userCount)}</div>
208 <div class="stat-label">Users</div>
209 </div>
210 <div class="stat-card">
211 <div class="stat-value">{formatNumber(stats.repoCount)}</div>
212 <div class="stat-label">Repositories</div>
213 </div>
214 <div class="stat-card">
215 <div class="stat-value">{formatNumber(stats.recordCount)}</div>
216 <div class="stat-label">Records</div>
217 </div>
218 <div class="stat-card">
219 <div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div>
220 <div class="stat-label">Blob Storage</div>
221 </div>
222 </div>
223 <button class="refresh-btn" onclick={loadStats}>Refresh Stats</button>
224 </section>
225 {/if}
226 <section>
227 <h2>User Management</h2>
228 <form class="search-form" onsubmit={handleSearch}>
229 <input
230 type="text"
231 bind:value={handleSearchQuery}
232 placeholder="Search by handle (optional)"
233 disabled={usersLoading}
234 />
235 <button type="submit" disabled={usersLoading}>
236 {usersLoading ? 'Loading...' : 'Search Users'}
237 </button>
238 </form>
239 {#if usersError}
240 <div class="message error">{usersError}</div>
241 {/if}
242 {#if showUsers}
243 <div class="user-list">
244 {#if users.length === 0}
245 <p class="no-results">No users found</p>
246 {:else}
247 <table>
248 <thead>
249 <tr>
250 <th>Handle</th>
251 <th>Email</th>
252 <th>Status</th>
253 <th>Created</th>
254 </tr>
255 </thead>
256 <tbody>
257 {#each users as user}
258 <tr class="clickable" onclick={() => selectUser(user.did)}>
259 <td class="handle">@{user.handle}</td>
260 <td class="email">{user.email || '-'}</td>
261 <td>
262 {#if user.deactivatedAt}
263 <span class="badge deactivated">Deactivated</span>
264 {:else if user.emailConfirmedAt}
265 <span class="badge verified">Verified</span>
266 {:else}
267 <span class="badge unverified">Unverified</span>
268 {/if}
269 </td>
270 <td class="date">{new Date(user.indexedAt).toLocaleDateString()}</td>
271 </tr>
272 {/each}
273 </tbody>
274 </table>
275 {#if usersCursor}
276 <button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
277 {usersLoading ? 'Loading...' : 'Load More'}
278 </button>
279 {/if}
280 {/if}
281 </div>
282 {/if}
283 </section>
284 <section>
285 <h2>Invite Codes</h2>
286 <div class="section-actions">
287 <button onclick={() => loadInvites(true)} disabled={invitesLoading}>
288 {invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'}
289 </button>
290 </div>
291 {#if invitesError}
292 <div class="message error">{invitesError}</div>
293 {/if}
294 {#if showInvites}
295 <div class="invite-list">
296 {#if invites.length === 0}
297 <p class="no-results">No invite codes found</p>
298 {:else}
299 <table>
300 <thead>
301 <tr>
302 <th>Code</th>
303 <th>Available</th>
304 <th>Uses</th>
305 <th>Status</th>
306 <th>Created</th>
307 <th>Actions</th>
308 </tr>
309 </thead>
310 <tbody>
311 {#each invites as invite}
312 <tr class:disabled-row={invite.disabled}>
313 <td class="code">{invite.code}</td>
314 <td>{invite.available}</td>
315 <td>{invite.uses.length}</td>
316 <td>
317 {#if invite.disabled}
318 <span class="badge deactivated">Disabled</span>
319 {:else if invite.available === 0}
320 <span class="badge unverified">Exhausted</span>
321 {:else}
322 <span class="badge verified">Active</span>
323 {/if}
324 </td>
325 <td class="date">{new Date(invite.createdAt).toLocaleDateString()}</td>
326 <td>
327 {#if !invite.disabled}
328 <button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
329 Disable
330 </button>
331 {:else}
332 <span class="muted">-</span>
333 {/if}
334 </td>
335 </tr>
336 {/each}
337 </tbody>
338 </table>
339 {#if invitesCursor}
340 <button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
341 {invitesLoading ? 'Loading...' : 'Load More'}
342 </button>
343 {/if}
344 {/if}
345 </div>
346 {/if}
347 </section>
348 {/if}
349 </div>
350 {#if selectedUser}
351 <div class="modal-overlay" onclick={closeUserDetail} role="presentation">
352 <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
353 <div class="modal-header">
354 <h2>User Details</h2>
355 <button class="close-btn" onclick={closeUserDetail}>×</button>
356 </div>
357 {#if userDetailLoading}
358 <p class="loading">Loading...</p>
359 {:else}
360 <div class="modal-body">
361 <dl class="user-details">
362 <dt>Handle</dt>
363 <dd>@{selectedUser.handle}</dd>
364 <dt>DID</dt>
365 <dd class="mono">{selectedUser.did}</dd>
366 <dt>Email</dt>
367 <dd>{selectedUser.email || '-'}</dd>
368 <dt>Status</dt>
369 <dd>
370 {#if selectedUser.deactivatedAt}
371 <span class="badge deactivated">Deactivated</span>
372 {:else if selectedUser.emailConfirmedAt}
373 <span class="badge verified">Verified</span>
374 {:else}
375 <span class="badge unverified">Unverified</span>
376 {/if}
377 </dd>
378 <dt>Created</dt>
379 <dd>{new Date(selectedUser.indexedAt).toLocaleString()}</dd>
380 <dt>Invites</dt>
381 <dd>
382 {#if selectedUser.invitesDisabled}
383 <span class="badge deactivated">Disabled</span>
384 {:else}
385 <span class="badge verified">Enabled</span>
386 {/if}
387 </dd>
388 </dl>
389 <div class="modal-actions">
390 <button
391 class="action-btn"
392 onclick={toggleUserInvites}
393 disabled={userActionLoading}
394 >
395 {selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'}
396 </button>
397 <button
398 class="action-btn danger"
399 onclick={deleteUser}
400 disabled={userActionLoading}
401 >
402 Delete Account
403 </button>
404 </div>
405 </div>
406 {/if}
407 </div>
408 </div>
409 {/if}
410{:else if auth.loading}
411 <div class="loading">Loading...</div>
412{/if}
413<style>
414 .page {
415 max-width: 800px;
416 margin: 0 auto;
417 padding: 2rem;
418 }
419 header {
420 margin-bottom: 2rem;
421 }
422 .back {
423 color: var(--text-secondary);
424 text-decoration: none;
425 font-size: 0.875rem;
426 }
427 .back:hover {
428 color: var(--accent);
429 }
430 h1 {
431 margin: 0.5rem 0 0 0;
432 }
433 .loading {
434 text-align: center;
435 color: var(--text-secondary);
436 padding: 2rem;
437 }
438 .message {
439 padding: 0.75rem;
440 border-radius: 4px;
441 margin-bottom: 1rem;
442 }
443 .message.error {
444 background: var(--error-bg);
445 border: 1px solid var(--error-border);
446 color: var(--error-text);
447 }
448 section {
449 background: var(--bg-secondary);
450 padding: 1.5rem;
451 border-radius: 8px;
452 margin-bottom: 1.5rem;
453 }
454 section h2 {
455 margin: 0 0 1rem 0;
456 font-size: 1.25rem;
457 }
458 .stats-grid {
459 display: grid;
460 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
461 gap: 1rem;
462 margin-bottom: 1rem;
463 }
464 .stat-card {
465 background: var(--bg-card);
466 border: 1px solid var(--border-color);
467 border-radius: 8px;
468 padding: 1rem;
469 text-align: center;
470 }
471 .stat-value {
472 font-size: 1.5rem;
473 font-weight: 600;
474 color: var(--accent);
475 }
476 .stat-label {
477 font-size: 0.875rem;
478 color: var(--text-secondary);
479 margin-top: 0.25rem;
480 }
481 .refresh-btn {
482 padding: 0.5rem 1rem;
483 background: transparent;
484 border: 1px solid var(--border-color);
485 border-radius: 4px;
486 cursor: pointer;
487 color: var(--text-primary);
488 }
489 .refresh-btn:hover {
490 background: var(--bg-card);
491 border-color: var(--accent);
492 }
493 .search-form {
494 display: flex;
495 gap: 0.5rem;
496 margin-bottom: 1rem;
497 }
498 .search-form input {
499 flex: 1;
500 padding: 0.5rem 0.75rem;
501 border: 1px solid var(--border-color);
502 border-radius: 4px;
503 font-size: 0.875rem;
504 background: var(--bg-input);
505 color: var(--text-primary);
506 }
507 .search-form input:focus {
508 outline: none;
509 border-color: var(--accent);
510 }
511 .search-form button {
512 padding: 0.5rem 1rem;
513 background: var(--accent);
514 color: white;
515 border: none;
516 border-radius: 4px;
517 cursor: pointer;
518 font-size: 0.875rem;
519 }
520 .search-form button:hover:not(:disabled) {
521 background: var(--accent-hover);
522 }
523 .search-form button:disabled {
524 opacity: 0.6;
525 cursor: not-allowed;
526 }
527 .user-list {
528 margin-top: 1rem;
529 }
530 .no-results {
531 color: var(--text-secondary);
532 text-align: center;
533 padding: 1rem;
534 }
535 table {
536 width: 100%;
537 border-collapse: collapse;
538 font-size: 0.875rem;
539 }
540 th, td {
541 padding: 0.75rem 0.5rem;
542 text-align: left;
543 border-bottom: 1px solid var(--border-color);
544 }
545 th {
546 font-weight: 600;
547 color: var(--text-secondary);
548 font-size: 0.75rem;
549 text-transform: uppercase;
550 letter-spacing: 0.05em;
551 }
552 .handle {
553 font-weight: 500;
554 }
555 .email {
556 color: var(--text-secondary);
557 }
558 .date {
559 color: var(--text-secondary);
560 font-size: 0.75rem;
561 }
562 .badge {
563 display: inline-block;
564 padding: 0.125rem 0.5rem;
565 border-radius: 4px;
566 font-size: 0.75rem;
567 }
568 .badge.verified {
569 background: var(--success-bg);
570 color: var(--success-text);
571 }
572 .badge.unverified {
573 background: var(--warning-bg);
574 color: var(--warning-text);
575 }
576 .badge.deactivated {
577 background: var(--error-bg);
578 color: var(--error-text);
579 }
580 .load-more {
581 display: block;
582 width: 100%;
583 padding: 0.75rem;
584 margin-top: 1rem;
585 background: transparent;
586 border: 1px solid var(--border-color);
587 border-radius: 4px;
588 cursor: pointer;
589 color: var(--text-primary);
590 font-size: 0.875rem;
591 }
592 .load-more:hover:not(:disabled) {
593 background: var(--bg-card);
594 border-color: var(--accent);
595 }
596 .load-more:disabled {
597 opacity: 0.6;
598 cursor: not-allowed;
599 }
600 .section-actions {
601 margin-bottom: 1rem;
602 }
603 .section-actions button {
604 padding: 0.5rem 1rem;
605 background: var(--accent);
606 color: white;
607 border: none;
608 border-radius: 4px;
609 cursor: pointer;
610 font-size: 0.875rem;
611 }
612 .section-actions button:hover:not(:disabled) {
613 background: var(--accent-hover);
614 }
615 .section-actions button:disabled {
616 opacity: 0.6;
617 cursor: not-allowed;
618 }
619 .invite-list {
620 margin-top: 1rem;
621 }
622 .code {
623 font-family: monospace;
624 font-size: 0.75rem;
625 }
626 .disabled-row {
627 opacity: 0.5;
628 }
629 .action-btn {
630 padding: 0.25rem 0.5rem;
631 font-size: 0.75rem;
632 border: none;
633 border-radius: 4px;
634 cursor: pointer;
635 }
636 .action-btn.danger {
637 background: var(--error-text);
638 color: white;
639 }
640 .action-btn.danger:hover {
641 background: #900;
642 }
643 .muted {
644 color: var(--text-muted);
645 }
646 .clickable {
647 cursor: pointer;
648 }
649 .clickable:hover {
650 background: var(--bg-card);
651 }
652 .modal-overlay {
653 position: fixed;
654 top: 0;
655 left: 0;
656 right: 0;
657 bottom: 0;
658 background: rgba(0, 0, 0, 0.5);
659 display: flex;
660 align-items: center;
661 justify-content: center;
662 z-index: 1000;
663 }
664 .modal {
665 background: var(--bg-card);
666 border-radius: 8px;
667 max-width: 500px;
668 width: 90%;
669 max-height: 90vh;
670 overflow-y: auto;
671 }
672 .modal-header {
673 display: flex;
674 justify-content: space-between;
675 align-items: center;
676 padding: 1rem 1.5rem;
677 border-bottom: 1px solid var(--border-color);
678 }
679 .modal-header h2 {
680 margin: 0;
681 font-size: 1.25rem;
682 }
683 .close-btn {
684 background: none;
685 border: none;
686 font-size: 1.5rem;
687 cursor: pointer;
688 color: var(--text-secondary);
689 padding: 0;
690 line-height: 1;
691 }
692 .close-btn:hover {
693 color: var(--text-primary);
694 }
695 .modal-body {
696 padding: 1.5rem;
697 }
698 .user-details {
699 display: grid;
700 grid-template-columns: auto 1fr;
701 gap: 0.5rem 1rem;
702 margin: 0 0 1.5rem 0;
703 }
704 .user-details dt {
705 font-weight: 500;
706 color: var(--text-secondary);
707 }
708 .user-details dd {
709 margin: 0;
710 }
711 .mono {
712 font-family: monospace;
713 font-size: 0.75rem;
714 word-break: break-all;
715 }
716 .modal-actions {
717 display: flex;
718 gap: 0.5rem;
719 flex-wrap: wrap;
720 }
721 .modal-actions .action-btn {
722 padding: 0.5rem 1rem;
723 border: 1px solid var(--border-color);
724 border-radius: 4px;
725 background: transparent;
726 cursor: pointer;
727 font-size: 0.875rem;
728 color: var(--text-primary);
729 }
730 .modal-actions .action-btn:hover:not(:disabled) {
731 background: var(--bg-secondary);
732 }
733 .modal-actions .action-btn:disabled {
734 opacity: 0.6;
735 cursor: not-allowed;
736 }
737 .modal-actions .action-btn.danger {
738 border-color: var(--error-text);
739 color: var(--error-text);
740 }
741 .modal-actions .action-btn.danger:hover:not(:disabled) {
742 background: var(--error-bg);
743 }
744</style>