+
+
{accountLinkingDisplayName}
{modalState.accountLinking.status === ACCOUNT_LINKING_STATUS.ERRORED ? (
-
-
{accountLinkingMessage}
+
+
{accountLinkingMessage}
) : (
)}
{accountLinkingMessage && modalState.accountLinking.status !== ACCOUNT_LINKING_STATUS.ERRORED && (
-
{accountLinkingMessage}
+
{accountLinkingMessage}
)}
);
diff --git a/packages/modal/src/ui/containers/ConnectWallet/ConnectWallet.tsx b/packages/modal/src/ui/containers/ConnectWallet/ConnectWallet.tsx
index 64f7f276e..1f3b40df8 100644
--- a/packages/modal/src/ui/containers/ConnectWallet/ConnectWallet.tsx
+++ b/packages/modal/src/ui/containers/ConnectWallet/ConnectWallet.tsx
@@ -246,6 +246,14 @@ function ConnectWallet(props: ConnectWalletProps) {
} else {
// show QR code if wallet connect v2 is supported
if (button.hasWalletConnect) {
+ // In account-linking picker mode, hand off to the WC v2 account-linking flow in
+ // modalManager (via the picker resolver in onExternalWalletLogin) instead of showing
+ // the regular inline QR — the regular QR uses a connector owned by the login flow,
+ // which is not what we want for linking.
+ if (modalState.accountLinking.pickerActive) {
+ handleExternalWalletClick({ connector: button.name });
+ return;
+ }
setSelectedButton(button);
setSelectedWallet(true);
setCurrentPage(CONNECT_WALLET_PAGES.SELECTED_WALLET);
@@ -292,6 +300,7 @@ function ConnectWallet(props: ConnectWalletProps) {
onBackClick={handleBack}
currentPage={currentPage}
selectedButton={selectedButton}
+ isLinking={modalState.accountLinking.pickerActive}
/>
{/* Body */}
{selectedWallet ? (
diff --git a/packages/modal/src/ui/containers/ConnectWallet/ConnectWalletHeader/ConnectWalletHeader.tsx b/packages/modal/src/ui/containers/ConnectWallet/ConnectWalletHeader/ConnectWalletHeader.tsx
index e45ed17e3..570b837d5 100644
--- a/packages/modal/src/ui/containers/ConnectWallet/ConnectWalletHeader/ConnectWalletHeader.tsx
+++ b/packages/modal/src/ui/containers/ConnectWallet/ConnectWalletHeader/ConnectWalletHeader.tsx
@@ -5,7 +5,7 @@ import i18n from "../../../localeImport";
import { ConnectWalletHeaderProps } from "./ConnectWalletHeader.type";
function ConnectWalletHeader(props: ConnectWalletHeaderProps) {
- const { hideBackButton, disableBackButton, onBackClick, currentPage, selectedButton } = props;
+ const { hideBackButton, disableBackButton, onBackClick, currentPage, selectedButton, isLinking } = props;
const [t] = useTranslation(undefined, { i18n });
const handleBack = () => {
@@ -44,7 +44,9 @@ function ConnectWalletHeader(props: ConnectWalletHeaderProps) {
{currentPage === CONNECT_WALLET_PAGES.SELECTED_WALLET
? selectedButton?.displayName
: currentPage === CONNECT_WALLET_PAGES.CONNECT_WALLET
- ? t("modal.connectYourWallet")
+ ? isLinking
+ ? t("modal.linkYourWallet")
+ : t("modal.connectYourWallet")
: currentPage}
diff --git a/packages/modal/src/ui/containers/ConnectWallet/ConnectWalletHeader/ConnectWalletHeader.type.ts b/packages/modal/src/ui/containers/ConnectWallet/ConnectWalletHeader/ConnectWalletHeader.type.ts
index af71b20c6..58aa1d214 100644
--- a/packages/modal/src/ui/containers/ConnectWallet/ConnectWalletHeader/ConnectWalletHeader.type.ts
+++ b/packages/modal/src/ui/containers/ConnectWallet/ConnectWalletHeader/ConnectWalletHeader.type.ts
@@ -6,4 +6,5 @@ export interface ConnectWalletHeaderProps {
currentPage: string;
selectedButton: ExternalButton;
hideBackButton?: boolean;
+ isLinking?: boolean;
}
diff --git a/packages/modal/src/ui/containers/Root/Root.tsx b/packages/modal/src/ui/containers/Root/Root.tsx
index a8afe5998..d6bf756f7 100644
--- a/packages/modal/src/ui/containers/Root/Root.tsx
+++ b/packages/modal/src/ui/containers/Root/Root.tsx
@@ -1,6 +1,6 @@
import { WALLET_CONNECTORS, type WalletRegistryItem } from "@web3auth/no-modal";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { twMerge } from "tailwind-merge";
+import { useTranslation } from "react-i18next";
import Footer from "../../components/Footer/Footer";
import Loader from "../../components/Loader";
@@ -10,6 +10,7 @@ import { useModalState } from "../../context/ModalStateContext";
import { RootProvider } from "../../context/RootContext";
import { useWidget } from "../../context/WidgetContext";
import { type ExternalButton, MODAL_STATUS } from "../../interfaces";
+import i18n from "../../localeImport";
import AccountLinking from "../AccountLinking";
import ConnectWallet from "../ConnectWallet";
import Login from "../Login";
@@ -19,6 +20,7 @@ import RootBodySheets from "./RootBodySheets/RootBodySheets";
function RootContent(props: RootProps) {
const { onCloseLoader } = props;
+ const [t] = useTranslation(undefined, { i18n });
const { modalState, shouldShowLoginPage, showPasswordLessInput, areSocialLoginsVisible } = useModalState();
const { deviceDetails, uiConfig, isConnectAndSignAuthenticationMode, handleMobileVerifyConnect, handleAcceptConsent, handleDeclineConsent } =
useWidget();
@@ -194,7 +196,11 @@ function RootContent(props: RootProps) {
return !isWalletConnectAccountLinkingVisible && modalState.status !== MODAL_STATUS.INITIALIZED;
}, [isWalletConnectAccountLinkingVisible, modalState.status]);
- const isConsentRequiringStatus = modalState.status === MODAL_STATUS.CONSENT_REQUIRING;
+ const loaderMessage = useMemo(() => {
+ const message = modalState.postLoadingMessage;
+ if (!message) return undefined;
+ return i18n.exists(message) ? t(message) : message;
+ }, [modalState.postLoadingMessage, t]);
return (
@@ -205,19 +211,14 @@ function RootContent(props: RootProps) {
}}
>
-
+
{/* Content */}
{isShowLoader ? (
}
{/* Login Screen */}
{!isWalletConnectAccountLinkingVisible &&
+ !modalState.accountLinking.pickerActive &&
modalState.currentPage === PAGES.LOGIN_OPTIONS &&
shouldShowLoginPage &&
modalState.status === MODAL_STATUS.INITIALIZED && (
@@ -245,7 +247,7 @@ function RootContent(props: RootProps) {
{/* Connect Wallet Screen */}
{!isWalletConnectAccountLinkingVisible &&
modalState.currentPage === PAGES.WALLET_LIST &&
- (!shouldShowLoginPage || isExternalWalletModeOnly) &&
+ (!shouldShowLoginPage || isExternalWalletModeOnly || modalState.accountLinking.pickerActive) &&
modalState.status === MODAL_STATUS.INITIALIZED && (
{
+ this.accountLinkingState = {
+ ...DEFAULT_ACCOUNT_LINKING_STATE,
+ active: true,
+ pickerActive: true,
+ chainId: params.chainId,
+ intent: ACCOUNT_LINKING_INTENT.LINK,
+ };
+ this.setState({
+ accountLinking: this.accountLinkingState,
+ currentPage: PAGES.WALLET_LIST,
+ modalVisibility: true,
+ status: MODAL_STATUS.INITIALIZED,
+ });
+ if (this.callbacks.onModalVisibility) {
+ this.callbacks.onModalVisibility(true);
+ }
+ };
+
+ endAccountLinkingPicker = (): void => {
+ this.accountLinkingState = { ...this.accountLinkingState, pickerActive: false };
+ this.setState({ accountLinking: this.accountLinkingState });
+ };
+
+ startConnectingLoader = (params: { connector: WALLET_CONNECTOR_TYPE | string; connectorName: string }): void => {
+ this.setState({
+ status: MODAL_STATUS.CONNECTING,
+ modalVisibility: true,
+ detailedLoaderConnector: params.connector,
+ detailedLoaderConnectorName: params.connectorName,
+ });
+ if (this.callbacks.onModalVisibility) {
+ this.callbacks.onModalVisibility(true);
+ }
+ };
+
+ markLoaderAuthorizing = (): void => {
+ this.setState({ status: MODAL_STATUS.AUTHORIZING });
+ };
+
+ endConnectingLoader = (params: { success: boolean; errorMessage?: string }): void => {
+ if (params.success) {
+ this.setState({
+ status: MODAL_STATUS.CONNECTED,
+ postLoadingMessage: "modal.post-loading.connected",
+ });
+ // Loader auto-closes after 1s on CONNECTED for non CONNECT_AND_SIGN modes.
+ // For CONNECT_AND_SIGN, the Loader does not auto-close on CONNECTED, so close explicitly.
+ if (this.uiConfig.initialAuthenticationMode === CONNECTOR_INITIAL_AUTHENTICATION_MODE.CONNECT_AND_SIGN) {
+ const delay = this.uiConfig.hideSuccessScreen ? 0 : 1000;
+ setTimeout(() => this.closeModal(), delay);
+ }
+ return;
+ }
+
+ if (this.uiConfig.displayErrorsOnModal) {
+ this.setState({
+ modalVisibility: true,
+ status: MODAL_STATUS.ERRORED,
+ postLoadingMessage: params.errorMessage || "modal.post-loading.something-wrong",
+ });
+ } else {
+ this.setState({
+ modalVisibility: false,
+ });
+ }
+ };
+
open = () => {
this.setState({
modalVisibility: true,
diff --git a/packages/no-modal/src/base/account-linking/interfaces.ts b/packages/no-modal/src/base/account-linking/interfaces.ts
index f1bec72d0..971f6ef3a 100644
--- a/packages/no-modal/src/base/account-linking/interfaces.ts
+++ b/packages/no-modal/src/base/account-linking/interfaces.ts
@@ -5,9 +5,13 @@ export type CITADEL_NETWORK = "ethereum" | "solana";
export interface LinkAccountParams {
/**
* Name of the external wallet connector to link.
- * Example: WALLET_CONNECTORS.METAMASK, WALLET_CONNECTORS.WALLET_CONNECT_V2
+ * Example: WALLET_CONNECTORS.METAMASK, WALLET_CONNECTORS.WALLET_CONNECT_V2.
+ *
+ * Optional in the modal SDK (`@web3auth/modal`): when omitted, the modal opens
+ * a wallet picker dedicated to account linking and uses the wallet selected by
+ * the user. Required in the no-modal SDK (`@web3auth/no-modal`).
*/
- connectorName: WALLET_CONNECTOR_TYPE | string;
+ connectorName?: WALLET_CONNECTOR_TYPE | string;
/**
* Chain ID to use when generating the wallet identity proof.
diff --git a/packages/no-modal/src/base/core/IWeb3Auth.ts b/packages/no-modal/src/base/core/IWeb3Auth.ts
index 025a2e0b5..7bf0f726f 100644
--- a/packages/no-modal/src/base/core/IWeb3Auth.ts
+++ b/packages/no-modal/src/base/core/IWeb3Auth.ts
@@ -264,7 +264,7 @@ export interface IWeb3Auth extends IWeb3AuthCore {
* @param params - Linking parameters including the target connector name.
* @returns A result object confirming the link, including the linked address.
*/
- linkAccount(params: LinkAccountParams): Promise;
+ linkAccount(params?: LinkAccountParams): Promise;
/**
* Unlink an external wallet from the currently authenticated user account
diff --git a/packages/no-modal/src/connectors/auth-connector/authConnector.ts b/packages/no-modal/src/connectors/auth-connector/authConnector.ts
index 5a85efe8b..3b1388cc4 100644
--- a/packages/no-modal/src/connectors/auth-connector/authConnector.ts
+++ b/packages/no-modal/src/connectors/auth-connector/authConnector.ts
@@ -726,6 +726,11 @@ class AuthConnector extends BaseConnector implements IAuthConne
signatureType: "eip191" | "sip99";
network: CITADEL_NETWORK;
}> {
+ // Notify listeners that the linking wallet is about to be asked for a signature so the UI
+ // (e.g. modal) can switch from a "connecting" loader to an "authorizing" prompt while the
+ // user reviews the signature request inside their wallet. Emitted on the isolated wallet
+ // connector (not the auth connector) so it doesn't mutate the global SDK status.
+ connector.emit(CONNECTOR_EVENTS.AUTHORIZING, { connector: connector.name as WALLET_CONNECTOR_TYPE });
const { challenge, signature, chainNamespace } = await connector.generateChallengeAndSign();
const address = await this.getLinkingWalletAddress(connector, chainNamespace);
diff --git a/packages/no-modal/src/noModal.ts b/packages/no-modal/src/noModal.ts
index be10f02f9..e4f89c0b5 100644
--- a/packages/no-modal/src/noModal.ts
+++ b/packages/no-modal/src/noModal.ts
@@ -646,7 +646,10 @@ export class Web3AuthNoModal extends SafeEventEmitter imp
}
}
- public async linkAccount(params: LinkAccountParams): Promise {
+ public async linkAccount(params?: LinkAccountParams): Promise {
+ if (!params?.connectorName) {
+ throw WalletInitializationError.invalidParams("connectorName is required when calling linkAccount on the no-modal SDK");
+ }
const chainId = this.resolveLinkAccountChainId(params.chainId);
const isolatedConnector = await this.createLinkingWalletConnector(params.connectorName, chainId);
return this.linkAccountWithConnector(params.connectorName, chainId, isolatedConnector);
diff --git a/packages/no-modal/src/react/hooks/useLinkAccount.ts b/packages/no-modal/src/react/hooks/useLinkAccount.ts
index 2be386825..0faa71917 100644
--- a/packages/no-modal/src/react/hooks/useLinkAccount.ts
+++ b/packages/no-modal/src/react/hooks/useLinkAccount.ts
@@ -14,7 +14,7 @@ export interface IUseLinkAccount {
loading: boolean;
error: Web3AuthError | null;
linkedAccounts: LinkedAccountInfo[];
- linkAccount(params: LinkAccountParams): Promise;
+ linkAccount(params?: LinkAccountParams): Promise;
unlinkAccount(address: string): Promise;
}
@@ -26,7 +26,7 @@ export const useLinkAccount = (): IUseLinkAccount => {
const [linkedAccounts, setLinkedAccounts] = useState([]);
const linkAccount = useCallback(
- async (params: LinkAccountParams): Promise => {
+ async (params?: LinkAccountParams): Promise => {
if (!web3Auth) throw WalletInitializationError.notReady();
setLoading(true);
setError(null);
diff --git a/packages/no-modal/src/vue/composables/useLinkAccount.ts b/packages/no-modal/src/vue/composables/useLinkAccount.ts
index 55080c10d..c1d7b14ec 100644
--- a/packages/no-modal/src/vue/composables/useLinkAccount.ts
+++ b/packages/no-modal/src/vue/composables/useLinkAccount.ts
@@ -15,7 +15,7 @@ export interface IUseLinkAccount {
loading: Ref;
error: Ref;
linkedAccounts: Ref;
- linkAccount(params: LinkAccountParams): Promise;
+ linkAccount(params?: LinkAccountParams): Promise;
unlinkAccount(address: string): Promise;
}
@@ -25,7 +25,7 @@ export const useLinkAccount = (): IUseLinkAccount => {
const error = ref(null);
const linkedAccounts = ref([]);
- const linkAccount = async (params: LinkAccountParams): Promise => {
+ const linkAccount = async (params?: LinkAccountParams): Promise => {
if (!web3Auth.value) throw WalletInitializationError.notReady();
try {
error.value = null;