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