diff --git a/src/Managing.WebApp/src/app/routes/index.tsx b/src/Managing.WebApp/src/app/routes/index.tsx index 3c196cdd..ef0b7092 100644 --- a/src/Managing.WebApp/src/app/routes/index.tsx +++ b/src/Managing.WebApp/src/app/routes/index.tsx @@ -6,9 +6,11 @@ import DeskWidget from '../../pages/desk/deskWidget' import Scenario from '../../pages/scenarioPage/scenario' import Tools from '../../pages/toolsPage/tools' +const Admin = lazy(() => import('../../pages/adminPage/admin')) const Backtest = lazy(() => import('../../pages/backtestPage/backtest')) const Bots = lazy(() => import('../../pages/botsPage/bots')) const Dashboard = lazy(() => import('../../pages/dashboardPage/dashboard')) +const Monitoring = lazy(() => import('../../pages/monitoringPage/monitoring')) const Settings = lazy(() => import('../../pages/settingsPage/settings')) const MyRoutes = () => { @@ -58,6 +60,28 @@ const MyRoutes = () => { /> + }> + + + + } + /> + + + }> + + + + } + /> + + }> void + account: Account +} + +const GmxPositionModal: React.FC = ({ + isOpen, + onClose, + account, +}) => { + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const { apiUrl } = useApiUrlStore() + const client = new TradingClient({}, apiUrl) + const [selectedTicker, setSelectedTicker] = useState(Ticker.BTC) + const [selectedDirection, setSelectedDirection] = useState( + TradeDirection.Long + ) + const { register, handleSubmit } = useForm() + + function setSelectedDirectionEvent(e: React.ChangeEvent) { + setSelectedDirection(e.target.value as TradeDirection) + } + + function setSelectedTickerEvent(e: React.ChangeEvent) { + setSelectedTicker(e.target.value as Ticker) + } + + const onSubmit: SubmitHandler = async (form) => { + const t = new Toast(`Opening ${form.direction} ${form.ticker} on ${account.name}`) + setIsLoading(true) + setError(null) + + try { + await client.trading_Trade( + account.name, + null, // moneyManagementName - assuming none selected + form.direction, + form.ticker, + form.riskLevel, + false, // isForPaperTrading - adjust if needed + form.stopLoss, // Pass stopLoss price to openPrice parameter + undefined // moneyManagement - not passing the full object + ) + t.update('success', 'Position opened successfully') + onClose() // Close modal on success + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'An unknown error occurred' + setError(errorMessage) + t.update('error', `Error: ${errorMessage}`) + } finally { + setIsLoading(false) + } + } + + const modalContent = ( + <> + {isLoading ? ( +
+ +

Opening GMX position...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( + <> +
+

+ Account: {account.name} +

+
+ +
+
+ + + + + + + + + +
+ +
+ Add TP/SL +
+
+ + + +
+
+ +
+
+ +
+ {/* Optionally keep or adjust info message */} + {/*
*/} + {/*

*/} + {/* Note: Ensure account has sufficient margin.*/} + {/*

*/} + {/*
*/} +
+ + )} + + ) + + return ( + + {modalContent} + + ) +} + +export default GmxPositionModal + diff --git a/src/Managing.WebApp/src/pages/adminPage/account/PrivyDelegationModal.tsx b/src/Managing.WebApp/src/pages/adminPage/account/PrivyDelegationModal.tsx new file mode 100644 index 00000000..cab3ad38 --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/account/PrivyDelegationModal.tsx @@ -0,0 +1,466 @@ +import React, {useEffect, useState} from 'react' +import {useDelegatedActions, usePrivy, useWallets, WalletWithMetadata} from '@privy-io/react-auth' +import {Account} from '../../../generated/ManagingApi' +import canonicalize from 'canonicalize' // Support JSON canonicalization +import elliptic from 'elliptic' // Elliptic curve cryptography for browsers +import * as base64 from 'base64-js' // Base64 encoding/decoding +import Modal from '../../../components/mollecules/Modal/Modal' + +// Replace with your app configuration - these should come from environment variables in a real app +const PRIVY_APP_ID = 'insert-your-app-id' +const PRIVY_APP_SECRET = 'insert-your-app-secret' // Don't hardcode in production! +const PRIVY_AUTHORIZATION_KEY = 'wallet-auth:insert-your-private-key-here' // Don't hardcode in production! + +// Initialize elliptic curve - P-256 is the curve used by Privy (NIST P-256) +const EC = new elliptic.ec('p256') + +interface PrivyDelegationModalProps { + isOpen: boolean + onClose: () => void + account: Account +} + +// Use Web Crypto API to hash the message with SHA-256 +async function sha256Hash(message: Uint8Array): Promise { + const hashBuffer = await crypto.subtle.digest('SHA-256', message) + return new Uint8Array(hashBuffer) +} + +async function generateAuthorizationSignature({url, body}: {url: string; body: object}) { + try { + // First, canonicalize the request body - this is crucial for consistent signatures + const canonicalizedBody = JSON.parse(canonicalize(body) as string); + + // Create the payload object that follows Privy's specification + const payload = { + version: 1, + method: 'POST', + url, + body: canonicalizedBody, // Use the canonicalized body here + headers: { + 'privy-app-id': PRIVY_APP_ID + } + }; + + // JSON-canonicalize the payload to ensure deterministic serialization + const serializedPayload = canonicalize(payload) as string; + + console.log('Canonicalized payload:', serializedPayload); + + // Extract private key from the authorization key by removing the prefix + const privateKeyBase64 = PRIVY_AUTHORIZATION_KEY.replace('wallet-auth:', ''); + + // Decode the base64 private key to get the raw bytes + const privateKeyBytes = base64.toByteArray(privateKeyBase64); + + // Import the private key into elliptic + const privateKey = EC.keyFromPrivate(privateKeyBytes); + + // Convert the serialized payload to UTF-8 bytes + const messageBytes = new TextEncoder().encode(serializedPayload); + + // Hash the message using SHA-256 with Web Crypto API + const messageHash = await sha256Hash(messageBytes); + + // Sign the hash with the private key - use Uint8Array instead of Buffer + // Using the array directly instead of creating a Buffer + const signature = privateKey.sign(Array.from(messageHash)); + + // Convert signature to the DER format and then to base64 + const signatureDer = signature.toDER(); + const signatureBase64 = base64.fromByteArray(new Uint8Array(signatureDer)); + + return { + payload: serializedPayload, + signature: signatureBase64, + body: canonicalizedBody, // Return the canonicalized body + canonicalizedBodyString: canonicalize(canonicalizedBody) as string // Return the string form too + }; + } catch (err) { + console.error('Error generating signature:', err); + throw err; + } +} + +// Function to send the actual request with the generated signature +async function sendPrivyRequest(url: string, body: any, signature: string): Promise { + try { + // Create basic auth token for Privy API + const authToken = btoa(`${PRIVY_APP_ID}:${PRIVY_APP_SECRET}`); + + // Set up the request headers + const headers = { + 'Authorization': `Basic ${authToken}`, + 'privy-app-id': PRIVY_APP_ID, + 'privy-authorization-signature': signature, + 'Content-Type': 'application/json' + }; + + console.log('Sending request with headers:', headers); + console.log('Body:', body); + + // Make the API call + const response = await fetch(url, { + method: 'POST', + headers: headers, + body: body // Use the exact same canonicalized body string + }); + + // Get the response body + const responseText = await response.text(); + + // Try to parse as JSON if possible + try { + const responseJson = JSON.parse(responseText); + + if (!response.ok) { + throw new Error(`API error (${response.status}): ${JSON.stringify(responseJson)}`); + } + + return responseJson; + } catch (parseError) { + // If not valid JSON, return as text + if (!response.ok) { + throw new Error(`API error (${response.status}): ${responseText}`); + } + + return { text: responseText }; + } + } catch (error) { + console.error('Error sending Privy request:', error); + throw error; + } +} + +const PrivyDelegationModal: React.FC = ({ + isOpen, + onClose, + account, +}) => { + const [isDelegated, setIsDelegated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [signatureResult, setSignatureResult] = useState<{ + payload: string, + signature: string, + body?: any, + canonicalizedBodyString?: string + } | null>(null) + const [isSigningMessage, setIsSigningMessage] = useState(false) + const [isSendingRequest, setIsSendingRequest] = useState(false) + const [messageToSign, setMessageToSign] = useState('Hello, Ethereum') + const [apiResponse, setApiResponse] = useState(null) + + const { delegateWallet, revokeWallets } = useDelegatedActions() + const { user } = usePrivy() + const { ready, wallets } = useWallets() + + // Find the embedded wallet to delegate from the array of the user's wallets + const walletToDelegate = wallets.find( + (wallet) => wallet.walletClientType === 'privy' && wallet.address === account.key + ) + + useEffect(() => { + if (user && ready) { + checkDelegationStatus() + } + }, [user, ready]) + + const checkDelegationStatus = () => { + setIsLoading(true) + setError(null) + + try { + // Check if the wallet is already delegated by inspecting the user's linked accounts + const delegatedWallet = user?.linkedAccounts.find( + (linkedAccount): linkedAccount is WalletWithMetadata => + linkedAccount.type === 'wallet' && + linkedAccount.delegated && + linkedAccount.address.toLowerCase() === account.key?.toLowerCase() + ) + + setIsDelegated(!!delegatedWallet) + } catch (err: any) { + console.error('Error checking delegation status:', err) + setError(`Failed to check delegation status: ${err.message}`) + } finally { + setIsLoading(false) + } + } + + const handleRequestDelegation = async () => { + try { + setIsLoading(true) + setError(null) + + if (!walletToDelegate) { + throw new Error('No embedded wallet found to delegate') + } + + // Fixes the linter error by using the correct parameter type + await delegateWallet({ + address: walletToDelegate.address, + chainType: 'ethereum' + }) + + setIsDelegated(true) + } catch (err: any) { + console.error('Error requesting delegation:', err) + setError(`Failed to request delegation: ${err.message}`) + } finally { + setIsLoading(false) + } + } + + const handleRevokeDelegation = async () => { + try { + setIsLoading(true) + setError(null) + + if (!walletToDelegate) { + throw new Error('No embedded wallet found to revoke delegation') + } + + // Fix for linter error - revokeWallets doesn't take parameters + await revokeWallets() + + setIsDelegated(false) + } catch (err: any) { + console.error('Error revoking delegation:', err) + setError(`Failed to revoke delegation: ${err.message}`) + } finally { + setIsLoading(false) + } + } + + const handleSignMessage = async () => { + try { + setIsSigningMessage(true) + setSignatureResult(null) + setApiResponse(null) + setError(null) + + if (!walletToDelegate) { + throw new Error('No embedded wallet found to sign with') + } + + // Prepare the request body + const body = { + address: walletToDelegate.address, + chain_type: 'ethereum', + method: 'personal_sign', + params: { + message: messageToSign, + encoding: 'utf-8' + } + } + + // Generate the signature using browser crypto + const result = await generateAuthorizationSignature({ + url: 'https://auth.privy.io/api/v1/wallets/rpc', + body + }) + + setSignatureResult(result) + + // For debugging in the console + console.log("Generated Authorization Signature:", result.signature); + console.log("Payload:", result.payload); + console.log("Body:", result.body); + console.log("Canonicalized Body String:", result.canonicalizedBodyString); + + // Create a curl command that could be used to make the request + // Note: We're using the canonicalized body from the result to ensure it matches what was signed + const curlCommand = `curl --request POST https://auth.privy.io/api/v1/wallets/rpc \\ +-u "${PRIVY_APP_ID}:YOUR_APP_SECRET" \\ +-H "privy-app-id: ${PRIVY_APP_ID}" \\ +-H "privy-authorization-signature: ${result.signature}" \\ +-H 'Content-Type: application/json' \\ +-d '${result.canonicalizedBodyString}'`; + + console.log("Curl command for testing:"); + console.log(curlCommand); + + } catch (err: any) { + console.error('Error signing message:', err) + setError(`Failed to sign message: ${err.message}`) + } finally { + setIsSigningMessage(false) + } + } + + const handleSendRequest = async () => { + if (!signatureResult?.canonicalizedBodyString || !signatureResult?.signature) { + setError('No signature available. Please generate a signature first.'); + return; + } + + try { + setIsSendingRequest(true); + setApiResponse(null); + setError(null); + + // Send the actual request using the same canonicalized body that was used for signature + const response = await sendPrivyRequest( + 'https://auth.privy.io/api/v1/wallets/rpc', + signatureResult.canonicalizedBodyString, + signatureResult.signature + ); + + setApiResponse(response); + console.log('API Response:', response); + } catch (err: any) { + console.error('Error sending request:', err); + setError(`Failed to send request: ${err.message}`); + } finally { + setIsSendingRequest(false); + } + }; + + // Modal content to be passed to the Modal component + const modalContent = ( + <> + {isLoading ? ( +
+ +

Checking delegation status...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( + <> +
+

+ Wallet Address: {account.key} +

+

+ Delegation Status:{' '} + + {isDelegated ? 'Enabled' : 'Disabled'} + +

+
+ +
+ {isDelegated ? ( + + ) : ( + + )} +
+ +
Message Signing
+ +
+ +
+ +
+ + + {signatureResult && ( + + )} +
+ + {signatureResult && ( +
+
+ Generated Signature: +
+
+
{signatureResult.signature}
+
+
+ )} + + {apiResponse && ( +
+
+ API Response: +
+
+
+                  {JSON.stringify(apiResponse, null, 2)}
+                
+
+
+ )} + +
+
+

+ Note: This component ensures that the exact same canonicalized payload is used + for both signature generation and request sending, maintaining signature integrity. +

+
+
+ + )} + + ) + + return ( + + {modalContent} + + ) +} + +export default PrivyDelegationModal + diff --git a/src/Managing.WebApp/src/pages/adminPage/account/SendTokenModal.tsx b/src/Managing.WebApp/src/pages/adminPage/account/SendTokenModal.tsx new file mode 100644 index 00000000..dacc54db --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/account/SendTokenModal.tsx @@ -0,0 +1,233 @@ +import React, {useState} from 'react' +import type {SubmitHandler} from 'react-hook-form' +import {useForm} from 'react-hook-form' +import {Account, AccountClient, Ticker,} from '../../../generated/ManagingApi' +import Modal from '../../../components/mollecules/Modal/Modal' +import useApiUrlStore from '../../../app/store/apiStore' +import {FormInput, Toast} from '../../../components/mollecules' +import {useApiError} from '../../../hooks/useApiError' + +interface SendTokenModalProps { + isOpen: boolean + onClose: () => void + account: Account + fromTicker: Ticker + availableAmount: number +} + +interface SendTokenFormInput { + recipientAddress: string + ticker: Ticker + amount: number + chainId?: number +} + +const SendTokenModal: React.FC = ({ + isOpen, + onClose, + account, + fromTicker, + availableAmount, +}) => { + const [isLoading, setIsLoading] = useState(false) + const { error, setError, handleApiErrorWithToast } = useApiError() + const { apiUrl } = useApiUrlStore() + const client = new AccountClient({}, apiUrl) + + const { register, handleSubmit, watch, setValue } = useForm({ + defaultValues: { + recipientAddress: '', + ticker: fromTicker, + amount: availableAmount * 0.1, // Start with 10% of available amount + chainId: 42161, // Default to ARBITRUM + } + }) + + const watchedAmount = watch('amount') + const watchedRecipientAddress = watch('recipientAddress') + + const onSubmit: SubmitHandler = async (form) => { + const t = new Toast(`Sending ${form.amount} ${form.ticker} to ${form.recipientAddress} from ${account.name}`) + setIsLoading(true) + setError(null) + + try { + const result = await client.account_SendToken( + account.name, + { + recipientAddress: form.recipientAddress, + ticker: form.ticker, + amount: form.amount, + chainId: form.chainId, + } + ) + + if (result.success) { + t.update('success', `Token sent successfully! Hash: ${result.hash}`) + onClose() + } else { + console.log(result) + const errorMessage = result.error || result.message || 'Send token failed' + setError(errorMessage) + t.update('error', `Send token failed: ${errorMessage}`) + } + } catch (err) { + handleApiErrorWithToast(err, t) + } finally { + setIsLoading(false) + } + } + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault() + handleSubmit(onSubmit)(e) + } + + const modalContent = ( + <> + {isLoading ? ( +
+ +

Processing token transfer...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( + <> +
+

+ Account: {account.name} +

+

+ Token: {fromTicker} +

+

+ Available: {availableAmount.toFixed(6)} {fromTicker} +

+
+ +
+
+ + + + + + + + + +
+ +
+ { + const value = parseFloat(e.target.value) + setValue('amount', value) + }} + /> + +
+ {watchedAmount && availableAmount > 0 ? ( + {((watchedAmount / availableAmount) * 100).toFixed(1)}% of available balance + ) : ( + 0% of available balance + )} +
+
+
+
+ + + + + + +
+
+ +
+
+

+ Warning: This will send tokens directly to the specified address. Make sure the address is correct as this action cannot be undone. +

+
+
+ + )} + + ) + + return ( + + {modalContent} + + ) +} + +export default SendTokenModal + diff --git a/src/Managing.WebApp/src/pages/adminPage/account/SwapModal.tsx b/src/Managing.WebApp/src/pages/adminPage/account/SwapModal.tsx new file mode 100644 index 00000000..fd9e38db --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/account/SwapModal.tsx @@ -0,0 +1,322 @@ +import React, {useState} from 'react' +import type {SubmitHandler} from 'react-hook-form' +import {useForm} from 'react-hook-form' +import {Account, AccountClient, Ticker,} from '../../../generated/ManagingApi' +import Modal from '../../../components/mollecules/Modal/Modal' +import useApiUrlStore from '../../../app/store/apiStore' +import {FormInput, Toast} from '../../../components/mollecules' +import {useApiError} from '../../../hooks/useApiError' + +interface SwapModalProps { + isOpen: boolean + onClose: () => void + account: Account + fromTicker: Ticker + availableAmount: number +} + +interface SwapFormInput { + fromTicker: Ticker + toTicker: Ticker + amount: number + orderType: string + triggerRatio?: number + allowedSlippage: number +} + +interface ValidationErrorResponse { + type: string + title: string + status: number + errors: Record + traceId: string +} + +const SwapModal: React.FC = ({ + isOpen, + onClose, + account, + fromTicker, + availableAmount, + }) => { + const [isLoading, setIsLoading] = useState(false) + const [validationErrors, setValidationErrors] = useState>({}) + const {error, setError, handleApiErrorWithToast} = useApiError() + const {apiUrl} = useApiUrlStore() + const client = new AccountClient({}, apiUrl) + const [selectedToTicker, setSelectedToTicker] = useState(Ticker.USDC) + const [selectedOrderType, setSelectedOrderType] = useState('market') + + const {register, handleSubmit, watch, setValue} = useForm({ + defaultValues: { + fromTicker: fromTicker, + toTicker: Ticker.USDC, + amount: availableAmount * 0.1, // Start with 10% of available amount + orderType: 'market', + allowedSlippage: 0.5, + } + }) + + const watchedAmount = watch('amount') + + function setSelectedToTickerEvent(e: React.ChangeEvent) { + setSelectedToTicker(e.target.value as Ticker) + } + + function setSelectedOrderTypeEvent(e: React.ChangeEvent) { + setSelectedOrderType(e.target.value) + } + + const onSubmit: SubmitHandler = async (form) => { + const t = new Toast(`Swapping ${form.amount} ${form.fromTicker} to ${form.toTicker} on ${account.name}`) + setIsLoading(true) + setError(null) + setValidationErrors({}) + + try { + const result = await client.account_SwapGmxTokens( + account.name, + { + fromTicker: form.fromTicker, + toTicker: form.toTicker, + amount: form.amount, + orderType: form.orderType, + triggerRatio: form.triggerRatio, + allowedSlippage: form.allowedSlippage, + } + ) + + if (result.success) { + t.update('success', `Swap successful! Hash: ${result.hash}`) + onClose() + } else { + console.log(result) + const errorMessage = result.error || result.message || 'Swap failed' + setError(errorMessage) + t.update('error', `Swap failed: ${errorMessage}`) + } + } catch (err: any) { + // Handle validation errors from API + if (err.response?.data && typeof err.response.data === 'object') { + const errorData = err.response.data as ValidationErrorResponse + console.log(errorData) + if (errorData.errors && typeof errorData.errors === 'object') { + setValidationErrors(errorData.errors) + const errorMessages = Object.values(errorData.errors).flat() + const errorMessage = errorMessages.join(', ') + setError(errorMessage) + t.update('error', `Validation failed: ${errorMessage}`) + } else { + handleApiErrorWithToast(err, t) + } + } else { + handleApiErrorWithToast(err, t) + } + } finally { + setIsLoading(false) + } + } + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault() + await handleSubmit(onSubmit)(e) + } + + const modalContent = ( + <> + {isLoading ? ( +
+ +

Processing swap...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( + <> +
+

+ Account: {account.name} +

+

+ From: {fromTicker} +

+
+ +
+ {Object.keys(validationErrors).length > 0 && ( +
+
+

Validation Errors:

+ {Object.entries(validationErrors).map(([field, errors]) => ( +
+ {field}: {errors.join(', ')} +
+ ))} +
+
+ )} +
+ + + + + +
+ + {validationErrors.Amount && ( +
+ {validationErrors.Amount.map((error, index) => ( +
{error}
+ ))} +
+ )} +
+ { + const value = parseFloat(e.target.value) + setValue('amount', value) + }} + /> + +
+ {watchedAmount && availableAmount > 0 ? ( + {((watchedAmount / availableAmount) * 100).toFixed(1)}% of available balance + ) : ( + 0% of available balance + )} +
+
+
+
+ + + + + + + + {validationErrors.AllowedSlippage && ( +
+ {validationErrors.AllowedSlippage.map((error, index) => ( +
{error}
+ ))} +
+ )} +
+ + {selectedOrderType === 'limit' && ( + + + {validationErrors.TriggerRatio && ( +
+ {validationErrors.TriggerRatio.map((error, index) => ( +
{error}
+ ))} +
+ )} +
+ )} + + +
+
+ +
+
+

+ Note: Ensure account has sufficient balance for the swap. +

+
+
+ + )} + + ) + + return ( + + {modalContent} + + ) +} + +export default SwapModal + diff --git a/src/Managing.WebApp/src/pages/adminPage/account/accountModal.tsx b/src/Managing.WebApp/src/pages/adminPage/account/accountModal.tsx new file mode 100644 index 00000000..f2816387 --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/account/accountModal.tsx @@ -0,0 +1,175 @@ +import {useState} from 'react' +import type {SubmitHandler} from 'react-hook-form' +import {useForm} from 'react-hook-form' + +import useApiUrlStore from '../../../app/store/apiStore' +import {Modal, Toast} from '../../../components/mollecules' +import type {Account} from '../../../generated/ManagingApi' +import {AccountClient, AccountType, TradingExchanges,} from '../../../generated/ManagingApi' +import type {IAccountFormInput, IModalProps} from '../../../global/type.tsx' + +const AccountModal: React.FC = ({ showModal, toggleModal }) => { + const [selectedExchange, setSelectedExchange] = useState() + const [selectedType, setSelectedType] = useState() + const { register, handleSubmit } = useForm() + const { apiUrl } = useApiUrlStore() + + async function createMoneyManagement(form: IAccountFormInput) { + const t = new Toast('Creating account') + const client = new AccountClient({}, apiUrl) + const a: Account = { + exchange: form.exchange, + key: form.key, + name: form.name, + secret: form.secret, + type: form.type, + } + await client + .account_PostAccount(a) + .then(() => { + t.update('success', 'Account created') + }) + .catch((err) => { + t.update('error', 'Error :' + err) + }) + } + const onSubmit: SubmitHandler = async (form) => { + // @ts-ignore + toggleModal() + await createMoneyManagement(form) + } + + function setSelectedExchangeEvent(e: any) { + setSelectedExchange(e.target.value) + } + + function setSelectedTypeEvent(e: any) { + setSelectedType(e.target.value) + } + + return ( +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + {(selectedExchange != TradingExchanges.Evm && selectedType != AccountType.Trader ) || + (selectedExchange == TradingExchanges.Evm && selectedType == AccountType.Privy )? ( + <> +
+
+ + +
+
+ +
+
+ + +
+
+ + ) : null} + + {selectedExchange == TradingExchanges.Evm && + selectedType == AccountType.Watch ? ( + <> +
+
+ + +
+
+ + ) : null} + +
+ +
+
+
+ ) +} + +export default AccountModal + diff --git a/src/Managing.WebApp/src/pages/adminPage/account/accountRowDetails.tsx b/src/Managing.WebApp/src/pages/adminPage/account/accountRowDetails.tsx new file mode 100644 index 00000000..e66ba7ee --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/account/accountRowDetails.tsx @@ -0,0 +1,247 @@ +import React, {useState} from 'react' +import {FiRefreshCw, FiSend} from 'react-icons/fi' +import {useQuery} from '@tanstack/react-query' + +import {SelectColumnFilter, Table} from '../../../components/mollecules' +import type {IAccountRowDetail} from '../../../global/type.tsx' +import type {Account, Balance} from '../../../generated/ManagingApi' +import {AccountClient, Ticker} from '../../../generated/ManagingApi' +import useApiUrlStore from '../../../app/store/apiStore' +import SwapModal from './SwapModal' +import SendTokenModal from './SendTokenModal' + +interface IAccountRowDetailProps extends IAccountRowDetail { + account: Account +} + +const AccountRowDetails: React.FC = ({ + balances, + showTotal, + account, +}) => { + const { apiUrl } = useApiUrlStore() + const accountClient = new AccountClient({}, apiUrl) + + // Fetch exchange approval status using TanStack Query + const { data: exchangeApprovalStatus, isLoading: isLoadingApprovalStatus, error: approvalStatusError, refetch: refetchApprovalStatus } = useQuery({ + queryKey: ['exchangeApprovalStatus'], + queryFn: async () => { + return await accountClient.account_GetExchangeApprovalStatus() + }, + staleTime: 60000, // Consider data fresh for 1 minute + gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + retry: 2, // Retry failed requests up to 2 times + enabled: !!apiUrl, // Only run query when apiUrl is available + }) + + const [swapModalState, setSwapModalState] = useState<{ + isOpen: boolean + fromTicker: Ticker | null + availableAmount: number + }>({ + isOpen: false, + fromTicker: null, + availableAmount: 0, + }) + + const [sendTokenModalState, setSendTokenModalState] = useState<{ + isOpen: boolean + fromTicker: Ticker | null + availableAmount: number + }>({ + isOpen: false, + fromTicker: null, + availableAmount: 0, + }) + + const handleSwapClick = (balance: Balance) => { + if (balance.tokenName && balance.amount) { + // Convert tokenName to Ticker enum + const ticker = balance.tokenName.toUpperCase() as Ticker + if (Object.values(Ticker).includes(ticker)) { + setSwapModalState({ + isOpen: true, + fromTicker: ticker, + availableAmount: balance.amount, + }) + } + } + } + + const handleSendClick = (balance: Balance) => { + if (balance.tokenName && balance.amount) { + // Convert tokenName to Ticker enum + const ticker = balance.tokenName.toUpperCase() as Ticker + if (Object.values(Ticker).includes(ticker)) { + setSendTokenModalState({ + isOpen: true, + fromTicker: ticker, + availableAmount: balance.amount, + }) + } + } + } + + const closeSwapModal = () => { + setSwapModalState({ + isOpen: false, + fromTicker: null, + availableAmount: 0, + }) + } + + const closeSendTokenModal = () => { + setSendTokenModalState({ + isOpen: false, + fromTicker: null, + availableAmount: 0, + }) + } + + const columns = [ + { + Header: 'Chain', + accessor: 'chain.name', + disableFilters: true, + disableSortBy: true, + }, + { + Filter: SelectColumnFilter, + Header: 'Assets', + accessor: 'tokenName', + disableFilters: true, + disableSortBy: true, + }, + { + Cell: ({ cell }: any) => ( + <> +
+ {cell.row.values.amount.toFixed(4)} +
+ + ), + Header: 'Quantity', + accessor: 'amount', + disableFilters: true, + }, + { + Cell: ({ cell }: any) => <>{cell.row.values.value.toFixed(2)} $, + Header: 'USD', + accessor: 'value', + disableFilters: true, + disableSortBy: true, + }, + { + Cell: ({ cell }: any) => <> {cell.row.values.price} $, + Header: 'Price', + accessor: 'price', + disableFilters: true, + }, + { + Cell: ({ cell }: any) => { + const balance = cell.row.original as Balance + + return ( +
+ {balance.tokenName && balance.amount && balance.amount > 0 && Object.values(Ticker).includes(balance.tokenName.toUpperCase() as Ticker) && ( + <> + + + + )} +
+ ) + }, + Header: 'Actions', + accessor: 'actions', + disableFilters: true, + disableSortBy: true, + }, + ] + + return ( + <> + + + {/* Exchange Approval Status */} +
+
+

Exchange Approval Status

+ +
+ {isLoadingApprovalStatus ? ( +
Loading approval status...
+ ) : approvalStatusError ? ( +
Error loading approval status
+ ) : exchangeApprovalStatus && exchangeApprovalStatus.length > 0 ? ( +
+ {exchangeApprovalStatus.map((status) => ( +
+ {status.exchange}: {status.isInitialized ? 'Initialized' : 'Not Initialized'} +
+ ))} +
+ ) : ( +
No exchange data available
+ )} +
+ + {swapModalState.isOpen && swapModalState.fromTicker && ( + + )} + + {sendTokenModalState.isOpen && sendTokenModalState.fromTicker && ( + + )} + + ) +} + +export default AccountRowDetails + diff --git a/src/Managing.WebApp/src/pages/adminPage/account/accountSettings.tsx b/src/Managing.WebApp/src/pages/adminPage/account/accountSettings.tsx new file mode 100644 index 00000000..edc7f88f --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/account/accountSettings.tsx @@ -0,0 +1,48 @@ +import React, {useEffect, useState} from 'react' + +import useApiUrlStore from '../../../app/store/apiStore' +import type {Account} from '../../../generated/ManagingApi' +import {AccountClient} from '../../../generated/ManagingApi' + +import AccountModal from './accountModal' +import AccountTable from './accountTable' + +const AccountSettings: React.FC = () => { + const [accounts, setAccounts] = useState([]) + const [showModal, setShowModal] = useState(false) + const { apiUrl } = useApiUrlStore() + const [isFetching, setIsFetching] = useState(false) + + useEffect(() => { + const client = new AccountClient({}, apiUrl) + setIsFetching(true) + client + .account_GetAccountsBalances() + .then((data) => { + setAccounts(data) + }) + .finally(() => setIsFetching(false)) + }, []) + + function toggleModal() { + setShowModal(!showModal) + } + + function openModal() { + setShowModal(true) + } + + return ( +
+
+ + + +
+
+ ) +} +export default AccountSettings + diff --git a/src/Managing.WebApp/src/pages/adminPage/account/accountTable.tsx b/src/Managing.WebApp/src/pages/adminPage/account/accountTable.tsx new file mode 100644 index 00000000..4901b36d --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/account/accountTable.tsx @@ -0,0 +1,249 @@ +import {ChevronDownIcon, ChevronRightIcon,} from '@heroicons/react/solid' +import React, {useEffect, useMemo, useState} from 'react' +import {useNavigate} from 'react-router-dom' +import {FiCopy, FiKey, FiPlay, FiTrash2, FiTrendingUp} from 'react-icons/fi' + +import useApiUrlStore from '../../../app/store/apiStore' +import {SelectColumnFilter, Table, Toast,} from '../../../components/mollecules' +import type {Account} from '../../../generated/ManagingApi' +import {AccountClient, AccountType, TradingClient} from '../../../generated/ManagingApi' + +import AccountRowDetails from './accountRowDetails' +import PrivyDelegationModal from './PrivyDelegationModal' +import GmxPositionModal from './GmxPositionModal' + +interface IAccountList { + list: Account[] + isFetching: boolean +} + +const AccountTable: React.FC = ({ list, isFetching }) => { + const [rows, setRows] = useState([]) + const { apiUrl } = useApiUrlStore() + const navigate = useNavigate() + const [modalAccount, setModalAccount] = useState(null) + const [isDelegationModalOpen, setIsDelegationModalOpen] = useState(false) + const [isGmxPositionModalOpen, setIsGmxPositionModalOpen] = useState(false) + + async function deleteAcount(accountName: string) { + const t = new Toast('Deleting money management') + const client = new AccountClient({}, apiUrl) + + await client + .account_DeleteAccount(accountName) + .then(() => { + t.update('success', 'Account deleted') + }) + .catch((err) => { + t.update('error', 'Error :' + err) + }) + } + + async function initPrivyWallet(publicAddress: string) { + const t = new Toast('Initializing Privy wallet') + const client = new TradingClient({}, apiUrl) + + try { + const response = await client.trading_InitPrivyWallet(publicAddress) + if (response.success) { + t.update('success', 'Privy wallet initialized successfully') + } else { + t.update('error', `Initialization failed: ${response.error || 'Unknown error'}`) + } + } catch (err) { + t.update('error', 'Error: ' + err) + } + } + + async function copyToClipboard(text: string) { + const t = new Toast('Copying to clipboard...') + try { + await navigator.clipboard.writeText(text) + t.update('success', 'Address copied to clipboard!') + } catch (err) { + t.update('error', 'Failed to copy to clipboard') + } + } + + const columns = useMemo( + () => [ + { + Cell: ({ row }: any) => ( + + {row.isExpanded ? ( + + ) : ( + + )} + + ), + + // Make sure it has an ID + Header: ({ getToggleAllRowsExpandedProps, isAllRowsExpanded }: any) => ( + + {isAllRowsExpanded ? 'v' : '>'} + + ), + // Build our expander column + id: 'expander', + }, + { + Header: 'Name', + accessor: 'name', + disableFilters: true, + }, + { + Filter: SelectColumnFilter, + Header: 'Exchange', + accessor: 'exchange', + disableSortBy: true, + }, + { + Filter: SelectColumnFilter, + Header: 'Type', + accessor: 'type', + disableSortBy: true, + }, + { + Cell: ({ cell }: any) => ( + <> +
+
+ {cell.row.values.key.substring(0, 6)}... + {cell.row.values.key.slice(-4)} +
+ +
+ + ), + Header: 'Key', + accessor: 'key', + disableFilters: true, + }, + { + Cell: ({ row }: any) => { + const account = row.original + + return ( +
+ + + {account.type === AccountType.Privy && ( + <> + + + + + + + )} +
+ ) + }, + Header: 'Actions', + accessor: 'id', + disableFilters: true, + }, + ], + [] + ) + + useEffect(() => { + setRows(list) + }, [list]) + + const renderRowSubComponent = React.useCallback( + ({ row }: { row: any }) => { + const { balances } = row.original + return ( + <> + {balances && balances.length > 0 ? ( + + ) : ( +
No balances
+ )} + + ) + }, + [] + ) + + return ( + <> + {isFetching ? ( +
+ +
+ ) : ( +
+ )} + + {modalAccount && ( + <> + { + setIsDelegationModalOpen(false) + setModalAccount(null) + }} + account={modalAccount} + /> + { + setIsGmxPositionModalOpen(false) + setModalAccount(null) + }} + account={modalAccount} + /> + + )} + + ) +} + +export default AccountTable + diff --git a/src/Managing.WebApp/src/pages/adminPage/admin.tsx b/src/Managing.WebApp/src/pages/adminPage/admin.tsx new file mode 100644 index 00000000..0faf446f --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/admin.tsx @@ -0,0 +1,41 @@ +import {useState} from 'react' + +import {Tabs} from '../../components/mollecules' + +import AccountSettings from './account/accountSettings' +import WhitelistSettings from './whitelist/whitelistSettings' + +type TabsType = { + label: string + index: number + Component: React.FC<{}> +}[] + +// Tabs Array +const tabs: TabsType = [ + { + Component: WhitelistSettings, + index: 1, + label: 'Whitelist', + }, + { + Component: AccountSettings, + index: 2, + label: 'Account', + }, +] + +const Admin: React.FC = () => { + const [selectedTab, setSelectedTab] = useState(tabs[0].index) + + return ( +
+
+ +
+
+ ) +} + +export default Admin + diff --git a/src/Managing.WebApp/src/pages/adminPage/whitelist/whitelistSettings.tsx b/src/Managing.WebApp/src/pages/adminPage/whitelist/whitelistSettings.tsx new file mode 100644 index 00000000..5552c295 --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/whitelist/whitelistSettings.tsx @@ -0,0 +1,115 @@ +import {useState} from 'react' +import {useQuery} from '@tanstack/react-query' + +import useApiUrlStore from '../../../app/store/apiStore' +import {WhitelistClient} from '../../../generated/ManagingApi' + +import WhitelistTable from './whitelistTable' + +const WhitelistSettings: React.FC = () => { + const { apiUrl } = useApiUrlStore() + const [pageNumber, setPageNumber] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [searchExternalEthereumAccount, setSearchExternalEthereumAccount] = useState('') + const [searchTwitterAccount, setSearchTwitterAccount] = useState('') + + const whitelistClient = new WhitelistClient({}, apiUrl) + + const { + data: whitelistData, + isLoading, + error, + refetch + } = useQuery({ + queryKey: ['whitelistAccounts', pageNumber, pageSize, searchExternalEthereumAccount, searchTwitterAccount], + queryFn: async () => { + return await whitelistClient.whitelist_GetWhitelistAccounts( + pageNumber, + pageSize, + searchExternalEthereumAccount || null, + searchTwitterAccount || null + ) + }, + staleTime: 30000, + gcTime: 5 * 60 * 1000, + }) + + const accounts = whitelistData?.accounts || [] + const totalCount = whitelistData?.totalCount || 0 + const totalPages = whitelistData?.totalPages || 0 + + const handlePageChange = (newPage: number) => { + setPageNumber(newPage) + } + + const handleSearchExternalEthereum = (value: string) => { + setSearchExternalEthereumAccount(value) + setPageNumber(1) // Reset to first page when searching + } + + const handleSearchTwitter = (value: string) => { + setSearchTwitterAccount(value) + setPageNumber(1) // Reset to first page when searching + } + + const handleWhitelistSuccess = () => { + refetch() + } + + return ( +
+
+
+

Whitelist Accounts

+ + {/* Search Filters */} +
+
+ + handleSearchExternalEthereum(e.target.value)} + /> +
+
+ + handleSearchTwitter(e.target.value)} + /> +
+
+
+ + + + {error && ( +
+ Failed to load whitelist accounts. Please try again. +
+ )} +
+
+ ) +} + +export default WhitelistSettings + diff --git a/src/Managing.WebApp/src/pages/adminPage/whitelist/whitelistTable.tsx b/src/Managing.WebApp/src/pages/adminPage/whitelist/whitelistTable.tsx new file mode 100644 index 00000000..e769d7dc --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/whitelist/whitelistTable.tsx @@ -0,0 +1,216 @@ +import React, {useMemo} from 'react' +import {type WhitelistAccount, WhitelistClient} from '../../../generated/ManagingApi' +import useApiUrlStore from '../../../app/store/apiStore' +import {Table, Toast} from '../../../components/mollecules' + +interface IWhitelistList { + list: WhitelistAccount[] + isFetching: boolean + totalCount: number + currentPage: number + totalPages: number + onPageChange: (page: number) => void + onWhitelistSuccess: () => void +} + +const WhitelistTable: React.FC = ({ + list, + isFetching, + totalCount, + currentPage, + totalPages, + onPageChange, + onWhitelistSuccess +}) => { + const { apiUrl } = useApiUrlStore() + const whitelistClient = new WhitelistClient({}, apiUrl) + + async function handleSetWhitelisted(id: number) { + const t = new Toast('Updating whitelist status...') + + try { + await whitelistClient.whitelist_SetWhitelisted(id) + t.update('success', 'Account whitelisted successfully') + onWhitelistSuccess() + } catch (err: any) { + const errorMessage = err?.response || err?.message || 'Unknown error' + t.update('error', `Error: ${errorMessage}`) + } + } + + const columns = useMemo(() => [ + { + Header: 'Is Whitelisted', + accessor: 'isWhitelisted', + width: 120, + Cell: ({ value }: any) => ( +
+ {value ? 'Yes' : 'No'} +
+ ), + }, + { + Header: 'Actions', + accessor: 'actions', + width: 150, + Cell: ({ row }: any) => { + const account = row.original as WhitelistAccount + return ( +
+ {!account.isWhitelisted && ( + + )} + {account.isWhitelisted && ( + Whitelisted + )} +
+ ) + }, + }, + { + Header: 'Privy ID', + accessor: 'privyId', + width: 200, + }, + { + Header: 'Embedded Wallet', + accessor: 'embeddedWallet', + width: 200, + Cell: ({ value }: any) => ( + {value || '-'} + ), + }, + { + Header: 'External Ethereum Account', + accessor: 'externalEthereumAccount', + width: 200, + Cell: ({ value }: any) => ( + {value || '-'} + ), + }, + { + Header: 'Twitter Account', + accessor: 'twitterAccount', + width: 150, + Cell: ({ value }: any) => ( + {value || '-'} + ), + }, + ], [isFetching]) + + const tableData = useMemo(() => { + return list.map((account) => ({ + id: account.id, + privyId: account.privyId || '-', + embeddedWallet: account.embeddedWallet || '-', + externalEthereumAccount: account.externalEthereumAccount || '-', + twitterAccount: account.twitterAccount || '-', + isWhitelisted: account.isWhitelisted || false, + createdAt: account.createdAt, + actions: account, + })) + }, [list]) + + return ( +
+
+

+ Total accounts: {totalCount} | Page {currentPage} of {totalPages} +

+
+ + {isFetching && ( +
+ +
+ )} + + {!isFetching && list.length === 0 && ( +
+ No whitelist accounts found. +
+ )} + + {!isFetching && list.length > 0 && ( + <> +
+ + {/* Manual Pagination */} + {totalPages > 1 && ( +
+ + + + {/* Page numbers */} +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum + if (totalPages <= 5) { + pageNum = i + 1 + } else if (currentPage <= 3) { + pageNum = i + 1 + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i + } else { + pageNum = currentPage - 2 + i + } + + return ( + + ) + })} +
+ + + +
+ )} + + )} + + ) +} + +export default WhitelistTable + diff --git a/src/Managing.WebApp/src/pages/settingsPage/healthchecks/healthChecks.tsx b/src/Managing.WebApp/src/pages/monitoringPage/healthchecks/healthChecks.tsx similarity index 100% rename from src/Managing.WebApp/src/pages/settingsPage/healthchecks/healthChecks.tsx rename to src/Managing.WebApp/src/pages/monitoringPage/healthchecks/healthChecks.tsx diff --git a/src/Managing.WebApp/src/pages/monitoringPage/monitoring.tsx b/src/Managing.WebApp/src/pages/monitoringPage/monitoring.tsx new file mode 100644 index 00000000..1735171e --- /dev/null +++ b/src/Managing.WebApp/src/pages/monitoringPage/monitoring.tsx @@ -0,0 +1,41 @@ +import {useState} from 'react' + +import {Tabs} from '../../components/mollecules' + +import HealthChecks from './healthchecks/healthChecks' +import SqlMonitoring from './sqlmonitoring/sqlMonitoring' + +type TabsType = { + label: string + index: number + Component: React.FC<{}> +}[] + +// Tabs Array +const tabs: TabsType = [ + { + Component: HealthChecks, + index: 1, + label: 'Health Checks', + }, + { + Component: SqlMonitoring, + index: 2, + label: 'SQL Monitoring', + }, +] + +const Monitoring: React.FC = () => { + const [selectedTab, setSelectedTab] = useState(tabs[0].index) + + return ( +
+
+ +
+
+ ) +} + +export default Monitoring + diff --git a/src/Managing.WebApp/src/pages/settingsPage/sqlmonitoring/README.md b/src/Managing.WebApp/src/pages/monitoringPage/sqlmonitoring/README.md similarity index 100% rename from src/Managing.WebApp/src/pages/settingsPage/sqlmonitoring/README.md rename to src/Managing.WebApp/src/pages/monitoringPage/sqlmonitoring/README.md diff --git a/src/Managing.WebApp/src/pages/settingsPage/sqlmonitoring/sqlMonitoring.tsx b/src/Managing.WebApp/src/pages/monitoringPage/sqlmonitoring/sqlMonitoring.tsx similarity index 100% rename from src/Managing.WebApp/src/pages/settingsPage/sqlmonitoring/sqlMonitoring.tsx rename to src/Managing.WebApp/src/pages/monitoringPage/sqlmonitoring/sqlMonitoring.tsx diff --git a/src/Managing.WebApp/src/pages/settingsPage/settings.tsx b/src/Managing.WebApp/src/pages/settingsPage/settings.tsx index 4bd08582..eceea309 100644 --- a/src/Managing.WebApp/src/pages/settingsPage/settings.tsx +++ b/src/Managing.WebApp/src/pages/settingsPage/settings.tsx @@ -2,15 +2,11 @@ import {useState} from 'react' import {Tabs} from '../../components/mollecules' -import AccountSettings from './account/accountSettings' -import HealthChecks from './healthchecks/healthChecks' import MoneyManagementSettings from './moneymanagement/moneyManagement' import Theme from './theme' import DefaultConfig from './defaultConfig/defaultConfig' import UserInfoSettings from './UserInfoSettings' import AccountFee from './accountFee/accountFee' -import SqlMonitoring from './sqlmonitoring/sqlMonitoring' -import WhitelistSettings from './whitelist/whitelistSettings' type TabsType = { label: string @@ -30,41 +26,21 @@ const tabs: TabsType = [ index: 2, label: 'Money Management', }, - { - Component: AccountSettings, - index: 3, - label: 'Account Settings', - }, { Component: AccountFee, - index: 4, + index: 3, label: 'Account Fee', }, { Component: Theme, - index: 5, + index: 4, label: 'Theme', }, { Component: DefaultConfig, - index: 6, + index: 5, label: 'Quick Start Config', }, - { - Component: HealthChecks, - index: 7, - label: 'Health Checks', - }, - { - Component: SqlMonitoring, - index: 8, - label: 'SQL Monitoring', - }, - { - Component: WhitelistSettings, - index: 9, - label: 'Whitelist', - }, ] const Settings: React.FC = () => {