-
Notifications
You must be signed in to change notification settings - Fork 257
feat: account linking #2423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
feat: account linking #2423
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, boolean>; | ||
| externalWalletsConfig: Record<string, BaseConnectorConfig>; | ||
| } | ||
|
|
||
| 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<LinkWalletStep>("wallet_list"); | ||
| const [stepError, setStepError] = useState(false); | ||
| const [selectedWallet, setSelectedWallet] = useState<ExternalButton | null>(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<string>(); | ||
| 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<string>(); | ||
| 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]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extensive duplicated wallet filtering logic across componentsMedium Severity
|
||
|
|
||
| const handleWalletSearch = useCallback((e: FormEvent<HTMLInputElement>) => { | ||
| 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"); | ||
| }, []); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const handleMoreWallets = useCallback(() => { | ||
| setIsShowAllWallets(true); | ||
| }, []); | ||
|
|
||
| const walletName = selectedWallet?.displayName || selectedWallet?.name || "Wallet"; | ||
| const walletId = selectedWallet?.name || ""; | ||
| const imgExtension = selectedWallet?.imgExtension; | ||
|
|
||
| if (step === "connecting") { | ||
| return ( | ||
| <LinkWalletConnecting | ||
| walletName={walletName} | ||
| walletId={walletId} | ||
| imgExtension={imgExtension} | ||
| stepError={stepError} | ||
| onSimulateSuccess={() => { | ||
| setStepError(false); | ||
| setStep("sign_verify"); | ||
| }} | ||
| onSimulateError={() => setStepError(true)} | ||
| onRetry={() => setStepError(false)} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| if (step === "sign_verify") { | ||
| return ( | ||
| <LinkWalletSignVerify | ||
| walletName={walletName} | ||
| walletId={walletId} | ||
| imgExtension={imgExtension} | ||
| stepError={stepError} | ||
| onSimulateSuccess={() => { | ||
| setStepError(false); | ||
| setStep("success"); | ||
| }} | ||
| onSimulateError={() => setStepError(true)} | ||
| onRetry={() => setStepError(false)} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| if (step === "success") { | ||
| return <LinkWalletSuccess walletName={walletName} walletId={walletId} imgExtension={imgExtension} />; | ||
| } | ||
|
|
||
| return ( | ||
| <div className="w3a--relative w3a--flex w3a--flex-1 w3a--flex-col w3a--gap-y-4"> | ||
| <div className="w3a--flex w3a--items-center w3a--justify-center"> | ||
| <p className="w3a--text-base w3a--font-medium w3a--text-app-gray-900 dark:w3a--text-app-white">Link a wallet</p> | ||
| </div> | ||
| <div className="w3a--flex w3a--flex-col w3a--gap-y-2"> | ||
| <ConnectWalletChainFilter isDark={isDark} isLoading={false} selectedChain={selectedChain} setSelectedChain={setSelectedChain} /> | ||
| <ConnectWalletSearch | ||
| totalExternalWalletCount={totalExternalWalletsCount} | ||
| isLoading={false} | ||
| walletSearch={walletSearch} | ||
| handleWalletSearch={handleWalletSearch} | ||
| /> | ||
| <ConnectWalletList | ||
| externalButtons={externalButtons} | ||
| isLoading={false} | ||
| totalExternalWalletsCount={totalExternalWalletsCount} | ||
| initialWalletCount={initialWalletCount} | ||
| handleWalletClick={handleWalletClick} | ||
| handleMoreWallets={handleMoreWallets} | ||
| isDark={isDark} | ||
| walletConnectUri="" | ||
| isShowAllWallets={isShowAllWallets} | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default LinkWallet; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className="w3a--flex w3a--flex-1 w3a--flex-col w3a--items-center w3a--justify-center w3a--gap-y-4 w3a--px-6 w3a--py-8 w3a--text-center"> | ||
| {stepError ? ( | ||
| <> | ||
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" className="w3a--error-logo"> | ||
| <path | ||
| fill="currentColor" | ||
| fillRule="evenodd" | ||
| d="M18 10a8 8 0 1 1-16.001 0A8 8 0 0 1 18 10m-7 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1-9a1 1 0 0 0-1 1v4a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1" | ||
| clipRule="evenodd" | ||
| /> | ||
| </svg> | ||
| <p className="w3a--text-lg w3a--font-semibold w3a--text-app-gray-900 dark:w3a--text-app-white">Connection failed</p> | ||
| <p className="w3a--text-sm w3a--text-app-gray-400">Unable to connect to {walletName}. Please try again.</p> | ||
| <button | ||
| type="button" | ||
| onClick={onRetry} | ||
| className="w3a--w-full w3a--rounded-xl w3a--bg-app-gray-100 w3a--p-2 w3a--py-3 w3a--text-center w3a--text-sm w3a--text-app-gray-900 dark:w3a--bg-app-gray-800 dark:w3a--text-app-white" | ||
| > | ||
| Retry | ||
| </button> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <Image | ||
| imageId={`login-${walletId}`} | ||
| hoverImageId={`login-${walletId}`} | ||
| fallbackImageId="wallet" | ||
| height="60" | ||
| width="60" | ||
| isButton | ||
| extension={imgExtension} | ||
| /> | ||
| <PulseLoader /> | ||
| <p className="w3a--text-lg w3a--font-semibold w3a--text-app-gray-900 dark:w3a--text-app-white">Waiting for {walletName}</p> | ||
| <p className="w3a--text-sm w3a--text-app-gray-400">Please approve the connection request in your wallet.</p> | ||
| </> | ||
| )} | ||
| {/* TODO: remove -- temporary test buttons */} | ||
| <div className="w3a--mt-4 w3a--flex w3a--w-full w3a--gap-x-2"> | ||
| <button | ||
| type="button" | ||
| onClick={onSimulateSuccess} | ||
| className="w3a--flex-1 w3a--rounded-xl w3a--bg-app-gray-50 w3a--p-2 w3a--text-xs w3a--text-app-gray-400 dark:w3a--bg-app-gray-700 dark:w3a--text-app-gray-300" | ||
| > | ||
| Simulate success | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={onSimulateError} | ||
| className="w3a--flex-1 w3a--rounded-xl w3a--bg-app-gray-50 w3a--p-2 w3a--text-xs w3a--text-app-gray-400 dark:w3a--bg-app-gray-700 dark:w3a--text-app-gray-300" | ||
| > | ||
| Simulate error | ||
| </button> | ||
| </div> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Temporary simulate buttons committed in production componentsHigh Severity
Additional Locations (1) |
||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default LinkWalletConnecting; | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing auto-expand effect from duplicated wallet logic
Low Severity
LinkWalletduplicates the wallet discovery logic fromConnectWalletbut omits theuseEffectthat auto-setsisShowAllWalletstotruewhentotalExternalWalletsCount <= 15. InConnectWallet, this effect ensures small wallet lists are fully expanded immediately. Without it,LinkWalletalways starts with only the "default" subset visible and shows a "More Wallets" button — even when there are only a handful of wallets — creating a UX inconsistency. Extracting the shared filtering/sorting logic into a common hook would prevent such drift.