chore: sync submodule state (parent ref update)
Made-with: Cursor
This commit is contained in:
36
services/token-aggregation/frontend/src/App.tsx
Normal file
36
services/token-aggregation/frontend/src/App.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import ApiKeys from './pages/ApiKeys';
|
||||
import Endpoints from './pages/Endpoints';
|
||||
import DexFactories from './pages/DexFactories';
|
||||
import Layout from './components/Layout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="api-keys" element={<ApiKeys />} />
|
||||
<Route path="endpoints" element={<Endpoints />} />
|
||||
<Route path="dex-factories" element={<DexFactories />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { LogOut, Key, Network, Factory, BarChart3 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
toast.success('Logged out successfully');
|
||||
};
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: BarChart3 },
|
||||
{ name: 'API Keys', href: '/api-keys', icon: Key },
|
||||
{ name: 'Endpoints', href: '/endpoints', icon: Network },
|
||||
{ name: 'DEX Factories', href: '/dex-factories', icon: Factory },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<h1 className="text-xl font-bold text-gray-900">Token Aggregation Control Panel</h1>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
|
||||
isActive
|
||||
? 'border-primary-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">{user?.username}</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
19
services/token-aggregation/frontend/src/index.css
Normal file
19
services/token-aggregation/frontend/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
24
services/token-aggregation/frontend/src/main.tsx
Normal file
24
services/token-aggregation/frontend/src/main.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
225
services/token-aggregation/frontend/src/pages/ApiKeys.tsx
Normal file
225
services/token-aggregation/frontend/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../services/api';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Plus, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
provider: string;
|
||||
keyName: string;
|
||||
isActive: boolean;
|
||||
rateLimitPerMinute?: number;
|
||||
rateLimitPerDay?: number;
|
||||
expiresAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function ApiKeys() {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ apiKeys: ApiKey[] }>({
|
||||
queryKey: ['api-keys'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/v1/admin/api-keys');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.delete(`/api/v1/admin/api-keys/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
toast.success('API key deleted');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete API key');
|
||||
},
|
||||
});
|
||||
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
|
||||
await api.put(`/api/v1/admin/api-keys/${id}`, { isActive: !isActive });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
toast.success('API key updated');
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-12">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">API Keys</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">Manage external API keys for data enrichment</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{data?.apiKeys.map((key) => (
|
||||
<li key={key.id} className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-900">{key.keyName}</span>
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{key.provider}
|
||||
</span>
|
||||
{key.isActive ? (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
Created: {new Date(key.createdAt).toLocaleDateString()}
|
||||
{key.expiresAt && ` • Expires: ${new Date(key.expiresAt).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => toggleActiveMutation.mutate({ id: key.id, isActive: key.isActive })}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{key.isActive ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(key.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data?.apiKeys.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">No API keys configured</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddModal && (
|
||||
<AddApiKeyModal
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddApiKeyModal({ onClose }: { onClose: () => void }) {
|
||||
const [formData, setFormData] = useState({
|
||||
provider: 'coingecko',
|
||||
keyName: '',
|
||||
apiKey: '',
|
||||
rateLimitPerMinute: '',
|
||||
rateLimitPerDay: '',
|
||||
expiresAt: '',
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await api.post('/api/v1/admin/api-keys', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('API key added');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to add API key');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate({
|
||||
...formData,
|
||||
rateLimitPerMinute: formData.rateLimitPerMinute ? parseInt(formData.rateLimitPerMinute, 10) : undefined,
|
||||
rateLimitPerDay: formData.rateLimitPerDay ? parseInt(formData.rateLimitPerDay, 10) : undefined,
|
||||
expiresAt: formData.expiresAt || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Add API Key</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Provider</label>
|
||||
<select
|
||||
value={formData.provider}
|
||||
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="coingecko">CoinGecko</option>
|
||||
<option value="coinmarketcap">CoinMarketCap</option>
|
||||
<option value="dexscreener">DexScreener</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Key Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.keyName}
|
||||
onChange={(e) => setFormData({ ...formData, keyName: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
services/token-aggregation/frontend/src/pages/Dashboard.tsx
Normal file
100
services/token-aggregation/frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../services/api';
|
||||
import { Key, Network, Factory, Activity } from 'lucide-react';
|
||||
|
||||
interface StatusData {
|
||||
status: string;
|
||||
stats: {
|
||||
apiKeys: { total: number; active: number };
|
||||
endpoints: { total: number; active: number };
|
||||
factories: { total: number; active: number };
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data, isLoading } = useQuery<StatusData>({
|
||||
queryKey: ['admin-status'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/v1/admin/status');
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-12">Loading...</div>;
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'API Keys',
|
||||
value: data?.stats.apiKeys.active || 0,
|
||||
total: data?.stats.apiKeys.total || 0,
|
||||
icon: Key,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
name: 'Endpoints',
|
||||
value: data?.stats.endpoints.active || 0,
|
||||
total: data?.stats.endpoints.total || 0,
|
||||
icon: Network,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
name: 'DEX Factories',
|
||||
value: data?.stats.factories.active || 0,
|
||||
total: data?.stats.factories.total || 0,
|
||||
icon: Factory,
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">Service status and statistics</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3 mb-8">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.name} className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className={`flex-shrink-0 ${stat.color} rounded-md p-3`}>
|
||||
<Icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">{stat.name}</dt>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-semibold text-gray-900">{stat.value}</div>
|
||||
<div className="ml-2 text-sm text-gray-500">of {stat.total}</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Service Status</h3>
|
||||
<div className="flex items-center">
|
||||
<Activity
|
||||
className={`h-5 w-5 mr-2 ${
|
||||
data?.status === 'operational' ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Status: {data?.status || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
services/token-aggregation/frontend/src/pages/DexFactories.tsx
Normal file
256
services/token-aggregation/frontend/src/pages/DexFactories.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../services/api';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
interface DexFactory {
|
||||
id: number;
|
||||
chainId: number;
|
||||
dexType: string;
|
||||
factoryAddress: string;
|
||||
routerAddress?: string;
|
||||
poolManagerAddress?: string;
|
||||
startBlock: number;
|
||||
isActive: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function DexFactories() {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ factories: DexFactory[] }>({
|
||||
queryKey: ['dex-factories'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/v1/admin/dex-factories');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.put(`/api/v1/admin/dex-factories/${id}`, { isActive: false });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dex-factories'] });
|
||||
toast.success('DEX factory deactivated');
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-12">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">DEX Factories</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">Manage DEX factory addresses for pool discovery</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Factory
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{data?.factories.map((factory) => (
|
||||
<li key={factory.id} className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-900">{factory.dexType}</span>
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Chain {factory.chainId}
|
||||
</span>
|
||||
{factory.isActive ? (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
Factory: {factory.factoryAddress}
|
||||
{factory.routerAddress && ` • Router: ${factory.routerAddress}`}
|
||||
{factory.poolManagerAddress && ` • Pool Manager: ${factory.poolManagerAddress}`}
|
||||
</div>
|
||||
{factory.description && (
|
||||
<div className="mt-1 text-sm text-gray-400">{factory.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(factory.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data?.factories.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">No DEX factories configured</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddModal && (
|
||||
<AddFactoryModal
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['dex-factories'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddFactoryModal({ onClose }: { onClose: () => void }) {
|
||||
const [formData, setFormData] = useState({
|
||||
chainId: '138',
|
||||
dexType: 'uniswap_v2',
|
||||
factoryAddress: '',
|
||||
routerAddress: '',
|
||||
poolManagerAddress: '',
|
||||
startBlock: '0',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await api.post('/api/v1/admin/dex-factories', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('DEX factory added');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to add factory');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate({
|
||||
...formData,
|
||||
chainId: parseInt(formData.chainId, 10),
|
||||
startBlock: parseInt(formData.startBlock, 10),
|
||||
routerAddress: formData.routerAddress || undefined,
|
||||
poolManagerAddress: formData.poolManagerAddress || undefined,
|
||||
description: formData.description || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Add DEX Factory</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Chain ID</label>
|
||||
<select
|
||||
value={formData.chainId}
|
||||
onChange={(e) => setFormData({ ...formData, chainId: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="138">138 (DeFi Oracle Meta Mainnet)</option>
|
||||
<option value="651940">651940 (ALL Mainnet)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">DEX Type</label>
|
||||
<select
|
||||
value={formData.dexType}
|
||||
onChange={(e) => setFormData({ ...formData, dexType: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="uniswap_v2">Uniswap V2</option>
|
||||
<option value="uniswap_v3">Uniswap V3</option>
|
||||
<option value="dodo">DODO</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Factory Address</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.factoryAddress}
|
||||
onChange={(e) => setFormData({ ...formData, factoryAddress: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
{formData.dexType !== 'dodo' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Router Address (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.routerAddress}
|
||||
onChange={(e) => setFormData({ ...formData, routerAddress: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{formData.dexType === 'dodo' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Pool Manager Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.poolManagerAddress}
|
||||
onChange={(e) => setFormData({ ...formData, poolManagerAddress: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Start Block</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.startBlock}
|
||||
onChange={(e) => setFormData({ ...formData, startBlock: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Description (optional)</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
services/token-aggregation/frontend/src/pages/Endpoints.tsx
Normal file
230
services/token-aggregation/frontend/src/pages/Endpoints.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../services/api';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Plus, Trash2, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface ApiEndpoint {
|
||||
id: number;
|
||||
chainId: number;
|
||||
endpointType: string;
|
||||
endpointName: string;
|
||||
endpointUrl: string;
|
||||
isPrimary: boolean;
|
||||
isActive: boolean;
|
||||
healthCheckStatus?: string;
|
||||
lastHealthCheck?: string;
|
||||
}
|
||||
|
||||
export default function Endpoints() {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ endpoints: ApiEndpoint[] }>({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/v1/admin/endpoints');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.put(`/api/v1/admin/endpoints/${id}`, { isActive: false });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
toast.success('Endpoint deactivated');
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-12">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">API Endpoints</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">Manage RPC and API endpoints for chains</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{data?.endpoints.map((endpoint) => (
|
||||
<li key={endpoint.id} className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-900">{endpoint.endpointName}</span>
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Chain {endpoint.chainId}
|
||||
</span>
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{endpoint.endpointType}
|
||||
</span>
|
||||
{endpoint.isPrimary && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
{endpoint.healthCheckStatus && (
|
||||
<span className="ml-2">
|
||||
{endpoint.healthCheckStatus === 'healthy' ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">{endpoint.endpointUrl}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(endpoint.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data?.endpoints.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">No endpoints configured</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddModal && (
|
||||
<AddEndpointModal
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddEndpointModal({ onClose }: { onClose: () => void }) {
|
||||
const [formData, setFormData] = useState({
|
||||
chainId: '138',
|
||||
endpointType: 'rpc',
|
||||
endpointName: '',
|
||||
endpointUrl: '',
|
||||
isPrimary: false,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await api.post('/api/v1/admin/endpoints', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Endpoint added');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to add endpoint');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate({
|
||||
...formData,
|
||||
chainId: parseInt(formData.chainId, 10),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Add Endpoint</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Chain ID</label>
|
||||
<select
|
||||
value={formData.chainId}
|
||||
onChange={(e) => setFormData({ ...formData, chainId: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="138">138 (DeFi Oracle Meta Mainnet)</option>
|
||||
<option value="651940">651940 (ALL Mainnet)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Endpoint Type</label>
|
||||
<select
|
||||
value={formData.endpointType}
|
||||
onChange={(e) => setFormData({ ...formData, endpointType: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="rpc">RPC</option>
|
||||
<option value="explorer">Explorer</option>
|
||||
<option value="indexer">Indexer</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Endpoint Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.endpointName}
|
||||
onChange={(e) => setFormData({ ...formData, endpointName: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Endpoint URL</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={formData.endpointUrl}
|
||||
onChange={(e) => setFormData({ ...formData, endpointUrl: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPrimary"
|
||||
checked={formData.isPrimary}
|
||||
onChange={(e) => setFormData({ ...formData, isPrimary: e.target.checked })}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="isPrimary" className="ml-2 block text-sm text-gray-900">
|
||||
Set as primary endpoint
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
services/token-aggregation/frontend/src/pages/Login.tsx
Normal file
87
services/token-aggregation/frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/v1/admin/auth/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
login(response.data.token, response.data.user);
|
||||
toast.success('Login successful');
|
||||
navigate('/');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Token Aggregation Control Panel
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to manage API keys and endpoints
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
services/token-aggregation/frontend/src/services/api.ts
Normal file
38
services/token-aggregation/frontend/src/services/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (authStorage) {
|
||||
try {
|
||||
const parsed = JSON.parse(authStorage);
|
||||
if (parsed.state?.token) {
|
||||
config.headers.Authorization = `Bearer ${parsed.state.token}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth-storage');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
35
services/token-aggregation/frontend/src/stores/authStore.ts
Normal file
35
services/token-aggregation/frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
} | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string, user: any) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
login: (token, user) => {
|
||||
set({ token, user, isAuthenticated: true });
|
||||
},
|
||||
logout: () => {
|
||||
set({ token: null, user: null, isAuthenticated: false });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user