Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions demo/vue-app-new/src/components/AccountLinkingSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ const onLinkAccount = async () => {
}
};

const onLinkAccountWithPicker = async () => {
lastUnlinkedAddress.value = null;
const result = await linkAccount();
if (result) {
await props.refreshUserInfo();
props.printToConsole("Link Wallet (picker) Result", result);
}
};

const onUnlinkAccount = async (address: string) => {
lastUnlinkedAddress.value = null;
pendingUnlinkAddress.value = address;
Expand Down Expand Up @@ -110,7 +119,9 @@ const getWalletCardClasses = (account: ConnectedAccountInfo): string => {
Switch the active wallet here. Non-primary inactive wallets can also be unlinked.
</p>
</div>
<p class="inline-flex self-start rounded-full bg-app-gray-100 px-3 py-1 text-xs font-semibold text-app-gray-700 dark:bg-app-gray-800 dark:text-app-gray-200">
<p
class="inline-flex self-start rounded-full bg-app-gray-100 px-3 py-1 text-xs font-semibold text-app-gray-700 dark:bg-app-gray-800 dark:text-app-gray-200"
>
Total: {{ connectedWallets.length }}
</p>
</div>
Expand Down Expand Up @@ -183,7 +194,11 @@ const getWalletCardClasses = (account: ConnectedAccountInfo): string => {
>
authConnectionId: {{ account.authConnectionId }}
</p>
<p v-if="account.aaAddress" class="mt-2 truncate text-xs leading-5 text-app-gray-400 dark:text-app-gray-400" :title="`Smart account: ${account.aaAddress}`">
<p
v-if="account.aaAddress"
class="mt-2 truncate text-xs leading-5 text-app-gray-400 dark:text-app-gray-400"
:title="`Smart account: ${account.aaAddress}`"
>
Smart account: {{ account.aaAddress }}
</p>

Expand Down Expand Up @@ -229,7 +244,7 @@ const getWalletCardClasses = (account: ConnectedAccountInfo): string => {
<p v-else class="text-xs leading-5 text-app-gray-500 dark:text-app-gray-300">No connected wallets found in user info yet.</p>
</Card>

<Card v-if="showLinkWallet" class="mb-2 !h-auto gap-4 overflow-hidden px-4 py-4" :shadow="false">
<Card v-if="showLinkWallet" class="mb-2 !h-auto gap-2 overflow-hidden px-4 py-4 flex flex-col" :shadow="false">
<div class="text-left text-xl font-bold leading-tight text-app-gray-900 dark:text-app-white">Link Wallet</div>
<p class="mt-1 break-all text-xs leading-5 text-app-gray-500 dark:text-app-gray-300">
Choose a wallet connector to start the account-linking flow.
Expand All @@ -238,5 +253,6 @@ const getWalletCardClasses = (account: ConnectedAccountInfo): string => {
<Select v-model="linkConnector" :options="linkConnectorOptions" placeholder="Select wallet" matchParentsWidth />
</div>
<Button :loading="accountLinkingLoading" block size="xs" pill class="!mb-0" @click="onLinkAccount">Link Wallet</Button>
<Button :loading="accountLinkingLoading" block size="xs" pill @click="onLinkAccountWithPicker">Link with another wallet (modal picker)</Button>
</Card>
</template>
143 changes: 122 additions & 21 deletions packages/modal/src/modalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal {

private removeAccountLinkingResetOnCloseListener: (() => void) | null = null;

private accountLinkingPickerResolver: ((connectorName: WALLET_CONNECTOR_TYPE | string) => void) | null = null;

private removeAccountLinkingPickerCloseListener: (() => void) | null = null;

constructor(options: Web3AuthOptions, initialState?: Partial<IWeb3AuthState>) {
super(options, initialState);
this.options = { ...options };
Expand Down Expand Up @@ -282,10 +286,12 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal {
switchResult.kind === "external" && !isExistingConnectorConnected ? (await this.getProjectAndWalletConfig())?.projectConfig : undefined;

if (switchResult.kind !== "external" || isExistingConnectorConnected) {
await super.processSwitchAccountResult(authConnector, switchResult, {
walletConnector: isExistingConnectorConnected && existingConnector ? existingConnector : undefined,
projectConfig,
});
await this.runNonWalletConnectAccountAction(switchResult.targetAccount.connector, () =>
super.processSwitchAccountResult(authConnector, switchResult, {
walletConnector: isExistingConnectorConnected && existingConnector ? existingConnector : undefined,
projectConfig,
})
);
await authConnector.trackSwitchAccountCompleted(switchResult.targetAccount);
return;
}
Expand All @@ -297,10 +303,12 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal {
);

if (connectorToSwitchTo.name !== WALLET_CONNECTORS.WALLET_CONNECT_V2) {
await super.processSwitchAccountResult(authConnector, switchResult, {
walletConnector: connectorToSwitchTo,
projectConfig,
});
await this.runNonWalletConnectAccountAction(switchResult.targetAccount.connector, () =>
super.processSwitchAccountResult(authConnector, switchResult, {
walletConnector: connectorToSwitchTo,
projectConfig,
})
);
await authConnector.trackSwitchAccountCompleted(switchResult.targetAccount);
return;
}
Expand All @@ -312,22 +320,18 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal {
}
}

public async linkAccount(params: LinkAccountParams): Promise<LinkAccountResult> {
const chainId = this.resolveLinkAccountChainId(params.chainId);
const connectorToLink = await this.prepareAccountLinkingConnector(params.connectorName, chainId);
public async linkAccount(params?: LinkAccountParams): Promise<LinkAccountResult> {
// Pre-flight: ensure user is connected via AUTH so we fail fast before opening the modal.
this.getMainAuthConnector();

if (connectorToLink.name !== WALLET_CONNECTORS.WALLET_CONNECT_V2) {
return super.linkAccountWithConnector(params.connectorName, chainId, connectorToLink);
const chainId = this.resolveLinkAccountChainId(params?.chainId);

if (!params?.connectorName) {
const pickedConnector = await this.pickWalletForAccountLinking(chainId);
return this.linkAccountWithChosenConnector(pickedConnector, chainId);
}

return this.runWalletConnectV2AccountAction({
connector: connectorToLink,
connectParams: { chainId, isAccountLinking: true },
onConnected: async (connector) => ({
result: await super.linkAccountWithConnector(params.connectorName, chainId, connector),
}),
intent: ACCOUNT_LINKING_INTENT.LINK,
});
return this.linkAccountWithChosenConnector(params.connectorName, chainId);
}

protected startAccountLinkingModalSession(params: {
Expand Down Expand Up @@ -819,6 +823,13 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal {
connector: WALLET_CONNECTOR_TYPE | string;
loginParams: { chainNamespace: ChainNamespaceType };
}): Promise<void> => {
// If the modal is in account-linking picker mode, hand off the selected connector
// to the linking flow instead of running the regular login `connectTo`.
if (this.accountLinkingPickerResolver) {
const resolver = this.accountLinkingPickerResolver;
resolver(params.connector);
return;
}
try {
const connector = this.getConnector(params.connector as WALLET_CONNECTOR_TYPE, params.loginParams?.chainNamespace);
// auto-connect WalletConnect in background to generate QR code URI without interfering with user's selected connection
Expand Down Expand Up @@ -1019,6 +1030,96 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal {
}
}

private async linkAccountWithChosenConnector(connectorName: WALLET_CONNECTOR_TYPE | string, chainId: string): Promise<LinkAccountResult> {
const connectorToLink = await this.prepareAccountLinkingConnector(connectorName, chainId);

if (connectorToLink.name !== WALLET_CONNECTORS.WALLET_CONNECT_V2) {
return this.runNonWalletConnectAccountAction(connectorName, () => super.linkAccountWithConnector(connectorName, chainId, connectorToLink), {
connector: connectorToLink,
});
}

return this.runWalletConnectV2AccountAction({
connector: connectorToLink,
connectParams: { chainId, isAccountLinking: true },
onConnected: async (connector) => ({
result: await super.linkAccountWithConnector(connectorName, chainId, connector),
}),
intent: ACCOUNT_LINKING_INTENT.LINK,
});
}

private pickWalletForAccountLinking(chainId: string): Promise<WALLET_CONNECTOR_TYPE | string> {
if (!this.loginModal) throw WalletInitializationError.notReady("Login modal is not initialized");
if (this.accountLinkingPickerResolver) {
throw AccountLinkingError.requestFailed("Another account linking picker is already in progress.");
}

this.loginModal.startAccountLinkingPicker({ chainId });

return new Promise<WALLET_CONNECTOR_TYPE | string>((resolve, reject) => {
const cleanup = () => {
this.accountLinkingPickerResolver = null;
if (this.removeAccountLinkingPickerCloseListener) {
this.removeAccountLinkingPickerCloseListener();
this.removeAccountLinkingPickerCloseListener = null;
}
this.loginModal.endAccountLinkingPicker();
};

const handleVisibility = (visible: boolean) => {
if (visible) return;
cleanup();
reject(WalletLoginError.popupClosed("User closed the modal"));
};

this.accountLinkingPickerResolver = (connector) => {
cleanup();
resolve(connector);
};
this.on(LOGIN_MODAL_EVENTS.MODAL_VISIBILITY, handleVisibility);
this.removeAccountLinkingPickerCloseListener = () => {
this.removeListener(LOGIN_MODAL_EVENTS.MODAL_VISIBILITY, handleVisibility);
};
});
}

private async runNonWalletConnectAccountAction<T>(
connectorName: WALLET_CONNECTOR_TYPE | string,
fn: () => Promise<T>,
options: { connector?: IConnector<unknown> } = {}
): Promise<T> {
if (!this.loginModal) throw WalletInitializationError.notReady("Login modal is not initialized");
const displayName = CONNECTOR_NAMES[connectorName as WALLET_CONNECTOR_TYPE] || connectorName;
this.loginModal.startConnectingLoader({ connector: connectorName, connectorName: displayName });

// Forward the AUTHORIZING event from the linking wallet connector to the modal so we can
// swap the loader from "connecting" to "authorizing" while the user reviews the signature
// request inside their wallet.
const handleAuthorizing = () => this.loginModal?.markLoaderAuthorizing();
if (options.connector) {
options.connector.on(CONNECTOR_EVENTS.AUTHORIZING, handleAuthorizing);
}

try {
const result = await fn();
this.loginModal.endConnectingLoader({ success: true });
return result;
} catch (error) {
let message = (error as Error)?.message;
if (error instanceof AccountLinkingError) {
const isUnlink = error.code >= 5406 && error.code <= 5408;
message = isUnlink ? `[${error.code}] Account unlinking failed` : `[${error.code}] Account linking failed`;
}
this.loginModal.endConnectingLoader({ success: false, errorMessage: message });
throw error;
} finally {
if (options.connector) {
options.connector.removeListener(CONNECTOR_EVENTS.AUTHORIZING, handleAuthorizing);
}
}
}

private subscribeToAccountLinkingConnectorEvents(connector: IConnector<unknown>): () => void {
const handleConnecting = () => {
this.updateAccountLinkingModalSession({
Expand Down
6 changes: 3 additions & 3 deletions packages/modal/src/ui/components/Loader/Loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function ConnectingStatus(props: ConnectingStatusType) {
);

return (
<div className="wta:flex wta:h-full wta:flex-1 wta:flex-col wta:items-center wta:justify-center wta:gap-y-4">
<div className="wta:flex wta:h-full wta:flex-1 wta:flex-col wta:items-center wta:justify-center wta:gap-y-4 wta:pt-6">
<SpinnerLoader width={95} height={95}>
{providerIcon}
</SpinnerLoader>
Expand All @@ -47,7 +47,7 @@ function ConnectingStatus(props: ConnectingStatusType) {
function ConnectedStatus(props: ConnectedStatusType) {
const { message } = props;
return (
<div className="wta:flex wta:flex-col wta:items-center wta:gap-y-2">
<div className="wta:flex wta:flex-col wta:items-center wta:gap-y-2 wta:pt-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" className="w3a--connected-logo">
<path
fill="currentColor"
Expand All @@ -69,7 +69,7 @@ function ConnectedStatus(props: ConnectedStatusType) {
function ErroredStatus(props: ErroredStatusType) {
const { message } = props;
return (
<div className="wta:flex wta:flex-col wta:items-center wta:gap-y-2">
<div className="wta:flex wta:flex-col wta:items-center wta:gap-y-2 wta:pt-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" className="w3a--error-logo">
<path
fill="currentColor"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,13 @@ function AccountLinking(props: AccountLinkingProps) {
]);

return (
<div className="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">{accountLinkingDisplayName}</p>
<div className="wta:flex wta:flex-1 wta:flex-col wta:gap-y-4">
<div className="wta:flex wta:items-center wta:justify-center">
<p className="wta:text-base wta:font-medium wta:text-app-gray-900 wta:dark:text-app-white">{accountLinkingDisplayName}</p>
</div>
{modalState.accountLinking.status === ACCOUNT_LINKING_STATUS.ERRORED ? (
<div className="w3a--rounded-2xl w3a--border w3a--border-app-gray-200 w3a--bg-app-gray-50 w3a--p-4 dark:w3a--border-app-gray-700 dark:w3a--bg-app-gray-800">
<p className="w3a--text-center w3a--text-sm w3a--text-app-gray-700 dark:w3a--text-app-gray-200">{accountLinkingMessage}</p>
<div className="wta:rounded-2xl wta:border wta:border-app-gray-200 wta:bg-app-gray-50 wta:p-4 wta:dark:border-app-gray-700 wta:dark:bg-app-gray-800">
<p className="wta:text-center wta:text-sm wta:text-app-gray-700 wta:dark:text-app-gray-200">{accountLinkingMessage}</p>
</div>
) : (
<ConnectWalletQrCode
Expand All @@ -104,7 +104,7 @@ function AccountLinking(props: AccountLinkingProps) {
/>
)}
{accountLinkingMessage && modalState.accountLinking.status !== ACCOUNT_LINKING_STATUS.ERRORED && (
<p className="w3a--text-center w3a--text-sm w3a--text-app-gray-500 dark:w3a--text-app-gray-300">{accountLinkingMessage}</p>
<p className="wta:text-center wta:text-sm wta:text-app-gray-500 wta:dark:text-app-gray-300">{accountLinkingMessage}</p>
)}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -292,6 +300,7 @@ function ConnectWallet(props: ConnectWalletProps) {
onBackClick={handleBack}
currentPage={currentPage}
selectedButton={selectedButton}
isLinking={modalState.accountLinking.pickerActive}
/>
{/* Body */}
{selectedWallet ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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}
</p>
<div className="wta:z-[-1] wta:size-5" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export interface ConnectWalletHeaderProps {
currentPage: string;
selectedButton: ExternalButton;
hideBackButton?: boolean;
isLinking?: boolean;
}
Loading
Loading