From 93ce9bf100a05a7772e59893e009199106ea17d5 Mon Sep 17 00:00:00 2001 From: Victor Ameh <64321248+Vicsygold@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:07:52 +0000 Subject: [PATCH] add protocol admin dashboard and governance actions --- src/hooks/useAdmin.ts | 160 ++++++++++++++++++++++++ src/pages/Admin.tsx | 279 ++++++++++++++++++++++++++++++++++++++++++ src/router/index.tsx | 5 + 3 files changed, 444 insertions(+) create mode 100644 src/hooks/useAdmin.ts create mode 100644 src/pages/Admin.tsx diff --git a/src/hooks/useAdmin.ts b/src/hooks/useAdmin.ts new file mode 100644 index 0000000..f247159 --- /dev/null +++ b/src/hooks/useAdmin.ts @@ -0,0 +1,160 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '../services/api' + +export interface AdminProtocolParameters { + maxLoanAmount: number + interestRate: number + repaymentWindowDays: number + lateFeeRate: number +} + +export interface AdminProtocolStats { + totalVolume: number + activeLoans: number + approvedVendors: number + pendingApprovals: number + auditEvents: number +} + +export interface PendingVendor { + id: string + name: string + walletAddress: string + category: string + submittedAt: string +} + +export interface PendingLoan { + id: string + borrower: string + vendor: string + amount: number + purpose: string + requestedAt: string +} + +export interface AuditLogEntry { + id: string + actor: string + action: string + target: string + createdAt: string +} + +export interface PaginatedAuditLogs { + data: AuditLogEntry[] + total: number + page: number + limit: number + totalPages: number +} + +export function useAdmin(page = 1) { + const queryClient = useQueryClient() + + const protocolParametersQuery = useQuery({ + queryKey: ['admin-parameters'], + queryFn: async () => { + const res = await api.get('/admin/protocol-parameters') + return res.data + }, + }) + + const protocolStatsQuery = useQuery({ + queryKey: ['admin-stats'], + queryFn: async () => { + const res = await api.get('/admin/stats') + return res.data + }, + }) + + const vendorQueueQuery = useQuery({ + queryKey: ['admin-vendor-queue'], + queryFn: async () => { + const res = await api.get('/admin/vendors/approvals') + return res.data + }, + }) + + const loanQueueQuery = useQuery({ + queryKey: ['admin-loan-queue'], + queryFn: async () => { + const res = await api.get('/admin/loans/review') + return res.data + }, + }) + + const auditLogsQuery = useQuery({ + queryKey: ['admin-audit-logs', page], + queryFn: async () => { + const res = await api.get(`/admin/audit-logs?page=${page}&limit=10`) + return res.data + }, + }) + + const updateParametersMutation = useMutation({ + mutationFn: async (params: AdminProtocolParameters) => { + const res = await api.put('/admin/protocol-parameters', params) + return res.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-parameters'] }) + queryClient.invalidateQueries({ queryKey: ['admin-stats'] }) + }, + }) + + const approveVendorMutation = useMutation({ + mutationFn: async (id: string) => { + const res = await api.post(`/admin/vendors/${id}/approve`) + return res.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-vendor-queue'] }) + queryClient.invalidateQueries({ queryKey: ['admin-stats'] }) + }, + }) + + const rejectVendorMutation = useMutation({ + mutationFn: async (id: string) => { + const res = await api.post(`/admin/vendors/${id}/reject`) + return res.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-vendor-queue'] }) + }, + }) + + const approveLoanMutation = useMutation({ + mutationFn: async (id: string) => { + const res = await api.post(`/admin/loans/${id}/approve`) + return res.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-loan-queue'] }) + queryClient.invalidateQueries({ queryKey: ['admin-stats'] }) + }, + }) + + const rejectLoanMutation = useMutation({ + mutationFn: async (id: string) => { + const res = await api.post(`/admin/loans/${id}/reject`) + return res.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-loan-queue'] }) + }, + }) + + return { + protocolParametersQuery, + protocolStatsQuery, + vendorQueueQuery, + loanQueueQuery, + auditLogsQuery, + updateParametersMutation, + approveVendorMutation, + rejectVendorMutation, + approveLoanMutation, + rejectLoanMutation, + } +} diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 0000000..598e805 --- /dev/null +++ b/src/pages/Admin.tsx @@ -0,0 +1,279 @@ +import { useEffect, useMemo, useState } from 'react' +import { AlertCircle, ChevronLeft, ChevronRight, ShieldCheck } from 'lucide-react' +import { useWallet } from '../hooks/useWallet' +import { useAdmin, type AdminProtocolParameters } from '../hooks/useAdmin' +import { Button } from '../components/ui/Button' +import { Card } from '../components/ui/Card' +import { Spinner } from '../components/ui/Spinner' + +const ADMIN_WALLETS = (import.meta.env.VITE_ADMIN_WALLETS ?? '') + .split(',') + .map((value: string) => value.trim()) + .filter(Boolean) + +function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( + +

{label}

+

{value}

+ {hint &&

{hint}

} +
+ ) +} + +export function Admin() { + const { address, isConnected } = useWallet() + const [page, setPage] = useState(1) + const [draft, setDraft] = useState({ + maxLoanAmount: 0, + interestRate: 0, + repaymentWindowDays: 0, + lateFeeRate: 0, + }) + + const { + protocolParametersQuery, + protocolStatsQuery, + vendorQueueQuery, + loanQueueQuery, + auditLogsQuery, + updateParametersMutation, + approveVendorMutation, + rejectVendorMutation, + approveLoanMutation, + rejectLoanMutation, + } = useAdmin(page) + + const isAdmin = useMemo(() => { + if (!isConnected || !address) return false + if (!ADMIN_WALLETS.length) return false + return ADMIN_WALLETS.includes(address) + }, [address, isConnected]) + + const handleDraftChange = (field: keyof AdminProtocolParameters, value: string) => { + setDraft((prev) => ({ + ...prev, + [field]: Number(value), + })) + } + + const handleSaveParameters = () => { + updateParametersMutation.mutate(draft) + } + + if (!isConnected) { + return ( +
+ +

Connect your wallet

+

Use a connected wallet to continue to the admin dashboard.

+
+ ) + } + + if (!isAdmin) { + return ( +
+ +

Access denied

+

Only authorized protocol administrators can view this page.

+
+ ) + } + + if (protocolParametersQuery.isLoading || protocolStatsQuery.isLoading) { + return ( +
+ +
+ ) + } + + useEffect(() => { + if (protocolParametersQuery.data) { + setDraft(protocolParametersQuery.data) + } + }, [protocolParametersQuery.data]) + + const stats = protocolStatsQuery.data + const auditLogs = auditLogsQuery.data + + return ( +
+
+
+

Protocol Admin

+

Administration Console

+
+ + {address.slice(0, 6)}...{address.slice(-4)} + +
+ +
+ + + + + +
+ +
+ +
+

Protocol Parameters

+ +
+
+ + + + +
+
+ + +

Vendor Approval Queue

+ {vendorQueueQuery.isLoading ? ( +
+ ) : !vendorQueueQuery.data?.length ? ( +

No vendor approvals pending.

+ ) : ( +
+ {vendorQueueQuery.data.map((vendor) => ( +
+
+

{vendor.name}

+

{vendor.category} · {vendor.walletAddress.slice(0, 8)}...

+
+
+ + +
+
+ ))} +
+ )} +
+
+ +
+ +

Loan Review Queue

+ {loanQueueQuery.isLoading ? ( +
+ ) : !loanQueueQuery.data?.length ? ( +

No loans require review.

+ ) : ( +
+ {loanQueueQuery.data.map((loan) => ( +
+
+
+

{loan.purpose}

+

{loan.borrower.slice(0, 8)}... · {loan.vendor}

+
+ ${loan.amount.toLocaleString()} +
+
+ + +
+
+ ))} +
+ )} +
+ + +
+

Audit Log

+
+ + {page} / {auditLogs?.totalPages ?? 1} + +
+
+ {auditLogsQuery.isLoading ? ( +
+ ) : !auditLogs?.data.length ? ( +

No audit log entries found.

+ ) : ( +
+ {auditLogs.data.map((entry) => ( +
+
+
+

{entry.action}

+

{entry.target}

+
+ {new Date(entry.createdAt).toLocaleString()} +
+

Actor: {entry.actor}

+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/src/router/index.tsx b/src/router/index.tsx index af430b9..faf4d63 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -10,6 +10,7 @@ import { Sponsors } from '../pages/Sponsors' import { SponsorOnboarding } from '../pages/SponsorOnboarding' import { Vouch } from '../pages/Vouch' import { LearnerProfile } from '../pages/LearnerProfile' +import { Admin } from '../pages/Admin' import { NotFound } from '../pages/NotFound' const router = createBrowserRouter([ @@ -25,6 +26,10 @@ const router = createBrowserRouter([ path: '/dashboard', element: , }, + { + path: '/admin', + element: , + }, { path: '/vendors', element: ,