From e5aa4b9c3b48660ac8b29748c4a48f99b614e1b4 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Thu, 22 May 2025 13:00:12 -0700 Subject: [PATCH] [SDK] Add required email verification for in-app wallet --- .../in-app-wallet/connect-button.tsx | 5 +- .../components/in-app-wallet/sponsored-tx.tsx | 1 + apps/playground-web/src/lib/constants.ts | 1 + .../ui/ConnectWallet/Modal/ConnectEmbed.tsx | 33 +++- .../Modal/ConnectModalContent.tsx | 175 +++++++++++++++--- .../react/web/ui/ConnectWallet/constants.ts | 1 + .../screens/LinkProfileScreen.tsx | 3 +- .../shared/ConnectWalletSocialOptions.tsx | 88 ++++++++- .../src/wallets/in-app/core/wallet/types.ts | 3 + 9 files changed, 278 insertions(+), 32 deletions(-) diff --git a/apps/playground-web/src/components/in-app-wallet/connect-button.tsx b/apps/playground-web/src/components/in-app-wallet/connect-button.tsx index 036214ac10a..f69c5abd999 100644 --- a/apps/playground-web/src/components/in-app-wallet/connect-button.tsx +++ b/apps/playground-web/src/components/in-app-wallet/connect-button.tsx @@ -1,11 +1,11 @@ "use client"; import { inAppWallet } from "thirdweb/wallets/in-app"; -import { StyledConnectEmbed } from "../styled-connect-embed"; +import { StyledConnectButton } from "../styled-connect-button"; export function InAppConnectEmbed() { return ( - { + if (!activeWallet || !profiles) { + return false; + } + + const walletConfig = ( + activeWallet as unknown as { + getConfig?: () => { auth?: { required?: string[] } } | undefined; + } + ).getConfig?.(); + + const required = walletConfig?.auth?.required || []; + const requiresEmail = required.includes("email"); + + if (!requiresEmail) { + return false; + } + + const hasEmail = profiles.some((p) => !!p.details.email); + + return !hasEmail; + })(); + const show = - !activeAccount || (siweAuth.requiresAuth && !siweAuth.isLoggedIn); + !activeAccount || + (siweAuth.requiresAuth && !siweAuth.isLoggedIn) || + needsEmailLink; const connectionManager = useConnectionManager(); // Add props.chain and props.chains to defined chains store diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModalContent.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModalContent.tsx index b54dbfb5ea7..28dca17af98 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModalContent.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModalContent.tsx @@ -1,7 +1,10 @@ "use client"; -import { Suspense, lazy, useCallback } from "react"; +import { Suspense, lazy, useCallback, useEffect, useState } from "react"; import type { Chain } from "../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import { isEcosystemWallet } from "../../../../../wallets/ecosystem/is-ecosystem-wallet.js"; +import type { Profile } from "../../../../../wallets/in-app/core/authentication/types.js"; +import { getProfiles } from "../../../../../wallets/in-app/web/lib/auth/index.js"; import type { Wallet } from "../../../../../wallets/interfaces/wallet.js"; import type { SmartWalletOptions } from "../../../../../wallets/smart/types.js"; import type { WalletId } from "../../../../../wallets/wallet-types.js"; @@ -13,11 +16,13 @@ import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccoun import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js"; import { useSetActiveWallet } from "../../../../core/hooks/wallets/useSetActiveWallet.js"; import { useConnectionManager } from "../../../../core/providers/connection-manager.js"; +import { useProfiles } from "../../../hooks/wallets/useProfiles.js"; import { useSetSelectionData } from "../../../providers/wallet-ui-states-provider.js"; import { LoadingScreen } from "../../../wallets/shared/LoadingScreen.js"; import { WalletSelector } from "../WalletSelector.js"; import { onModalUnmount, reservedScreens } from "../constants.js"; import type { ConnectLocale } from "../locale/types.js"; +import { LinkProfileScreen } from "../screens/LinkProfileScreen.js"; import { SignatureScreen } from "../screens/SignatureScreen.js"; import { StartScreen } from "../screens/StartScreen.js"; import type { WelcomeScreen } from "../screens/types.js"; @@ -84,45 +89,147 @@ export const ConnectModalContent = (props: { const showSignatureScreen = siweAuth.requiresAuth && !siweAuth.isLoggedIn; const connectionManager = useConnectionManager(); + // state to hold wallet awaiting email link + const [pendingWallet, setPendingWallet] = useState(); + + // get profiles to observe email linking + const profilesQuery = useProfiles({ client: props.client }); + const handleConnected = useCallback( - (wallet: Wallet) => { - if (shouldSetActive) { - setActiveWallet(wallet); - } else { - connectionManager.addConnectedWallet(wallet); - } + async (wallet: Wallet) => { + // we will only set active wallet and call onConnect once requirements are met + const finalizeConnection = (w: Wallet) => { + if (shouldSetActive) { + setActiveWallet(w); + } else { + connectionManager.addConnectedWallet(w); + } + + if (props.onConnect) { + props.onConnect(w); + } + + onModalUnmount(() => { + setSelectionData({}); + setScreen(initialScreen); + setModalVisibility(true); + }); + }; + + // ---------------------------------------------------------------- + // Enforce required profile linking (currently only "email") + // ---------------------------------------------------------------- + type WalletConfig = { + auth?: { + required?: string[]; + }; + partnerId?: string; + }; + + const walletWithConfig = wallet as unknown as { + getConfig?: () => WalletConfig | undefined; + }; + + const walletConfig = walletWithConfig.getConfig + ? walletWithConfig.getConfig() + : undefined; + const required = walletConfig?.auth?.required as string[] | undefined; + const requiresEmail = required?.includes("email"); - if (props.onConnect) { - props.onConnect(wallet); + console.log("wallet", walletConfig); + + console.log("requiresEmail", requiresEmail); + + if (requiresEmail) { + try { + const ecosystem = isEcosystemWallet(wallet) + ? { id: wallet.id, partnerId: walletConfig?.partnerId } + : undefined; + + const profiles = await getProfiles({ + client: props.client, + ecosystem, + }); + + console.log("profiles", profiles); + + const hasEmail = (profiles as Profile[]).some( + (p) => !!p.details.email, + ); + + console.log("hasEmail", hasEmail); + + if (!hasEmail) { + setPendingWallet(wallet); + setScreen(reservedScreens.linkProfile); + return; // defer activation until linked + } + } catch (err) { + console.error("Failed to fetch profiles for required linking", err); + // if fetching profiles fails, just continue the normal flow + } } - onModalUnmount(() => { - setSelectionData({}); - setModalVisibility(true); - }); + // ---------------------------------------------------------------- + // Existing behavior (sign in step / close modal) + // ---------------------------------------------------------------- - // show sign in screen if required if (showSignatureScreen) { setScreen(reservedScreens.signIn); } else { - setScreen(initialScreen); + finalizeConnection(wallet); onClose?.(); } }, [ - setModalVisibility, - onClose, - props.onConnect, + shouldSetActive, setActiveWallet, - showSignatureScreen, - setScreen, + connectionManager, + props.onConnect, setSelectionData, - shouldSetActive, + setModalVisibility, + props.client, + setScreen, + showSignatureScreen, initialScreen, - connectionManager, + onClose, ], ); + // Effect to watch for email linking completion + useEffect(() => { + if (!pendingWallet) { + return; + } + const profiles = profilesQuery.data; + if (!profiles) { + return; + } + const hasEmail = profiles.some((p) => !!p.details.email); + if (hasEmail) { + // finalize connection now + if (shouldSetActive) { + setActiveWallet(pendingWallet); + } else { + connectionManager.addConnectedWallet(pendingWallet); + } + props.onConnect?.(pendingWallet); + setPendingWallet(undefined); + setScreen(initialScreen); + onClose?.(); + } + }, [ + profilesQuery.data, + pendingWallet, + shouldSetActive, + setActiveWallet, + connectionManager, + props.onConnect, + setScreen, + initialScreen, + onClose, + ]); + const handleBack = useCallback(() => { setSelectionData({}); setScreen(initialScreen); @@ -145,7 +252,9 @@ export const ConnectModalContent = (props: { onShowAll={() => { setScreen(reservedScreens.showAll); }} - done={handleConnected} + done={async (w) => { + await handleConnected(w); + }} goBack={props.wallets.length > 1 ? handleBack : undefined} setModalVisibility={setModalVisibility} client={props.client} @@ -195,8 +304,8 @@ export const ConnectModalContent = (props: { { - handleConnected(smartWallet); + done={async (smartWallet) => { + await handleConnected(smartWallet); }} personalWallet={wallet} onBack={goBack} @@ -217,8 +326,8 @@ export const ConnectModalContent = (props: { key={wallet.id} wallet={wallet} onBack={goBack} - done={() => { - handleConnected(wallet); + done={async () => { + await handleConnected(wallet); }} setModalVisibility={props.setModalVisibility} chain={props.chain} @@ -245,6 +354,16 @@ export const ConnectModalContent = (props: { /> ); + const linkProfileScreen = ( + + ); + return ( {props.size === "wide" ? ( @@ -256,6 +375,7 @@ export const ConnectModalContent = (props: { {screen === reservedScreens.main && getStarted} {screen === reservedScreens.getStarted && getStarted} {screen === reservedScreens.showAll && showAll} + {screen === reservedScreens.linkProfile && linkProfileScreen} {typeof screen !== "string" && getWalletUI(screen)} } @@ -266,6 +386,7 @@ export const ConnectModalContent = (props: { {screen === reservedScreens.main && walletList} {screen === reservedScreens.getStarted && getStarted} {screen === reservedScreens.showAll && showAll} + {screen === reservedScreens.linkProfile && linkProfileScreen} {typeof screen !== "string" && getWalletUI(screen)} )} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/constants.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/constants.ts index aa8386cf093..02327eaf8d4 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/constants.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/constants.ts @@ -3,6 +3,7 @@ export const reservedScreens = { getStarted: "getStarted", signIn: "signIn", showAll: "showAll", + linkProfile: "linkProfile", }; export const modalMaxWidthCompact = "360px"; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkProfileScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkProfileScreen.tsx index caed3ecfb32..fd246c872d9 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkProfileScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkProfileScreen.tsx @@ -28,13 +28,14 @@ export function LinkProfileScreen(props: { locale: ConnectLocale; client: ThirdwebClient; walletConnect: { projectId?: string } | undefined; + wallet?: Wallet; }) { const adminWallet = useAdminWallet(); const activeWallet = useActiveWallet(); const chain = useActiveWalletChain(); const queryClient = useQueryClient(); - const wallet = adminWallet || activeWallet; + const wallet = props.wallet || adminWallet || activeWallet; if (!wallet) { return ; diff --git a/packages/thirdweb/src/react/web/wallets/shared/ConnectWalletSocialOptions.tsx b/packages/thirdweb/src/react/web/wallets/shared/ConnectWalletSocialOptions.tsx index 7059402b9a8..aa405a12f9b 100644 --- a/packages/thirdweb/src/react/web/wallets/shared/ConnectWalletSocialOptions.tsx +++ b/packages/thirdweb/src/react/web/wallets/shared/ConnectWalletSocialOptions.tsx @@ -37,6 +37,8 @@ import { Spacer } from "../../ui/components/Spacer.js"; import { TextDivider } from "../../ui/components/TextDivider.js"; import { Container } from "../../ui/components/basic.js"; import { Button } from "../../ui/components/buttons.js"; +import { Input, InputContainer } from "../../ui/components/formElements.js"; +import { Text } from "../../ui/components/text.js"; import { InputSelectionUI } from "../in-app/InputSelectionUI.js"; import { validateEmail } from "../in-app/validateEmail.js"; import { LoadingScreen } from "./LoadingScreen.js"; @@ -132,10 +134,16 @@ export const ConnectWalletSocialOptions = ( enabled: isEcosystemWallet(wallet), retry: false, }); - const authOptions = isEcosystemWallet(wallet) + const authOptions: AuthOption[] = isEcosystemWallet(wallet) ? (ecosystemAuthOptions ?? defaultAuthOptions) : (wallet.getConfig()?.auth?.options ?? defaultAuthOptions); + // If we're in linking mode AND the wallet requires an email, restrict options to email only + const requiresEmail = ( + (wallet.getConfig()?.auth as { required?: string[] } | undefined) + ?.required || [] + ).includes("email"); + const emailIndex = authOptions.indexOf("email"); const isEmailEnabled = emailIndex !== -1; const phoneIndex = authOptions.indexOf("phone"); @@ -319,6 +327,20 @@ export const ConnectWalletSocialOptions = ( const showOnlyIcons = socialLogins.length > 2; + // ------------------------------------------------------------ + // When wallet requires an email, show a dedicated prompt + // ------------------------------------------------------------ + if (props.isLinking && requiresEmail) { + return ( + + ); + } + return ( void; + setData: (v: ConnectWalletSelectUIState) => void; + locale: InAppWalletLocale; + disabled?: boolean; +}) { + const [email, setEmail] = useState(""); + const [error, setError] = useState(); + + const isValidEmail = email ? validateEmail(email.toLowerCase()) : false; + + const handleContinue = () => { + if (!isValidEmail) { + setError(props.locale.invalidEmail); + return; + } + props.setData({ emailLogin: email }); + props.select(); + }; + + return ( + + + + Verify your email + + + { + setEmail(e.target.value); + if (error) setError(undefined); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleContinue(); + } + }} + /> + + {error && ( + + {error} + + )} + + + ); +} diff --git a/packages/thirdweb/src/wallets/in-app/core/wallet/types.ts b/packages/thirdweb/src/wallets/in-app/core/wallet/types.ts index 0f728cbd615..3759ef5ed72 100644 --- a/packages/thirdweb/src/wallets/in-app/core/wallet/types.ts +++ b/packages/thirdweb/src/wallets/in-app/core/wallet/types.ts @@ -60,6 +60,8 @@ export type ExecutionModeOptions = mode: "EOA"; }; +export type InAppWalletRequired = "email"; + export type InAppWalletCreationOptions = | { auth?: { @@ -67,6 +69,7 @@ export type InAppWalletCreationOptions = * List of authentication options to display in the Connect Modal */ options: InAppWalletAuth[]; + required?: InAppWalletRequired[]; /** * Whether to display the social auth prompt in a popup or redirect */