diff --git a/demo/vue-app-new/src/components/AppDashboard.vue b/demo/vue-app-new/src/components/AppDashboard.vue index 8aacc11ae..091eea56e 100644 --- a/demo/vue-app-new/src/components/AppDashboard.vue +++ b/demo/vue-app-new/src/components/AppDashboard.vue @@ -4,6 +4,7 @@ import { CHAIN_NAMESPACES, IProvider, log, WALLET_CONNECTORS, WALLET_PLUGINS } f import { useCheckout, useFunding, + useLinkWallet, useReceive, useEnableMFA, useIdentityToken, @@ -51,6 +52,7 @@ const { showCheckout, loading: showCheckoutLoading } = useCheckout(); const { showFunding, loading: showFundingLoading } = useFunding(); const { showReceive, loading: showReceiveLoading } = useReceive(); const { getIdentityToken, loading: getIdentityTokenLoading } = useIdentityToken(); +const { linkWallet, loading: linkWalletLoading } = useLinkWallet(); const { status, address } = useConnection(); const { mutateAsync: signTypedDataAsync } = useSignTypedData(); const { mutateAsync: signMessageAsync } = useSignMessage(); @@ -351,7 +353,7 @@ const onSwitchChainNamespace = async () => {
- @@ -372,23 +374,27 @@ const onSwitchChainNamespace = async () => { > {{ isMFAEnabled ? "Manage MFA" : "Enable MFA" }} + +
Wallet Service
- - - - - @@ -460,7 +466,7 @@ const onSwitchChainNamespace = async () => { - +
; + linkWallet(): Promise; } diff --git a/packages/modal/src/modalManager.ts b/packages/modal/src/modalManager.ts index 0d4512d63..dfa0d4bd8 100644 --- a/packages/modal/src/modalManager.ts +++ b/packages/modal/src/modalManager.ts @@ -220,6 +220,14 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { }); } + public async linkWallet(): Promise { + if (!this.loginModal) throw WalletInitializationError.notReady("Login modal is not initialized"); + if (!this.connectedConnectorName || !CONNECTED_STATUSES.includes(this.status)) { + throw WalletInitializationError.notReady("Must be connected before linking a wallet"); + } + this.loginModal.openLinkWallet(); + } + protected initUIConfig(projectConfig: ProjectConfig) { super.initUIConfig(projectConfig); this.options.uiConfig = deepmerge(cloneDeep(projectConfig.whitelabel || {}), this.options.uiConfig || {}); diff --git a/packages/modal/src/ui/constants.ts b/packages/modal/src/ui/constants.ts index e1afe1cd3..c40a400ac 100644 --- a/packages/modal/src/ui/constants.ts +++ b/packages/modal/src/ui/constants.ts @@ -7,6 +7,8 @@ export const PAGES = { WALLET_LIST: "connect_wallet", /** QR code or instructions for connecting to the selected wallet */ WALLET_CONNECTION_DETAILS: "selected_wallet", + /** Link an external wallet to the logged-in account */ + LINK_WALLET: "link_wallet", }; export const CONNECT_WALLET_PAGES = { diff --git a/packages/modal/src/ui/containers/LinkWallet/LinkWallet.tsx b/packages/modal/src/ui/containers/LinkWallet/LinkWallet.tsx new file mode 100644 index 000000000..3536e1302 --- /dev/null +++ b/packages/modal/src/ui/containers/LinkWallet/LinkWallet.tsx @@ -0,0 +1,206 @@ +import { type BaseConnectorConfig, type ChainNamespaceType, log, WALLET_CONNECTORS } from "@web3auth/no-modal"; +import { FormEvent, useCallback, useMemo, useState } from "react"; + +import { useWidget } from "../../context/WidgetContext"; +import { ExternalButton } from "../../interfaces"; +import ConnectWalletChainFilter from "../ConnectWallet/ConnectWalletChainFilter"; +import ConnectWalletList from "../ConnectWallet/ConnectWalletList"; +import ConnectWalletSearch from "../ConnectWallet/ConnectWalletSearch"; +import LinkWalletConnecting from "./LinkWalletConnecting"; +import LinkWalletSignVerify from "./LinkWalletSignVerify"; +import LinkWalletSuccess from "./LinkWalletSuccess"; + +export interface LinkWalletProps { + allRegistryButtons: ExternalButton[]; + customConnectorButtons: ExternalButton[]; + connectorVisibilityMap: Record; + externalWalletsConfig: Record; +} + +type LinkWalletStep = "wallet_list" | "connecting" | "sign_verify" | "success"; + +function LinkWallet(props: LinkWalletProps) { + const { allRegistryButtons, customConnectorButtons, connectorVisibilityMap } = props; + + const { isDark, uiConfig } = useWidget(); + const { walletRegistry } = uiConfig; + + const [step, setStep] = useState("wallet_list"); + const [stepError, setStepError] = useState(false); + const [selectedWallet, setSelectedWallet] = useState(null); + const [walletSearch, setWalletSearch] = useState(""); + const [selectedChain, setSelectedChain] = useState("all"); + const [isShowAllWallets, setIsShowAllWallets] = useState(false); + + const config = useMemo(() => props.externalWalletsConfig ?? {}, [props.externalWalletsConfig]); + + const walletDiscoverySupported = useMemo( + () => walletRegistry && (Object.keys(walletRegistry.default || {}).length > 0 || Object.keys(walletRegistry.others || {}).length > 0), + [walletRegistry] + ); + + const allUniqueButtons = useMemo(() => { + const seen = new Set(); + return customConnectorButtons.concat(allRegistryButtons).filter((b: ExternalButton) => { + if (seen.has(b.name)) return false; + seen.add(b.name); + return true; + }); + }, [allRegistryButtons, customConnectorButtons]); + + const defaultButtonKeys = useMemo(() => new Set(Object.keys(walletRegistry.default)), [walletRegistry]); + + const defaultButtons = useMemo(() => { + const buttons = [ + ...allRegistryButtons.filter((b: ExternalButton) => b.hasInjectedWallet && defaultButtonKeys.has(b.name)), + ...customConnectorButtons, + ...allRegistryButtons.filter((b: ExternalButton) => !b.hasInjectedWallet && defaultButtonKeys.has(b.name)), + ].sort((a: ExternalButton, b: ExternalButton) => { + if (a.name === WALLET_CONNECTORS.METAMASK && b.name !== WALLET_CONNECTORS.METAMASK) return -1; + if (b.name === WALLET_CONNECTORS.METAMASK && a.name !== WALLET_CONNECTORS.METAMASK) return 1; + return 0; + }); + + const seen = new Set(); + return buttons + .filter((b: ExternalButton) => { + if (seen.has(b.name)) return false; + seen.add(b.name); + return true; + }) + .filter((b: ExternalButton) => selectedChain === "all" || b.chainNamespaces?.includes(selectedChain as ChainNamespaceType)); + }, [allRegistryButtons, customConnectorButtons, defaultButtonKeys, selectedChain]); + + const installedWalletButtons = useMemo(() => { + return Object.keys(config).reduce((acc: ExternalButton[], connector: string) => { + if (connector !== WALLET_CONNECTORS.WALLET_CONNECT_V2 && connectorVisibilityMap[connector]) { + acc.push({ + name: connector, + displayName: config[connector].label || connector, + hasInjectedWallet: config[connector].isInjected || false, + hasWalletConnect: false, + hasInstallLinks: false, + }); + } + return acc; + }, []); + }, [config, connectorVisibilityMap]); + + const filteredButtons = useMemo(() => { + if (walletDiscoverySupported) { + return [ + ...allUniqueButtons.filter((b: ExternalButton) => b.hasInjectedWallet), + ...allUniqueButtons.filter((b: ExternalButton) => !b.hasInjectedWallet), + ] + .sort((a: ExternalButton) => (a.name === WALLET_CONNECTORS.METAMASK ? -1 : 1)) + .filter((b: ExternalButton) => selectedChain === "all" || b.chainNamespaces?.includes(selectedChain as ChainNamespaceType)) + .filter((b: ExternalButton) => b.name.toLowerCase().includes(walletSearch.toLowerCase())); + } + return installedWalletButtons; + }, [walletDiscoverySupported, installedWalletButtons, walletSearch, allUniqueButtons, selectedChain]); + + const externalButtons = useMemo(() => { + if (walletDiscoverySupported && !walletSearch && !isShowAllWallets) return defaultButtons; + return filteredButtons; + }, [walletDiscoverySupported, walletSearch, filteredButtons, defaultButtons, isShowAllWallets]); + + const totalExternalWalletsCount = filteredButtons.length; + + const initialWalletCount = useMemo(() => { + if (isShowAllWallets) return totalExternalWalletsCount; + return walletDiscoverySupported ? defaultButtons.length : installedWalletButtons.length; + }, [walletDiscoverySupported, defaultButtons, installedWalletButtons, isShowAllWallets, totalExternalWalletsCount]); + + const handleWalletSearch = useCallback((e: FormEvent) => { + setWalletSearch((e.target as HTMLInputElement).value); + }, []); + + const handleWalletClick = useCallback((button: ExternalButton) => { + log.info("linkWallet: wallet selected", { + name: button.name, + displayName: button.displayName, + isInstalled: button.isInstalled, + hasInjectedWallet: button.hasInjectedWallet, + chainNamespaces: button.chainNamespaces, + }); + setSelectedWallet(button); + setStepError(false); + setStep("connecting"); + }, []); + + const handleMoreWallets = useCallback(() => { + setIsShowAllWallets(true); + }, []); + + const walletName = selectedWallet?.displayName || selectedWallet?.name || "Wallet"; + const walletId = selectedWallet?.name || ""; + const imgExtension = selectedWallet?.imgExtension; + + if (step === "connecting") { + return ( + { + setStepError(false); + setStep("sign_verify"); + }} + onSimulateError={() => setStepError(true)} + onRetry={() => setStepError(false)} + /> + ); + } + + if (step === "sign_verify") { + return ( + { + setStepError(false); + setStep("success"); + }} + onSimulateError={() => setStepError(true)} + onRetry={() => setStepError(false)} + /> + ); + } + + if (step === "success") { + return ; + } + + return ( +
+
+

Link a wallet

+
+
+ + + +
+
+ ); +} + +export default LinkWallet; diff --git a/packages/modal/src/ui/containers/LinkWallet/LinkWalletConnecting.tsx b/packages/modal/src/ui/containers/LinkWallet/LinkWalletConnecting.tsx new file mode 100644 index 000000000..fe7fc0ee1 --- /dev/null +++ b/packages/modal/src/ui/containers/LinkWallet/LinkWalletConnecting.tsx @@ -0,0 +1,76 @@ +import Image from "../../components/Image"; +import PulseLoader from "../../components/PulseLoader"; + +export interface LinkWalletConnectingProps { + walletName: string; + walletId: string; + imgExtension?: string; + stepError: boolean; + onSimulateSuccess: () => void; + onSimulateError: () => void; + onRetry: () => void; +} + +function LinkWalletConnecting(props: LinkWalletConnectingProps) { + const { walletName, walletId, imgExtension, stepError, onSimulateSuccess, onSimulateError, onRetry } = props; + + return ( +
+ {stepError ? ( + <> + + + +

Connection failed

+

Unable to connect to {walletName}. Please try again.

+ + + ) : ( + <> + + +

Waiting for {walletName}

+

Please approve the connection request in your wallet.

+ + )} + {/* TODO: remove -- temporary test buttons */} +
+ + +
+
+ ); +} + +export default LinkWalletConnecting; diff --git a/packages/modal/src/ui/containers/LinkWallet/LinkWalletSignVerify.tsx b/packages/modal/src/ui/containers/LinkWallet/LinkWalletSignVerify.tsx new file mode 100644 index 000000000..5010ba73f --- /dev/null +++ b/packages/modal/src/ui/containers/LinkWallet/LinkWalletSignVerify.tsx @@ -0,0 +1,76 @@ +import Image from "../../components/Image"; +import PulseLoader from "../../components/PulseLoader"; + +export interface LinkWalletSignVerifyProps { + walletName: string; + walletId: string; + imgExtension?: string; + stepError: boolean; + onSimulateSuccess: () => void; + onSimulateError: () => void; + onRetry: () => void; +} + +function LinkWalletSignVerify(props: LinkWalletSignVerifyProps) { + const { walletName, walletId, imgExtension, stepError, onSimulateSuccess, onSimulateError, onRetry } = props; + + return ( +
+ {stepError ? ( + <> + + + +

Verification failed

+

Unable to verify signature from {walletName}. Please try again.

+ + + ) : ( + <> + + +

Sign to verify

+

Please sign the verification request in {walletName} to link your wallet.

+ + )} + {/* TODO: remove -- temporary test buttons */} +
+ + +
+
+ ); +} + +export default LinkWalletSignVerify; diff --git a/packages/modal/src/ui/containers/LinkWallet/LinkWalletSuccess.tsx b/packages/modal/src/ui/containers/LinkWallet/LinkWalletSuccess.tsx new file mode 100644 index 000000000..41869c50f --- /dev/null +++ b/packages/modal/src/ui/containers/LinkWallet/LinkWalletSuccess.tsx @@ -0,0 +1,29 @@ +import Image from "../../components/Image"; + +export interface LinkWalletSuccessProps { + walletName: string; + walletId: string; + imgExtension?: string; +} + +function LinkWalletSuccess(props: LinkWalletSuccessProps) { + const { walletName, walletId, imgExtension } = props; + + return ( +
+ +

Wallet linked successfully

+

{walletName} has been linked to your account.

+
+ ); +} + +export default LinkWalletSuccess; diff --git a/packages/modal/src/ui/containers/LinkWallet/index.ts b/packages/modal/src/ui/containers/LinkWallet/index.ts new file mode 100644 index 000000000..e4f0024ec --- /dev/null +++ b/packages/modal/src/ui/containers/LinkWallet/index.ts @@ -0,0 +1,2 @@ +export type { LinkWalletProps } from "./LinkWallet"; +export { default } from "./LinkWallet"; diff --git a/packages/modal/src/ui/containers/Root/Root.tsx b/packages/modal/src/ui/containers/Root/Root.tsx index b3007f0b9..2c6bc6a1e 100644 --- a/packages/modal/src/ui/containers/Root/Root.tsx +++ b/packages/modal/src/ui/containers/Root/Root.tsx @@ -11,6 +11,7 @@ import { RootProvider } from "../../context/RootContext"; import { useWidget } from "../../context/WidgetContext"; import { ExternalButton, MODAL_STATUS } from "../../interfaces"; import ConnectWallet from "../ConnectWallet"; +import LinkWallet from "../LinkWallet"; import Login from "../Login"; import { RootProps } from "./Root.type"; import RootBodySheets from "./RootBodySheets/RootBodySheets"; @@ -233,6 +234,15 @@ function RootContent(props: RootProps) { isExternalWalletModeOnly={isExternalWalletModeOnly} /> )} + {/* Link Wallet Screen */} + {modalState.currentPage === PAGES.LINK_WALLET && modalState.status === MODAL_STATUS.INITIALIZED && ( + + )} )} diff --git a/packages/modal/src/ui/loginModal.tsx b/packages/modal/src/ui/loginModal.tsx index eee4fb4d7..b616fcfed 100644 --- a/packages/modal/src/ui/loginModal.tsx +++ b/packages/modal/src/ui/loginModal.tsx @@ -335,6 +335,17 @@ export class LoginModal { } }; + openLinkWallet = () => { + this.setState({ + modalVisibility: true, + currentPage: PAGES.LINK_WALLET, + status: MODAL_STATUS.INITIALIZED, + }); + if (this.callbacks.onModalVisibility) { + this.callbacks.onModalVisibility(true); + } + }; + initExternalWalletContainer = () => { this.setState({ hasExternalWallets: true, diff --git a/packages/modal/src/vue/composables/index.ts b/packages/modal/src/vue/composables/index.ts index e14ac8e2a..0baa024a0 100644 --- a/packages/modal/src/vue/composables/index.ts +++ b/packages/modal/src/vue/composables/index.ts @@ -3,6 +3,7 @@ export * from "./useCheckout"; export * from "./useEnableMFA"; export * from "./useFunding"; export * from "./useIdentityToken"; +export * from "./useLinkWallet"; export * from "./useManageMFA"; export * from "./useReceive"; export * from "./useSwap"; diff --git a/packages/modal/src/vue/composables/useLinkWallet.ts b/packages/modal/src/vue/composables/useLinkWallet.ts new file mode 100644 index 000000000..64a1a7fab --- /dev/null +++ b/packages/modal/src/vue/composables/useLinkWallet.ts @@ -0,0 +1,36 @@ +import { log, WalletInitializationError, Web3AuthError } from "@web3auth/no-modal"; +import { Ref, ref } from "vue"; + +import { useWeb3AuthInner } from "./useWeb3AuthInner"; + +export interface IUseLinkWallet { + loading: Ref; + error: Ref; + linkWallet(): Promise; +} + +export const useLinkWallet = (): IUseLinkWallet => { + const { web3Auth } = useWeb3AuthInner(); + const loading = ref(false); + const error = ref(null); + + const linkWallet = async () => { + try { + if (!web3Auth.value) throw WalletInitializationError.notReady(); + error.value = null; + loading.value = true; + await web3Auth.value.linkWallet(); + } catch (err) { + log.error("Error opening link wallet", err); + error.value = err as Web3AuthError; + } finally { + loading.value = false; + } + }; + + return { + loading, + error, + linkWallet, + }; +};