From 1ad6983d4e42e1b4ba12ec41535a53d851bd132d Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Tue, 26 Nov 2024 11:12:05 +0800 Subject: [PATCH] feat: test webhook and delete wallet (#5489) --- .../src/@3rdweb-sdk/react/hooks/useEngine.ts | 77 ++++- .../components/backend-wallets-table.tsx | 241 ++++++++++++---- .../webhooks/components/webhooks-table.tsx | 263 ++++++++++++------ 3 files changed, 434 insertions(+), 147 deletions(-) diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts index 8d161234531..cd14eca9344 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts @@ -83,7 +83,7 @@ export function useEngineInstances() { export type BackendWallet = { address: string; label?: string; - type: string; + type: EngineBackendWalletType; awsKmsKeyId?: string | null; awsKmsArn?: string | null; gcpKmsKeyId?: string | null; @@ -980,6 +980,39 @@ export function useEngineImportBackendWallet(instance: string) { }); } +interface DeleteBackendWalletInput { + walletAddress: string; +} +export function useEngineDeleteBackendWallet(instance: string) { + const token = useLoggedInUser().user?.jwt ?? null; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: DeleteBackendWalletInput) => { + invariant(instance, "instance is required"); + + const res = await fetch( + `${instance}backend-wallet/${input.walletAddress}`, + { + method: "DELETE", + headers: getEngineRequestHeaders(token), + }, + ); + const json = await res.json(); + + if (json.error) { + throw new Error(json.error.message); + } + return json.result; + }, + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: engineKeys.backendWallets(instance), + }); + }, + }); +} + export function useEngineGrantPermissions(instance: string) { const token = useLoggedInUser().user?.jwt ?? null; const queryClient = useQueryClient(); @@ -1177,16 +1210,15 @@ export function useEngineCreateWebhook(instance: string) { }); } -type RevokeWebhookInput = { +type DeleteWebhookInput = { id: number; }; - -export function useEngineRevokeWebhook(instance: string) { +export function useEngineDeleteWebhook(instance: string) { const token = useLoggedInUser().user?.jwt ?? null; const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (input: RevokeWebhookInput) => { + mutationFn: async (input: DeleteWebhookInput) => { invariant(instance, "instance is required"); const res = await fetch(`${instance}webhooks/revoke`, { @@ -1195,11 +1227,44 @@ export function useEngineRevokeWebhook(instance: string) { body: JSON.stringify(input), }); const json = await res.json(); - if (json.error) { throw new Error(json.error.message); } + return json.result; + }, + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: engineKeys.webhooks(instance), + }); + }, + }); +} + +interface TestWebhookInput { + id: number; +} +interface TestWebhookResponse { + ok: boolean; + status: number; + body: string; +} +export function useEngineTestWebhook(instance: string) { + const token = useLoggedInUser().user?.jwt ?? null; + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: TestWebhookInput) => { + invariant(instance, "instance is required"); + + const res = await fetch(`${instance}webhooks/${input.id}/test`, { + method: "POST", + headers: getEngineRequestHeaders(token), + body: JSON.stringify({}), + }); + const json = await res.json(); + if (json.error) { + throw new Error(json.error.message); + } return json.result; }, onSuccess: () => { diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/backend-wallets-table.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/backend-wallets-table.tsx index 45f79dd3902..d25c540702f 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/backend-wallets-table.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/backend-wallets-table.tsx @@ -1,15 +1,19 @@ import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; +import { FormItem } from "@/components/ui/form"; import { type BackendWallet, useEngineBackendWalletBalance, + useEngineDeleteBackendWallet, useEngineSendTokens, useEngineUpdateBackendWallet, } from "@3rdweb-sdk/react/hooks/useEngine"; import { Flex, FormControl, - Image, Input, InputGroup, InputRightAddon, @@ -29,21 +33,23 @@ import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; import { ChainIcon } from "components/icons/ChainIcon"; import { TWTable } from "components/shared/TWTable"; import { useTrack } from "hooks/analytics/useTrack"; -import { useTxNotifications } from "hooks/useTxNotifications"; +import { EngineBackendWalletOptions } from "lib/engine"; import { useActiveChainAsDashboardChain } from "lib/v5-adapter"; -import { DownloadIcon, PencilIcon, UploadIcon } from "lucide-react"; +import { + DownloadIcon, + PencilIcon, + TrashIcon, + TriangleAlertIcon, + UploadIcon, +} from "lucide-react"; import QRCode from "qrcode"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import { toast } from "sonner"; import { getAddress } from "thirdweb"; import { shortenAddress } from "thirdweb/utils"; -import { - Button, - FormHelperText, - FormLabel, - LinkButton, - Text, -} from "tw-components"; +import invariant from "tiny-invariant"; +import { FormHelperText, FormLabel, LinkButton, Text } from "tw-components"; import { prettyPrintCurrency } from "./utils"; interface BackendWalletsTableProps { @@ -150,6 +156,7 @@ export const BackendWalletsTable: React.FC = ({ const editDisclosure = useDisclosure(); const receiveDisclosure = useDisclosure(); const sendDisclosure = useDisclosure(); + const deleteDisclosure = useDisclosure(); const columns = setColumns(instanceUrl); const [selectedBackendWallet, setSelectedBackendWallet] = useState(); @@ -187,6 +194,15 @@ export const BackendWalletsTable: React.FC = ({ sendDisclosure.onOpen(); }, }, + { + icon: , + text: "Delete", + onClick: (wallet) => { + setSelectedBackendWallet(wallet); + deleteDisclosure.onOpen(); + }, + isDestructive: true, + }, ]} /> @@ -211,6 +227,13 @@ export const BackendWalletsTable: React.FC = ({ instanceUrl={instanceUrl} /> )} + {selectedBackendWallet && deleteDisclosure.isOpen && ( + + )} ); }; @@ -224,25 +247,19 @@ const EditModal = ({ disclosure: UseDisclosureReturn; instanceUrl: string; }) => { - const { mutate: updatePermissions } = - useEngineUpdateBackendWallet(instanceUrl); + const updateBackendWallet = useEngineUpdateBackendWallet(instanceUrl); const trackEvent = useTrack(); - const { onSuccess, onError } = useTxNotifications( - "Successfully updated backend wallet", - "Failed to update backend wallet", - ); const [label, setLabel] = useState(backendWallet.label ?? ""); - const onClick = () => { - updatePermissions( + const onClick = async () => { + const promise = updateBackendWallet.mutateAsync( { walletAddress: backendWallet.address, label, }, { onSuccess: () => { - onSuccess(); disclosure.onClose(); trackEvent({ category: "engine", @@ -252,7 +269,6 @@ const EditModal = ({ }); }, onError: (error) => { - onError(error); trackEvent({ category: "engine", action: "update-backend-wallet", @@ -263,6 +279,11 @@ const EditModal = ({ }, }, ); + + toast.promise(promise, { + success: "Successfully updated backend wallet.", + error: "Failed to update backend wallet.", + }); }; return ( @@ -275,7 +296,10 @@ const EditModal = ({
Wallet Address - {backendWallet.address} + Label @@ -290,10 +314,10 @@ const EditModal = ({ - - @@ -347,11 +371,10 @@ const ReceiveFundsModal = ({ address={backendWallet.address} shortenAddress={false} /> - Receive funds to your backend wallet
@@ -364,7 +387,6 @@ interface SendFundsInput { toAddress: string; amount: number; } - const SendFundsModal = ({ fromWallet, backendWallets, @@ -378,39 +400,39 @@ const SendFundsModal = ({ }) => { const chain = useActiveChainAsDashboardChain(); const form = useForm(); - const { mutate: sendTokens } = useEngineSendTokens(instanceUrl); + const sendTokens = useEngineSendTokens(instanceUrl); const { data: backendWalletBalance } = useEngineBackendWalletBalance( instanceUrl, fromWallet.address, ); - const { onSuccess, onError } = useTxNotifications( - "Successfully sent a request to send funds.", - "Failed to send tokens.", - ); const toWalletDisclosure = useDisclosure(); + if (!backendWalletBalance) { + return null; + } + const onSubmit = async (data: SendFundsInput) => { - if (!chain) { - return; - } + invariant(chain, "chain is required"); - try { - await sendTokens({ + const promise = sendTokens.mutateAsync( + { chainId: chain.chainId, fromAddress: fromWallet.address, toAddress: data.toAddress, amount: data.amount, - }); - onSuccess(); - disclosure.onClose(); - } catch (e) { - onError(e); - } - }; + }, + { + onSuccess: () => { + disclosure.onClose(); + }, + }, + ); - if (!backendWalletBalance) { - return null; - } + toast.promise(promise, { + success: "Successfully sent a request to send funds.", + error: "Failed to send tokens.", + }); + }; return ( @@ -426,7 +448,10 @@ const SendFundsModal = ({
From - {fromWallet.address} + To @@ -457,7 +482,6 @@ const SendFundsModal = ({ toWalletDisclosure.onToggle(); }} variant="link" - size="xs" > {toWalletDisclosure.isOpen ? "Or send to a backend wallet" @@ -498,18 +522,125 @@ const SendFundsModal = ({ - + + + + + ); +}; + +function DeleteModal({ + backendWallet, + disclosure, + instanceUrl, +}: { + backendWallet: BackendWallet; + disclosure: UseDisclosureReturn; + instanceUrl: string; +}) { + const deleteBackendWallet = useEngineDeleteBackendWallet(instanceUrl); + const trackEvent = useTrack(); + + const isLocalWallet = + backendWallet.type === "local" || backendWallet.type === "smart:local"; + const [ackDeletion, setAckDeletion] = useState(false); + + const onClick = () => { + const promise = deleteBackendWallet.mutateAsync( + { walletAddress: backendWallet.address }, + { + onSuccess: () => { + disclosure.onClose(); + trackEvent({ + category: "engine", + action: "delete-backend-wallet", + label: "success", + instance: instanceUrl, + }); + }, + onError: (error) => { + trackEvent({ + category: "engine", + action: "delete-backend-wallet", + label: "error", + instance: instanceUrl, + error, + }); + }, + }, + ); + + toast.promise(promise, { + success: "Successfully deleted backend wallet.", + error: "Failed to delete backend wallet.", + }); + }; + + return ( + + + + Delete Backend Wallet + + +
+ + Wallet Type +
+ { + EngineBackendWalletOptions.find( + (opt) => opt.key === backendWallet.type, + )?.name + } +
+
+ + Wallet Address + + +
+ + {isLocalWallet && ( + + + This action is irreversible. + + + + setAckDeletion(!!checked)} + /> + I understand that access to this backend wallet and any + remaining funds will be lost. + + + + )} +
+ + +
); -}; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/webhooks/components/webhooks-table.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/webhooks/components/webhooks-table.tsx index 53ba43b5e34..5eea406127e 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/webhooks/components/webhooks-table.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/webhooks/components/webhooks-table.tsx @@ -1,7 +1,12 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FormItem } from "@/components/ui/form"; import { type EngineWebhook, - useEngineRevokeWebhook, + useEngineDeleteWebhook, + useEngineTestWebhook, } from "@3rdweb-sdk/react/hooks/useEngine"; import { Flex, @@ -14,16 +19,17 @@ import { ModalHeader, ModalOverlay, Tooltip, + type UseDisclosureReturn, useDisclosure, } from "@chakra-ui/react"; import { createColumnHelper } from "@tanstack/react-table"; import { TWTable } from "components/shared/TWTable"; import { format, formatDistanceToNowStrict } from "date-fns"; import { useTrack } from "hooks/analytics/useTrack"; -import { useTxNotifications } from "hooks/useTxNotifications"; -import { Trash2Icon } from "lucide-react"; +import { MailQuestion, TrashIcon } from "lucide-react"; import { useState } from "react"; -import { Button, Card, FormLabel, Text } from "tw-components"; +import { toast } from "sonner"; +import { Card, FormLabel, Text } from "tw-components"; import { shortenString } from "utils/usedapp-external"; export function beautifyString(str: string): string { @@ -113,45 +119,89 @@ export const WebhooksTable: React.FC = ({ isPending, isFetched, }) => { - const [webhookToRevoke, setWebhookToRevoke] = useState(); - const { isOpen, onOpen, onClose } = useDisclosure(); - const { mutate: revokeWebhook } = useEngineRevokeWebhook(instanceUrl); - const trackEvent = useTrack(); - const { onSuccess, onError } = useTxNotifications( - "Successfully deleted webhook", - "Failed to delete webhook", - ); + const [selectedWebhook, setSelectedWebhook] = useState(); + const deleteDisclosure = useDisclosure(); + const testDisclosure = useDisclosure(); - const onDelete = (webhook: EngineWebhook) => { - setWebhookToRevoke(webhook); - onOpen(); - }; + const activeWebhooks = webhooks.filter((webhook) => webhook.active); + + return ( + <> + , + text: "Test webhook", + onClick: (row) => { + setSelectedWebhook(row); + testDisclosure.onOpen(); + }, + }, + { + icon: , + text: "Delete", + onClick: (row) => { + setSelectedWebhook(row); + deleteDisclosure.onOpen(); + }, + isDestructive: true, + }, + ]} + /> + + {selectedWebhook && deleteDisclosure.isOpen && ( + + )} + {selectedWebhook && testDisclosure.isOpen && ( + + )} + + ); +}; - const onRevoke = () => { - if (!webhookToRevoke) { - return; - } +interface DeleteWebhookModalProps { + webhook: EngineWebhook; + disclosure: UseDisclosureReturn; + instanceUrl: string; +} +function DeleteWebhookModal({ + webhook, + disclosure, + instanceUrl, +}: DeleteWebhookModalProps) { + const deleteWebhook = useEngineDeleteWebhook(instanceUrl); + const trackEvent = useTrack(); - revokeWebhook( - { - id: webhookToRevoke.id, - }, + const onDelete = () => { + const promise = deleteWebhook.mutateAsync( + { id: webhook.id }, { onSuccess: () => { - onSuccess(); - onClose(); + disclosure.onClose(); trackEvent({ category: "engine", - action: "revoke-webhook", + action: "delete-webhook", label: "success", instance: instanceUrl, }); }, onError: (error) => { - onError(error); trackEvent({ category: "engine", - action: "revoke-webhook", + action: "delete-webhook", label: "error", instance: instanceUrl, error, @@ -159,68 +209,109 @@ export const WebhooksTable: React.FC = ({ }, }, ); + + toast.promise(promise, { + success: "Successfully deleted webhook.", + error: "Failed to delete webhook.", + }); }; - const activeWebhooks = webhooks.filter((webhook) => webhook.active); + return ( + + + + Delete Webhook + + +
+ Are you sure you want to delete this webhook? + + Name + {webhook.name} + + + URL + {webhook.url} + + + Created at + + {format(new Date(webhook.createdAt ?? ""), "PP pp z")} + + +
+
+ + + + + +
+
+ ); +} + +interface TestWebhookModalProps { + webhook: EngineWebhook; + disclosure: UseDisclosureReturn; + instanceUrl: string; +} +function TestWebhookModal({ + webhook, + disclosure, + instanceUrl, +}: TestWebhookModalProps) { + const { mutate: testWebhook, isPending } = useEngineTestWebhook(instanceUrl); + const [status, setStatus] = useState(); + const [body, setBody] = useState(); + + const onTest = () => { + testWebhook( + { id: webhook.id }, + { + onSuccess: (result) => { + setStatus(result.status); + setBody(result.body); + }, + }, + ); + }; return ( - <> - - - - Delete webhook - - - {webhookToRevoke && ( -
- Are you sure you want to delete this webook? - - Name - {webhookToRevoke?.name} - - - URL - {webhookToRevoke?.url} - - - Created at - - {format( - new Date(webhookToRevoke?.createdAt ?? ""), - "PP pp z", - )} - - -
- )} -
+ + + + Test Webhook + + +
+ + URL + {webhook.url} + - - - - - - - , - text: "Delete", - onClick: onDelete, - isDestructive: true, - }, - ]} - /> - + {status && ( +
+ + {status} + +
+ )} +
+ {body ?? "Send a request to see the response."} +
+
+
+
+
); -}; +}