diff --git a/__tests__/components/auth/SeizeConnectContext.addAccount.test.tsx b/__tests__/components/auth/SeizeConnectContext.addAccount.test.tsx new file mode 100644 index 0000000000..63b986f3f2 --- /dev/null +++ b/__tests__/components/auth/SeizeConnectContext.addAccount.test.tsx @@ -0,0 +1,255 @@ +import React from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { + SeizeConnectProvider, + useSeizeConnectContext, +} from "@/components/auth/SeizeConnectContext"; +import { APP_WALLET_CONNECTOR_TYPE } from "@/wagmiConfig/wagmiAppWalletConnector"; + +const ACTIVE_ADDRESS = "0x00000000000000000000000000000000000000AA"; + +const mockOpen = jest.fn(); +const mockDisconnect = jest.fn(); +const mockLogError = jest.fn(); +const mockLogSecurityEvent = jest.fn(); + +let mockAppKitAccount: { + address?: string; + isConnected: boolean; + status: "connected" | "connecting" | "reconnecting" | "disconnected"; +}; + +let mockWagmiAccount: { + connector?: { + type?: string; + }; +}; + +let mockAppKitState: { open: boolean }; + +jest.mock("@reown/appkit/react", () => ({ + useAppKit: () => ({ + open: mockOpen, + }), + useAppKitAccount: () => mockAppKitAccount, + useAppKitState: () => mockAppKitState, + useDisconnect: () => ({ + disconnect: mockDisconnect, + }), + useWalletInfo: () => ({ + walletInfo: undefined, + }), +})); + +jest.mock("wagmi", () => ({ + useAccount: () => mockWagmiAccount, +})); + +jest.mock("@/config/env", () => ({ + getNodeEnv: () => "test", + publicEnv: { + USE_DEV_AUTH: "false", + DEV_MODE_WALLET_ADDRESS: undefined, + }, +})); + +jest.mock("@/hooks/useConnectedAccountsUnreadNotifications", () => ({ + useConnectedAccountsUnreadNotifications: () => ({}), +})); + +jest.mock("@/services/auth/auth.utils", () => ({ + WALLET_ACCOUNTS_UPDATED_EVENT: "6529-wallet-accounts-updated", + canStoreAnotherWalletAccount: jest.fn(() => true), + getConnectedWalletAccounts: jest.fn(() => [ + { + address: ACTIVE_ADDRESS, + refreshToken: "refresh-token", + role: null, + jwt: null, + profileId: null, + profileHandle: null, + }, + ]), + getWalletAddress: jest.fn(() => ACTIVE_ADDRESS), + removeAuthJwt: jest.fn(), + setActiveWalletAccount: jest.fn(() => true), +})); + +jest.mock("@/src/utils/security-logger", () => ({ + createConnectionEventContext: jest.fn(() => ({})), + createValidationEventContext: jest.fn(() => ({})), + logError: (...args: unknown[]) => mockLogError(...args), + logSecurityEvent: (...args: unknown[]) => mockLogSecurityEvent(...args), +})); + +jest.mock("@/utils/wallet-detection", () => ({ + isSafeWalletInfo: () => false, +})); + +jest.mock("@/components/auth/error-boundary", () => ({ + WalletErrorBoundary: ({ + children, + }: { + readonly children: React.ReactNode; + }) => <>{children}, +})); + +function AddAccountButton() { + const { seizeAddConnectedAccount } = useSeizeConnectContext(); + + return ; +} + +describe("SeizeConnectProvider add-account flow", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + mockAppKitAccount = { + address: ACTIVE_ADDRESS, + isConnected: true, + status: "connected", + }; + mockWagmiAccount = { + connector: { + type: "injected", + }, + }; + mockAppKitState = { open: false }; + mockDisconnect.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it("opens the connect flow directly for app-wallet connectors and keeps add-account mode active", () => { + mockWagmiAccount = { + connector: { + type: APP_WALLET_CONNECTOR_TYPE, + }, + }; + mockAppKitState = { open: true }; + + render( + + + + ); + + const addButton = screen.getByRole("button", { name: "Add account" }); + + act(() => { + fireEvent.click(addButton); + }); + + expect(mockDisconnect).not.toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalledTimes(1); + expect(mockOpen).toHaveBeenLastCalledWith({ view: "Connect" }); + + act(() => { + fireEvent.click(addButton); + }); + + expect(mockDisconnect).not.toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalledTimes(1); + }); + + it("clears a stale add-flow guard before reopening connect for app-wallet connectors", () => { + mockDisconnect.mockImplementation(() => new Promise(() => undefined)); + + const { rerender } = render( + + + + ); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: "Add account" })); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(mockOpen).not.toHaveBeenCalled(); + + mockWagmiAccount = { + connector: { + type: APP_WALLET_CONNECTOR_TYPE, + }, + }; + mockAppKitAccount = { + address: undefined, + isConnected: false, + status: "disconnected", + }; + mockAppKitState = { open: false }; + + rerender( + + + + ); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: "Add account" })); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(mockOpen).toHaveBeenCalledTimes(1); + expect(mockOpen).toHaveBeenLastCalledWith({ view: "Connect" }); + }); + + it("keeps the disconnect-then-connect flow for browser-wallet connectors", async () => { + render( + + + + ); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: "Add account" })); + }); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(mockOpen).not.toHaveBeenCalled(); + + await act(async () => { + await Promise.resolve(); + }); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(mockOpen).toHaveBeenCalledTimes(1); + expect(mockOpen).toHaveBeenLastCalledWith({ view: "Connect" }); + }); + + it("opens connect directly when there is no live connected wallet and keeps add-account mode active", () => { + mockAppKitAccount = { + address: undefined, + isConnected: false, + status: "disconnected", + }; + mockAppKitState = { open: true }; + + render( + + + + ); + + const addButton = screen.getByRole("button", { name: "Add account" }); + + act(() => { + fireEvent.click(addButton); + }); + + act(() => { + fireEvent.click(addButton); + }); + + expect(mockDisconnect).not.toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/auth/SeizeConnectContext.tsx b/components/auth/SeizeConnectContext.tsx index 6fb45c0f34..86bbb64915 100644 --- a/components/auth/SeizeConnectContext.tsx +++ b/components/auth/SeizeConnectContext.tsx @@ -17,6 +17,7 @@ import React, { useState, } from "react"; import { getAddress, isAddress } from "viem"; +import { useAccount } from "wagmi"; import { getNodeEnv, publicEnv } from "@/config/env"; import { MAX_CONNECTED_PROFILES } from "@/constants/constants"; import { @@ -38,6 +39,7 @@ import { logSecurityEvent, } from "@/src/utils/security-logger"; import { isSafeWalletInfo } from "@/utils/wallet-detection"; +import { APP_WALLET_CONNECTOR_TYPE } from "@/wagmiConfig/wagmiAppWalletConnector"; import { WalletErrorBoundary } from "./error-boundary"; // Custom error types for better error handling @@ -389,6 +391,7 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const account = useAppKitAccount(); + const wagmiAccount = useAccount(); const { walletInfo } = useWalletInfo(); const { disconnect } = useDisconnect(); const { open } = useAppKit(); @@ -982,15 +985,11 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ return storedConnectedAccounts.length < MAX_CONNECTED_PROFILES; }, [storedConnectedAccounts]); - const seizeAddConnectedAccount = useCallback((): void => { - if (!canAddConnectedAccount || !canStoreAnotherWalletAccount()) { - return; - } - - if (isAddingConnectedAccountRef.current) { - return; - } + const activeConnectorType = wagmiAccount.connector?.type; + const isActiveAppWalletConnector = + activeConnectorType === APP_WALLET_CONNECTOR_TYPE; + const seizeAddConnectedAccount = useCallback((): void => { const clearAddConnectedAccountGuard = (): void => { isAddingConnectedAccountRef.current = false; addFlowOriginAddressRef.current = null; @@ -1000,86 +999,122 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ } }; - isAddingConnectedAccountRef.current = true; + if (!canAddConnectedAccount || !canStoreAnotherWalletAccount()) { + return; + } const liveConnectedWallet = account.address && account.isConnected && isAddress(account.address) ? getAddress(account.address) : null; + const addFlowOriginAddress = addFlowOriginAddressRef.current; + const addFlowReturnedToOrigin = + !state.open && + !!liveConnectedWallet && + !!addFlowOriginAddress && + normalizeAddress(liveConnectedWallet) === + normalizeAddress(addFlowOriginAddress); + const hasStaleAddConnectedAccountGuard = + isAddingConnectedAccountRef.current && + (!isAddingConnectedAccount || + addFlowReturnedToOrigin || + (!state.open && + !retryConnectTimeoutRef.current && + !liveConnectedWallet && + account.status !== "connecting" && + account.status !== "reconnecting")); + + if (hasStaleAddConnectedAccountGuard) { + clearAddConnectedAccountGuard(); + setIsAddingConnectedAccount(false); + } - addFlowOriginAddressRef.current = liveConnectedWallet; - setIsAddingConnectedAccount(true); + if (isAddingConnectedAccountRef.current) { + return; + } - if (liveConnectedWallet) { - if (retryConnectTimeoutRef.current) { - clearTimeout(retryConnectTimeoutRef.current); - retryConnectTimeoutRef.current = null; - } + if (!liveConnectedWallet || isActiveAppWalletConnector) { + isAddingConnectedAccountRef.current = true; + addFlowOriginAddressRef.current = liveConnectedWallet; + setIsAddingConnectedAccount(true); try { - disconnect() - .then(() => { - retryConnectTimeoutRef.current = setTimeout(() => { - retryConnectTimeoutRef.current = null; - if (!isMountedRef.current) { - clearAddConnectedAccountGuard(); - return; - } - try { - seizeConnect(); - } catch (error: unknown) { - clearAddConnectedAccountGuard(); - setIsAddingConnectedAccount(false); - const connectionError = createWalletError( - WalletConnectionError, - "start add-account connection flow", - error - ); - logError("seizeAddConnectedAccount", connectionError); - } - }, 100); - }) - .catch((error: unknown) => { - clearAddConnectedAccountGuard(); - setIsAddingConnectedAccount(false); - const walletError = createWalletError( - WalletDisconnectionError, - "disconnect wallet before adding account", - error - ); - logError("seizeAddConnectedAccount", walletError); - }); + seizeConnect(); } catch (error: unknown) { clearAddConnectedAccountGuard(); setIsAddingConnectedAccount(false); - const walletError = createWalletError( - WalletDisconnectionError, - "disconnect wallet before adding account", + const connectionError = createWalletError( + WalletConnectionError, + "start add-account connection flow", error ); - logError("seizeAddConnectedAccount", walletError); + logError("seizeAddConnectedAccount", connectionError); } return; } + isAddingConnectedAccountRef.current = true; + addFlowOriginAddressRef.current = liveConnectedWallet; + setIsAddingConnectedAccount(true); + + if (retryConnectTimeoutRef.current) { + clearTimeout(retryConnectTimeoutRef.current); + retryConnectTimeoutRef.current = null; + } + try { - seizeConnect(); + disconnect() + .then(() => { + retryConnectTimeoutRef.current = setTimeout(() => { + retryConnectTimeoutRef.current = null; + if (!isMountedRef.current) { + clearAddConnectedAccountGuard(); + return; + } + try { + seizeConnect(); + } catch (error: unknown) { + clearAddConnectedAccountGuard(); + setIsAddingConnectedAccount(false); + const connectionError = createWalletError( + WalletConnectionError, + "start add-account connection flow", + error + ); + logError("seizeAddConnectedAccount", connectionError); + } + }, 100); + }) + .catch((error: unknown) => { + clearAddConnectedAccountGuard(); + setIsAddingConnectedAccount(false); + const walletError = createWalletError( + WalletDisconnectionError, + "disconnect wallet before adding account", + error + ); + logError("seizeAddConnectedAccount", walletError); + }); } catch (error: unknown) { clearAddConnectedAccountGuard(); setIsAddingConnectedAccount(false); - const connectionError = createWalletError( - WalletConnectionError, - "start add-account connection flow", + const walletError = createWalletError( + WalletDisconnectionError, + "disconnect wallet before adding account", error ); - logError("seizeAddConnectedAccount", connectionError); + logError("seizeAddConnectedAccount", walletError); } }, [ account.address, account.isConnected, + account.status, canAddConnectedAccount, disconnect, + isActiveAppWalletConnector, + isAddingConnectedAccount, seizeConnect, + state.open, ]); const connectedAccounts = useMemo(() => {