From 4672475ced6481543deff3a0eb1bedd23cb82fea Mon Sep 17 00:00:00 2001 From: arcoraven Date: Mon, 14 Oct 2024 19:42:57 +0000 Subject: [PATCH] feat: Support multiple wallets in Engine dashboard (#4956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds support for configuring creds for AWS + GCP independently - Moves all touched UI to tailwind - Import / Create wallet buttons allow selecting the wallet type - Unrelated: Moved version upgrade button to the new enpdoint. Will deprecate the old one. --- ## PR-Codex overview This PR focuses on refactoring the engine configuration components to improve how instances and wallet configurations are handled, enhancing the overall clarity and functionality of the engine's wallet management features. ### Detailed summary - Removed `instanceUrl` prop in favor of `instance` for components. - Introduced `LocalConfig` component for local wallet configuration. - Added support for multiple wallet types with `EngineBackendWalletOptions`. - Updated wallet configuration forms to use a unified `Form` component. - Improved error handling and user feedback for wallet operations. - Enhanced dialog components for creating and importing backend wallets. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../src/@3rdweb-sdk/react/hooks/useEngine.ts | 109 ++-- .../engine/(instance)/[engineId]/page.tsx | 2 +- .../src/components/engine/badges/version.tsx | 5 +- .../configuration/engine-configuration.tsx | 2 +- .../configuration/engine-wallet-config.tsx | 141 +++--- .../engine/configuration/ip-allowlist.tsx | 7 +- .../engine/configuration/kms-aws-config.tsx | 223 +++++---- .../engine/configuration/kms-gcp-config.tsx | 295 ++++++----- .../engine/configuration/local-config.tsx | 10 + .../engine/configuration/local-config.tsx.tsx | 76 --- .../overview/create-backend-wallet-button.tsx | 218 +++++--- .../engine/overview/engine-overview.tsx | 79 ++- .../overview/import-backend-wallet-button.tsx | 464 +++++++++++------- .../permissions/engine-access-tokens.tsx | 12 +- apps/dashboard/src/lib/engine.ts | 11 + .../dashboard/engine/[engineId]/index.tsx | 2 +- 16 files changed, 956 insertions(+), 700 deletions(-) create mode 100644 apps/dashboard/src/components/engine/configuration/local-config.tsx delete mode 100644 apps/dashboard/src/components/engine/configuration/local-config.tsx.tsx diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts index 549e3772aba..7b8b32d7f25 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ResultItem } from "components/engine/system-metrics/components/StatusCodes"; import { THIRDWEB_API_HOST } from "constants/urls"; +import type { EngineBackendWalletType } from "lib/engine"; import { useState } from "react"; import { useActiveAccount, useActiveWalletChain } from "thirdweb/react"; import invariant from "tiny-invariant"; @@ -111,10 +112,16 @@ export function useEngineBackendWallets(instance: string) { }); } +type EngineFeature = + | "KEYPAIR_AUTH" + | "CONTRACT_SUBSCRIPTIONS" + | "IP_ALLOWLIST" + | "HETEROGENEOUS_WALLET_TYPES"; + interface EngineSystemHealth { status: string; engineVersion?: string; - features?: string[]; + features?: EngineFeature[]; } export function useEngineSystemHealth( @@ -138,6 +145,18 @@ export function useEngineSystemHealth( }); } +// Helper function to check if a feature is supported. +export function useHasEngineFeature( + instanceUrl: string, + feature: EngineFeature, +) { + const query = useEngineSystemHealth(instanceUrl); + return { + query, + isSupported: !!query.data?.features?.includes(feature), + }; +} + interface EngineSystemQueueMetrics { result: { queued: number; @@ -188,17 +207,15 @@ export function useEngineLatestVersion() { } interface UpdateVersionInput { - engineId: string; + deploymentId: string; serverVersion: string; } export function useEngineUpdateServerVersion() { return useMutation({ mutationFn: async (input: UpdateVersionInput) => { - invariant(input.engineId, "engineId is required"); - const res = await fetch( - `${THIRDWEB_API_HOST}/v2/engine/${input.engineId}/infrastructure`, + `${THIRDWEB_API_HOST}/v2/engine/deployments/${input.deploymentId}/infrastructure`, { method: "PUT", headers: { @@ -401,29 +418,22 @@ export function useEngineTransactions(instance: string, autoUpdate: boolean) { }); } -type WalletConfig = - | { - type: "local"; - } - | { - type: "aws-kms"; - awsAccessKeyId: string; - awsSecretAccessKey: string; - awsRegion: string; - } - | { - type: "gcp-kms"; - gcpApplicationProjectId: string; - gcpKmsLocationId: string; - gcpKmsKeyRingId: string; - gcpApplicationCredentialEmail: string; - gcpApplicationCredentialPrivateKey: string; - }; +export interface WalletConfigResponse { + type: EngineBackendWalletType; + + awsAccessKeyId?: string | null; + awsRegion?: string | null; + + gcpApplicationProjectId?: string | null; + gcpKmsLocationId?: string | null; + gcpKmsKeyRingId?: string | null; + gcpApplicationCredentialEmail?: string | null; +} export function useEngineWalletConfig(instance: string) { const token = useLoggedInUser().user?.jwt ?? null; - return useQuery({ + return useQuery({ queryKey: engineKeys.walletConfig(instance), queryFn: async () => { const res = await fetch(`${instance}configuration/wallets`, { @@ -432,8 +442,7 @@ export function useEngineWalletConfig(instance: string) { }); const json = await res.json(); - - return (json.result as WalletConfig) || {}; + return json.result; }, enabled: !!instance && !!token, }); @@ -799,9 +808,6 @@ export function useEngineWebhooks(instance: string) { // POST REQUESTS export type SetWalletConfigInput = - | { - type: "local"; - } | { type: "aws-kms"; awsAccessKeyId: string; @@ -821,8 +827,8 @@ export function useEngineSetWalletConfig(instance: string) { const token = useLoggedInUser().user?.jwt ?? null; const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (input: SetWalletConfigInput) => { + return useMutation({ + mutationFn: async (input) => { invariant(instance, "instance is required"); const res = await fetch(`${instance}configuration/wallets`, { @@ -847,6 +853,7 @@ export function useEngineSetWalletConfig(instance: string) { } export type CreateBackendWalletInput = { + type: EngineBackendWalletType; label?: string; }; @@ -913,25 +920,20 @@ export function useEngineUpdateBackendWallet(instance: string) { }); } -export type ImportBackendWalletInput = - | { - awsKmsKeyId: string; - awsKmsArn: string; - } - | { - gcpKmsKeyId: string; - gcpKmsKeyVersionId: string; - } - | { - privateKey?: string; - } - | { - mnemonic?: string; - } - | { - encryptedJson?: string; - password?: string; - }; +// The backend determines the wallet imported based on the provided fields. +export type ImportBackendWalletInput = { + label?: string; + + awsKmsArn?: string; + + gcpKmsKeyId?: string; + gcpKmsKeyVersionId?: string; + + privateKey?: string; + mnemonic?: string; + encryptedJson?: string; + password?: string; +}; export function useEngineImportBackendWallet(instance: string) { const token = useLoggedInUser().user?.jwt ?? null; @@ -1639,6 +1641,9 @@ export function useEngineCreateNotificationChannel(engineId: string) { `${THIRDWEB_API_HOST}/v1/engine/${engineId}/notification-channels`, { method: "POST", + headers: { + "Content-Type": "application/json", + }, body: JSON.stringify(input), }, ); @@ -1667,7 +1672,9 @@ export function useEngineDeleteNotificationChannel(engineId: string) { const res = await fetch( `${THIRDWEB_API_HOST}/v1/engine/${engineId}/notification-channels/${notificationChannelId}`, - { method: "DELETE" }, + { + method: "DELETE", + }, ); if (!res.ok) { throw new Error(`Unexpected status ${res.status}: ${await res.text()}`); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/engine/(instance)/[engineId]/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/engine/(instance)/[engineId]/page.tsx index 21aa995388d..85c78a6421b 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/engine/(instance)/[engineId]/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/engine/(instance)/[engineId]/page.tsx @@ -8,7 +8,7 @@ export default function Page(props: EngineInstancePageProps) { return ( } + content={(res) => } rootPath={`/team/${props.params.team_slug}/${props.params.project_slug}/engine`} /> ); diff --git a/apps/dashboard/src/components/engine/badges/version.tsx b/apps/dashboard/src/components/engine/badges/version.tsx index 8a83dd86641..390584f8623 100644 --- a/apps/dashboard/src/components/engine/badges/version.tsx +++ b/apps/dashboard/src/components/engine/badges/version.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/dialog"; import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { toast } from "sonner"; +import invariant from "tiny-invariant"; export const EngineVersionBadge = ({ instance, @@ -119,9 +120,11 @@ const UpdateVersionModal = (props: { } const onClick = async () => { + invariant(instance.deploymentId, "Engine is missing deploymentId."); + try { const promise = updateEngineServerMutation.mutateAsync({ - engineId: instance.id, + deploymentId: instance.deploymentId, serverVersion: latestVersion, }); toast.promise(promise, { diff --git a/apps/dashboard/src/components/engine/configuration/engine-configuration.tsx b/apps/dashboard/src/components/engine/configuration/engine-configuration.tsx index e461719359a..8c63c7feab1 100644 --- a/apps/dashboard/src/components/engine/configuration/engine-configuration.tsx +++ b/apps/dashboard/src/components/engine/configuration/engine-configuration.tsx @@ -15,7 +15,7 @@ export const EngineConfiguration: React.FC = ({ }) => { return (
- + diff --git a/apps/dashboard/src/components/engine/configuration/engine-wallet-config.tsx b/apps/dashboard/src/components/engine/configuration/engine-wallet-config.tsx index 5d37b96fd52..7d21fde3fb1 100644 --- a/apps/dashboard/src/components/engine/configuration/engine-wallet-config.tsx +++ b/apps/dashboard/src/components/engine/configuration/engine-wallet-config.tsx @@ -1,102 +1,81 @@ -import { useEngineWalletConfig } from "@3rdweb-sdk/react/hooks/useEngine"; -import { ButtonGroup, Flex, Icon } from "@chakra-ui/react"; +import { TabButtons } from "@/components/ui/tabs"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + type EngineInstance, + useEngineWalletConfig, +} from "@3rdweb-sdk/react/hooks/useEngine"; +import { Flex } from "@chakra-ui/react"; +import { + EngineBackendWalletOptions, + type EngineBackendWalletType, +} from "lib/engine"; +import { CircleAlertIcon } from "lucide-react"; +import Link from "next/link"; import { useState } from "react"; -import { MdRadioButtonChecked, MdRadioButtonUnchecked } from "react-icons/md"; -import { Button, Heading, Link, Text } from "tw-components"; +import {} from "react-icons/md"; +import { Heading } from "tw-components"; import { KmsAwsConfig } from "./kms-aws-config"; import { KmsGcpConfig } from "./kms-gcp-config"; -import { LocalConfig } from "./local-config.tsx"; +import { LocalConfig } from "./local-config"; interface EngineWalletConfigProps { - instanceUrl: string; + instance: EngineInstance; } export const EngineWalletConfig: React.FC = ({ - instanceUrl, + instance, }) => { - const { data: localConfig } = useEngineWalletConfig(instanceUrl); - const [selected, setSelected] = useState<"aws-kms" | "gcp-kms" | "local">( - localConfig?.type || "local", - ); + const { data: walletConfig } = useEngineWalletConfig(instance.url); + + const tabContent: Record = { + local: , + "aws-kms": , + "gcp-kms": , + } as const; + + const [activeTab, setActiveTab] = useState("local"); + + const isAwsKmsConfigured = !!walletConfig?.awsAccessKeyId; + const isGcpKmsConfigured = !!walletConfig?.gcpKmsKeyRingId; return ( Backend Wallets - - Select the type of backend wallets to use.{" "} +

+ Create backend wallets on the{" "} - Learn more about backend wallets - - . - + Overview + {" "} + tab. To use other wallet types, configure them below. +

- - - - - - - {selected === "local" && } - {selected === "aws-kms" && } - {selected === "gcp-kms" && } - + ({ + key, + name, + isActive: activeTab === key, + isEnabled: true, + onClick: () => setActiveTab(key), + icon: + (key === "aws-kms" && !isAwsKmsConfigured) || + (key === "gcp-kms" && !isGcpKmsConfigured) + ? ({ className }) => ( + + + + ) + : undefined, + }))} + tabClassName="font-medium !text-sm" + /> + + {tabContent[activeTab]}
); }; diff --git a/apps/dashboard/src/components/engine/configuration/ip-allowlist.tsx b/apps/dashboard/src/components/engine/configuration/ip-allowlist.tsx index 6d27612a06b..dfc3a02eec5 100644 --- a/apps/dashboard/src/components/engine/configuration/ip-allowlist.tsx +++ b/apps/dashboard/src/components/engine/configuration/ip-allowlist.tsx @@ -2,7 +2,7 @@ import { InlineCode } from "@/components/ui/inline-code"; import { useEngineIpAllowlistConfiguration, useEngineSetIpAllowlistConfiguration, - useEngineSystemHealth, + useHasEngineFeature, } from "@3rdweb-sdk/react/hooks/useEngine"; import { Flex, Textarea } from "@chakra-ui/react"; import { useTxNotifications } from "hooks/useTxNotifications"; @@ -30,8 +30,7 @@ export const EngineIpAllowlistConfig: React.FC< "IP Allowlist updated successfully.", "Failed to update IP Allowlist", ); - - const { data: engineHealthInfo } = useEngineSystemHealth(instanceUrl); + const { isSupported } = useHasEngineFeature(instanceUrl, "IP_ALLOWLIST"); const form = useForm({ values: { raw: existingIpAllowlist?.join("\n") ?? "" }, @@ -61,7 +60,7 @@ export const EngineIpAllowlistConfig: React.FC< } }; - if (!engineHealthInfo?.features?.includes("IP_ALLOWLIST")) { + if (!isSupported) { return null; } diff --git a/apps/dashboard/src/components/engine/configuration/kms-aws-config.tsx b/apps/dashboard/src/components/engine/configuration/kms-aws-config.tsx index 495e662e007..0c951b13846 100644 --- a/apps/dashboard/src/components/engine/configuration/kms-aws-config.tsx +++ b/apps/dashboard/src/components/engine/configuration/kms-aws-config.tsx @@ -1,126 +1,177 @@ +import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { Form, FormDescription } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { + type EngineInstance, type SetWalletConfigInput, useEngineSetWalletConfig, useEngineWalletConfig, + useHasEngineFeature, } from "@3rdweb-sdk/react/hooks/useEngine"; -import { Flex, FormControl, Input } from "@chakra-ui/react"; import { useTrack } from "hooks/analytics/useTrack"; import { useTxNotifications } from "hooks/useTxNotifications"; import { useForm } from "react-hook-form"; -import { Button, Card, FormLabel, Text } from "tw-components"; interface KmsAwsConfigProps { - instanceUrl: string; + instance: EngineInstance; } -export const KmsAwsConfig: React.FC = ({ instanceUrl }) => { - const { mutate: setAwsKmsConfig } = useEngineSetWalletConfig(instanceUrl); - const { data: awsConfig } = useEngineWalletConfig(instanceUrl); +export const KmsAwsConfig: React.FC = ({ instance }) => { + const { mutate: setAwsKmsConfig, isPending } = useEngineSetWalletConfig( + instance.url, + ); + const { data: awsConfig } = useEngineWalletConfig(instance.url); + const { isSupported: supportsMultipleWalletTypes } = useHasEngineFeature( + instance.url, + "HETEROGENEOUS_WALLET_TYPES", + ); const trackEvent = useTrack(); const { onSuccess, onError } = useTxNotifications( "Configuration set successfully.", "Failed to set configuration.", ); - const transformedQueryData: SetWalletConfigInput = { + const defaultValues: SetWalletConfigInput = { type: "aws-kms" as const, - awsAccessKeyId: - awsConfig?.type === "aws-kms" ? (awsConfig?.awsAccessKeyId ?? "") : "", + awsAccessKeyId: awsConfig?.awsAccessKeyId ?? "", awsSecretAccessKey: "", - awsRegion: - awsConfig?.type === "aws-kms" ? (awsConfig?.awsRegion ?? "") : "", + awsRegion: awsConfig?.awsRegion ?? "", }; const form = useForm({ - defaultValues: transformedQueryData, - values: transformedQueryData, + defaultValues, + values: defaultValues, resetOptions: { keepDirty: true, keepDirtyValues: true, }, }); - return ( - { - setAwsKmsConfig(data, { - onSuccess: () => { - onSuccess(); - trackEvent({ - category: "engine", - action: "set-wallet-config", - type: "aws-kms", - label: "success", - }); - }, - onError: (error) => { - onError(error); - trackEvent({ - category: "engine", - action: "set-wallet-config", - type: "aws-kms", - label: "error", - error, - }); - }, + const onSubmit = (data: SetWalletConfigInput) => { + setAwsKmsConfig(data, { + onSuccess: () => { + onSuccess(); + trackEvent({ + category: "engine", + action: "set-wallet-config", + type: "aws-kms", + label: "success", + }); + }, + onError: (error) => { + onError(error); + trackEvent({ + category: "engine", + action: "set-wallet-config", + type: "aws-kms", + label: "error", + error, }); - })} - > - - - Engine supports AWS KWS for signing & sending transactions over any - EVM chain. - - - - Access Key + }, + }); + }; + + return ( +
+ +

+ AWS KMS wallets require credentials from your Amazon Web Services + account with sufficient permissions to manage KMS keys. Wallets are + stored in KMS keys on your AWS account. +

+

+ For help and more advanced use cases,{" "} + + learn more about using AWS KMS wallets + + . +

+ +
+ - - - Secret Key + + + - - - - Region - - - - - {form.formState.isDirty && ( - - This will reset your other backend wallet configurations - - )} - - - + + + + + + This will not be shown again. + + +
+ +
+ {!supportsMultipleWalletTypes && ( +

+ This will clear other credentials. +

+ )} + +
+
+ ); }; diff --git a/apps/dashboard/src/components/engine/configuration/kms-gcp-config.tsx b/apps/dashboard/src/components/engine/configuration/kms-gcp-config.tsx index 7af04965f10..a5f9c5b9508 100644 --- a/apps/dashboard/src/components/engine/configuration/kms-gcp-config.tsx +++ b/apps/dashboard/src/components/engine/configuration/kms-gcp-config.tsx @@ -1,165 +1,224 @@ +import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { Form, FormDescription } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { + type EngineInstance, type SetWalletConfigInput, useEngineSetWalletConfig, useEngineWalletConfig, + useHasEngineFeature, } from "@3rdweb-sdk/react/hooks/useEngine"; -import { Flex, FormControl, Input } from "@chakra-ui/react"; import { useTrack } from "hooks/analytics/useTrack"; import { useTxNotifications } from "hooks/useTxNotifications"; import { useForm } from "react-hook-form"; -import { Button, Card, FormLabel, Text } from "tw-components"; interface KmsGcpConfigProps { - instanceUrl: string; + instance: EngineInstance; } -export const KmsGcpConfig: React.FC = ({ instanceUrl }) => { - const { mutate: setGcpKmsConfig } = useEngineSetWalletConfig(instanceUrl); - const { data: gcpConfig } = useEngineWalletConfig(instanceUrl); +export const KmsGcpConfig: React.FC = ({ instance }) => { + const { mutate: setGcpKmsConfig, isPending } = useEngineSetWalletConfig( + instance.url, + ); + const { data: gcpConfig } = useEngineWalletConfig(instance.url); + const { isSupported: supportsMultipleWalletTypes } = useHasEngineFeature( + instance.url, + "HETEROGENEOUS_WALLET_TYPES", + ); const trackEvent = useTrack(); const { onSuccess, onError } = useTxNotifications( "Configuration set successfully.", "Failed to set configuration.", ); - const transformedQueryData: SetWalletConfigInput = { + const defaultValues: SetWalletConfigInput = { type: "gcp-kms" as const, - gcpApplicationProjectId: - gcpConfig?.type === "gcp-kms" - ? (gcpConfig?.gcpApplicationProjectId ?? "") - : "", - gcpKmsLocationId: - gcpConfig?.type === "gcp-kms" ? (gcpConfig?.gcpKmsLocationId ?? "") : "", - gcpKmsKeyRingId: - gcpConfig?.type === "gcp-kms" ? (gcpConfig?.gcpKmsKeyRingId ?? "") : "", + gcpApplicationProjectId: gcpConfig?.gcpApplicationProjectId ?? "", + gcpKmsLocationId: gcpConfig?.gcpKmsLocationId ?? "", + gcpKmsKeyRingId: gcpConfig?.gcpKmsKeyRingId ?? "", gcpApplicationCredentialEmail: - gcpConfig?.type === "gcp-kms" - ? (gcpConfig?.gcpApplicationCredentialEmail ?? "") - : "", - gcpApplicationCredentialPrivateKey: - gcpConfig?.type === "gcp-kms" - ? (gcpConfig?.gcpApplicationCredentialPrivateKey ?? "") - : "", + gcpConfig?.gcpApplicationCredentialEmail ?? "", + gcpApplicationCredentialPrivateKey: "", }; const form = useForm({ - defaultValues: transformedQueryData, - values: transformedQueryData, + defaultValues, + values: defaultValues, resetOptions: { keepDirty: true, keepDirtyValues: true, }, }); - return ( - { - setGcpKmsConfig(data, { - onSuccess: () => { - onSuccess(); - trackEvent({ - category: "engine", - action: "set-wallet-config", - type: "gcp-kms", - label: "success", - }); - }, - onError: (error) => { - onError(error); - trackEvent({ - category: "engine", - action: "set-wallet-config", - type: "gcp-kms", - label: "error", - error, - }); - }, + const onSubmit = (data: SetWalletConfigInput) => { + setGcpKmsConfig(data, { + onSuccess: () => { + onSuccess(); + trackEvent({ + category: "engine", + action: "set-wallet-config", + type: "gcp-kms", + label: "success", }); - })} - > - - - Engine supports Google KMS for signing & sending transactions over any - EVM chain. - - - - Location ID + }, + onError: (error) => { + onError(error); + trackEvent({ + category: "engine", + action: "set-wallet-config", + type: "gcp-kms", + label: "error", + error, + }); + }, + }); + }; + + return ( +
+ +

+ GCP KMS wallets require credentials from your Google Cloud Platform + account with sufficient permissions to manage KMS keys. Wallets are + stored in KMS keys on your GCP account. +

+

+ For help and more advanced use cases,{" "} + + learn more about using Google Cloud KMS wallets + + . +

+ +
+ - - - Key Ring ID + + + - - - - - Project ID + + + - - - Credential Email + + + - - - - Private Key - +
+ + +