From 92adf585dfdeb3b2ea2689ba7c5e27948e52ea7d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 13:00:51 -0600 Subject: [PATCH 01/10] feat(mosaic): wire up user profile Web3 wallets --- .changeset/web3-wallets-wire-up.md | 2 + packages/mosaic/package.json | 3 +- .../user-profile-profile-panel.view.tsx | 20 ++- .../user-profile-web3-wallet-row.view.tsx | 14 +- ...user-profile-web3-wallets-section.view.tsx | 6 + .../user-profile-solana-wallet.dialog.tsx | 122 ++++++++++++++ ...e-web3-wallets-section.controller.test.tsx | 74 +++++++++ ...profile-web3-wallets-section.controller.ts | 83 +++++++++ ...profile-web3-wallets-section.model.test.ts | 101 +++++++++++ ...user-profile-web3-wallets-section.model.ts | 85 ++++++++++ .../user-profile-web3-wallets-section.tsx | 157 ++++++++++++++++++ .../user-profile-web3-wallets.messages.ts | 7 + .../swingset/src/app/(clerk)/live-sidebar.tsx | 1 + .../app/(clerk)/live/web3-wallets/page.tsx | 36 ++++ pnpm-lock.yaml | 3 + 15 files changed, 701 insertions(+), 13 deletions(-) create mode 100644 .changeset/web3-wallets-wire-up.md create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.test.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx create mode 100644 packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/web3-wallets-wire-up.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index eb089c4f40e..7b91b795d97 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -64,7 +64,8 @@ }, "dependencies": { "@clerk/shared": "workspace:^", - "@floating-ui/react": "catalog:repo" + "@floating-ui/react": "catalog:repo", + "@wallet-standard/core": "catalog:module-manager" }, "devDependencies": { "@clerk/clerk-js": "workspace:^", diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 1c315c85ae6..ee27358f829 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -35,6 +35,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect onRemoveConnectedAccount?: (id: string) => void | Promise; /** Connected accounts section. Replaces the one built from `connectedAccounts` and its callbacks. */ connectedAccountsSlot?: ReactNode; + web3WalletsSlot?: ReactNode; onConnectWeb3Wallet?: (id: string) => void; onSetPrimaryWeb3Wallet?: (id: string) => void; onRemoveWeb3Wallet?: (id: string) => void | Promise; @@ -80,6 +81,7 @@ export function UserProfileProfilePanelView({ onConnectAccount, onRemoveConnectedAccount, connectedAccountsSlot, + web3WalletsSlot, onConnectWeb3Wallet, onSetPrimaryWeb3Wallet, onRemoveWeb3Wallet, @@ -136,14 +138,16 @@ export function UserProfileProfilePanelView({ onRemove={onRemoveConnectedAccount} /> )} - pageTitle.current} - wallets={web3Wallets} - availableProviders={availableWeb3Providers} - onConnect={onConnectWeb3Wallet} - onRemove={onRemoveWeb3Wallet} - onSetPrimary={onSetPrimaryWeb3Wallet} - /> + {web3WalletsSlot ?? ( + pageTitle.current} + wallets={web3Wallets} + availableProviders={availableWeb3Providers} + onConnect={onConnectWeb3Wallet} + onRemove={onRemoveWeb3Wallet} + onSetPrimary={onSetPrimaryWeb3Wallet} + /> + )} {onDeleteAccount ? : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx index e0322278d0d..a9ee607daf9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx @@ -4,7 +4,7 @@ import type { Ref } from 'react'; import type { ActionMenuAction } from '../../components/action-menu'; import { ActionMenu } from '../../components/action-menu'; import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; +import { SubmitButton } from '../../components/button'; import { Icon, IconFrame } from '../../components/icon'; import { Section } from '../../components/section'; import { fill, useMessages } from '../../localization'; @@ -14,12 +14,16 @@ import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-prof export function UserProfileWeb3WalletRowView({ wallet, triggerRef, + isPending = false, + isDisabled = false, onConnect, onSetPrimary, onRemove, }: { wallet: UserProfileWeb3Wallet | UserProfileWeb3Provider; triggerRef?: Ref; + isPending?: boolean; + isDisabled?: boolean; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (wallet: UserProfileWeb3Wallet) => void; @@ -31,7 +35,7 @@ export function UserProfileWeb3WalletRowView({ const shortAddress = address && (address.length <= 10 ? address : `${address.slice(0, 6)}...${address.slice(-4)}`); const actions: ActionMenuAction[] = []; - if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary) { + if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary && !isDisabled) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) }); } if (linkedWallet && onRemove && linkedWallet.canRemove !== false) { @@ -79,10 +83,12 @@ export function UserProfileWeb3WalletRowView({ {onConnect ? ( - + ) : actions.length > 0 ? ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx index f4cd5ad851b..26222b9ac73 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -29,6 +29,7 @@ export interface UserProfileWeb3WalletsSectionViewProps { fallbackFocus?: () => HTMLElement | null; wallets: UserProfileWeb3Wallet[]; availableProviders?: UserProfileWeb3Provider[]; + pendingId?: string; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void | Promise; @@ -38,6 +39,7 @@ export function UserProfileWeb3WalletsSectionView({ wallets, fallbackFocus, availableProviders = [], + pendingId, onConnect, onSetPrimary, onRemove, @@ -55,6 +57,7 @@ export function UserProfileWeb3WalletsSectionView({ }); const removeWallet = useMemo(() => Confirmation.createHandle(), []); const hasRows = wallets.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); + const isBusy = pendingId !== undefined; return ( <> @@ -70,6 +73,7 @@ export function UserProfileWeb3WalletsSectionView({ key={wallet.id} wallet={wallet} triggerRef={removalFocus.registerTrigger(wallet.id)} + isDisabled={isBusy} onSetPrimary={onSetPrimary} onRemove={onRemove ? wallet => removeWallet.open(wallet) : undefined} /> @@ -79,6 +83,8 @@ export function UserProfileWeb3WalletsSectionView({ )) diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx new file mode 100644 index 00000000000..0ace24d0634 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx @@ -0,0 +1,122 @@ +import { getWallets } from '@wallet-standard/core'; +import { useEffect, useState } from 'react'; + +import { Banner } from '../../../components/banner'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Dialog } from '../../../components/dialog'; +import { useMessages } from '../../../localization'; + +function installedSolanaWallets() { + return getWallets() + .get() + .filter( + wallet => wallet.chains.some(chain => chain.startsWith('solana:')) && 'solana:signMessage' in wallet.features, + ); +} + +export function UserProfileSolanaWalletDialog({ + open, + pending, + error, + onOpenChange, + onConnect, +}: { + open: boolean; + pending: boolean; + error?: string; + onOpenChange: (open: boolean) => void; + onConnect: (walletName: string) => void; +}) { + const m = useMessages('userProfileWeb3Wallets'); + const [wallets, setWallets] = useState(installedSolanaWallets); + + useEffect(() => { + const registry = getWallets(); + const update = () => setWallets(installedSolanaWallets()); + const unregister = registry.on('register', update); + const unadvertise = registry.on('unregister', update); + update(); + return () => { + unregister(); + unadvertise(); + }; + }, []); + + return ( + + + + + {m.solanaDialog.title} + {m.solanaDialog.description} + + + {error ? ( + + {error} + + ) : null} + {wallets.length === 0 ? ( +

+ {m.solanaDialog.noneAvailable}{' '} + + {m.solanaDialog.findWallet} + +

+ ) : null} + {wallets.map(wallet => ( + onConnect(wallet.name)} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + ))} +
+ + + {m.solanaDialog.cancel} + + } + /> + +
+
+
+ ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx new file mode 100644 index 00000000000..9566865b9aa --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx @@ -0,0 +1,74 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; + +const wallets = [{ id: 'wallet_1', address: '0x1234567890abcdef', isVerified: true }]; +const availableProviders = [{ id: 'web3_metamask_signature', provider: 'MetaMask' }]; + +describe('Web3 wallet controller', () => { + it('WEB3-06 holds a pending connection and blocks another attempt', async () => { + const pending = createDeferredPromise(); + const connect = vi.fn(() => pending.promise); + const { result } = renderHook(() => + useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }), + ); + + act(() => { + void result.current.onConnect('web3_metamask_signature'); + void result.current.onConnect('web3_metamask_signature'); + void result.current.onConnect('web3_solana_signature'); + }); + expect(result.current.pendingId).toBe('web3_metamask_signature'); + expect(connect).toHaveBeenCalledOnce(); + expect(result.current.solanaPickerOpen).toBe(false); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + await waitFor(() => expect(result.current.pendingId).toBeUndefined()); + }); + + it('WEB3-07 surfaces connection errors on the provider and allows retry', async () => { + const connect = vi + .fn() + .mockRejectedValueOnce(new Error('Wallet extension missing')) + .mockResolvedValueOnce(undefined); + const { result } = renderHook(() => + useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }), + ); + + await act(async () => result.current.onConnect('web3_metamask_signature')); + expect(result.current.availableProviders[0].connectError).toBe('Wallet extension missing'); + await act(async () => result.current.onConnect('web3_metamask_signature')); + expect(result.current.availableProviders[0].connectError).toBeUndefined(); + }); + + it('WEB3-08 surfaces primary selection errors on the wallet', async () => { + const { result } = renderHook(() => + useUserProfileWeb3WalletsController({ + wallets, + availableProviders, + connect: vi.fn(), + setPrimary: vi.fn().mockRejectedValue(new Error('Reverification canceled')), + }), + ); + + await act(async () => result.current.onSetPrimary('wallet_1')); + expect(result.current.wallets[0].primaryError).toBe('Reverification canceled'); + }); + + it('WEB3-09 opens the Solana picker before connecting', () => { + const connect = vi.fn(); + const { result } = renderHook(() => + useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }), + ); + act(() => void result.current.onConnect('web3_solana_signature')); + expect(result.current.solanaPickerOpen).toBe(true); + expect(connect).not.toHaveBeenCalled(); + act(() => result.current.closeSolanaPicker()); + expect(result.current.solanaPickerOpen).toBe(false); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts new file mode 100644 index 00000000000..9e0ba769130 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts @@ -0,0 +1,83 @@ +import { useRef, useState } from 'react'; + +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; + +export function useUserProfileWeb3WalletsController({ + wallets, + availableProviders, + connect, + setPrimary, +}: { + wallets: UserProfileWeb3Wallet[]; + availableProviders: UserProfileWeb3Provider[]; + connect: (strategy: string, walletName?: string) => Promise; + setPrimary: (walletId: string) => Promise; +}) { + const [pendingId, setPendingId] = useState(); + const [connectErrors, setConnectErrors] = useState>({}); + const [primaryErrors, setPrimaryErrors] = useState>({}); + const [solanaPickerOpen, setSolanaPickerOpen] = useState(false); + const inFlight = useRef(false); + + const run = async ( + id: string, + action: () => Promise, + setErrors: typeof setConnectErrors, + ): Promise => { + if (inFlight.current) { + return false; + } + inFlight.current = true; + setPendingId(id); + setErrors(({ [id]: _previous, ...rest }) => rest); + try { + await action(); + return true; + } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'; + setErrors(current => ({ ...current, [id]: message })); + return false; + } finally { + inFlight.current = false; + setPendingId(undefined); + } + }; + + const onConnect = (strategy: string) => { + if (inFlight.current) { + return; + } + if (strategy === 'web3_solana_signature') { + setSolanaPickerOpen(true); + return; + } + return run(strategy, () => connect(strategy), setConnectErrors); + }; + + const connectSolana = async (walletName: string) => { + const succeeded = await run( + 'web3_solana_signature', + () => connect('web3_solana_signature', walletName), + setConnectErrors, + ); + if (succeeded) { + setSolanaPickerOpen(false); + } + }; + + return { + wallets: wallets.map(wallet => + primaryErrors[wallet.id] ? { ...wallet, primaryError: primaryErrors[wallet.id] } : wallet, + ), + availableProviders: availableProviders.map(provider => + connectErrors[provider.id] ? { ...provider, connectError: connectErrors[provider.id] } : provider, + ), + pendingId, + solanaPickerOpen, + onConnect, + onSetPrimary: (walletId: string) => run(walletId, () => setPrimary(walletId), setPrimaryErrors), + connectSolana, + closeSolanaPicker: () => setSolanaPickerOpen(false), + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.test.ts new file mode 100644 index 00000000000..45f69dcc766 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; + +const metamask = { + id: 'wallet_metamask', + address: '0x1234567890abcdef', + strategy: 'web3_metamask_signature', + status: 'verified', +}; + +describe('Web3 wallet projection', () => { + it('WEB3-01 shows configured providers that do not have a verified wallet', () => { + expect( + projectWeb3Wallets({ + wallets: [metamask], + primaryId: null, + enabledStrategies: ['web3_metamask_signature', 'web3_coinbase_wallet_signature'], + allowCreation: true, + }), + ).toMatchObject({ + status: 'ready', + availableProviders: [{ id: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet' }], + }); + }); + + it('WEB3-02 keeps unverified wallets and their provider available', () => { + const result = projectWeb3Wallets({ + wallets: [{ ...metamask, status: 'unverified' }], + primaryId: null, + enabledStrategies: ['web3_metamask_signature'], + allowCreation: true, + }); + expect(result).toMatchObject({ + wallets: [{ id: 'wallet_metamask', provider: 'MetaMask', isVerified: false }], + availableProviders: [{ id: 'web3_metamask_signature' }], + }); + }); + + it('WEB3-03 shows admin wallets without a provider and filters unknown strategies', () => { + const result = projectWeb3Wallets({ + wallets: [ + { id: 'admin', address: '0xadmin', strategy: 'admin', status: 'verified' }, + { id: 'unknown', address: '0xunknown', strategy: 'unknown', status: 'verified' }, + ], + primaryId: null, + enabledStrategies: [], + allowCreation: false, + }); + expect(result).toMatchObject({ status: 'ready', wallets: [{ id: 'admin', address: '0xadmin' }] }); + if (result.status === 'ready') { + expect(result.wallets[0].provider).toBeUndefined(); + } + }); + + it('WEB3-04 orders primary, verified, then unverified wallets', () => { + const result = projectWeb3Wallets({ + wallets: [ + { ...metamask, id: 'unverified', status: 'unverified', expireAt: new Date('2026-01-02') }, + { ...metamask, id: 'verified_z' }, + { ...metamask, id: 'verified_a' }, + { ...metamask, id: 'primary' }, + ], + primaryId: 'primary', + enabledStrategies: ['web3_metamask_signature'], + allowCreation: false, + }); + expect(result.status).toBe('ready'); + if (result.status === 'ready') { + expect(result.wallets.map(wallet => wallet.id)).toEqual(['primary', 'verified_a', 'verified_z', 'unverified']); + expect(result.availableProviders).toEqual([]); + } + }); + + it('WEB3-04 keeps wallets without a verification status after expiring unverified wallets', () => { + const result = projectWeb3Wallets({ + wallets: [ + { ...metamask, id: 'missing', status: null }, + { ...metamask, id: 'unverified_without_expiry', status: 'unverified' }, + { ...metamask, id: 'unverified_with_expiry', status: 'unverified', expireAt: new Date('2026-01-02') }, + ], + primaryId: null, + enabledStrategies: [], + allowCreation: false, + }); + expect(result.status).toBe('ready'); + if (result.status === 'ready') { + expect(result.wallets.map(wallet => wallet.id)).toEqual([ + 'unverified_without_expiry', + 'unverified_with_expiry', + 'missing', + ]); + } + }); + + it('WEB3-05 hides the section without creation or existing wallets', () => { + expect(projectWeb3Wallets({ wallets: [], primaryId: null, enabledStrategies: [], allowCreation: false })).toEqual({ + status: 'hidden', + }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts new file mode 100644 index 00000000000..857c08540d0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts @@ -0,0 +1,85 @@ +import { iconImageUrl } from '@clerk/shared/constants'; +import { WEB3_PROVIDERS } from '@clerk/shared/web3'; + +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; + +export interface Web3WalletEntry { + id: string; + address: string; + strategy: string; + status: string | null; + expireAt?: Date | null; +} + +export type Web3WalletsProjection = + | { status: 'hidden' } + | { status: 'ready'; wallets: UserProfileWeb3Wallet[]; availableProviders: UserProfileWeb3Provider[] }; + +export function projectWeb3Wallets({ + wallets, + primaryId, + enabledStrategies, + allowCreation, +}: { + wallets: readonly Web3WalletEntry[]; + primaryId: string | null; + enabledStrategies: readonly string[]; + allowCreation: boolean; +}): Web3WalletsProjection { + if (!allowCreation && wallets.length === 0) { + return { status: 'hidden' }; + } + + const providers = WEB3_PROVIDERS.filter(provider => enabledStrategies.includes(provider.strategy)); + const knownWallets = wallets.filter( + wallet => wallet.strategy === 'admin' || WEB3_PROVIDERS.some(provider => provider.strategy === wallet.strategy), + ); + const sorted = [...knownWallets].sort((first, second) => { + if (first.id === primaryId) { + return -1; + } + if (second.id === primaryId) { + return 1; + } + const firstRank = first.status === 'verified' ? 0 : first.status ? 1 : 2; + const secondRank = second.status === 'verified' ? 0 : second.status ? 1 : 2; + if (firstRank !== secondRank) { + return firstRank - secondRank; + } + if (firstRank === 0) { + return first.id.localeCompare(second.id); + } + if (firstRank === 1 && first.expireAt && second.expireAt) { + return first.expireAt.getTime() - second.expireAt.getTime(); + } + return 0; + }); + + const connectedStrategies = new Set( + wallets.filter(wallet => wallet.status === 'verified').map(wallet => wallet.strategy), + ); + + return { + status: 'ready', + wallets: sorted.map(wallet => { + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === wallet.strategy); + return { + id: wallet.id, + address: wallet.address, + provider: provider?.name, + iconUrl: provider ? iconImageUrl(provider.provider) : undefined, + isPrimary: wallet.id === primaryId, + isVerified: wallet.status === 'verified', + }; + }), + availableProviders: allowCreation + ? providers + .filter(provider => !connectedStrategies.has(provider.strategy)) + .map(provider => ({ + id: provider.strategy, + provider: provider.name, + iconUrl: iconImageUrl(provider.provider), + })) + : [], + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx new file mode 100644 index 00000000000..47328911f63 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -0,0 +1,157 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; +import { useClerk, useUser } from '@clerk/shared/react'; +import { WEB3_PROVIDERS } from '@clerk/shared/web3'; +import type { ReactNode } from 'react'; + +import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import { Reverification, useReverificationFlow } from '../../reverification'; +import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; +import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; +import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; +import { projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; + +export interface UserProfileWeb3WalletsSectionProps { + fallback?: ReactNode; + fallbackFocus?: () => HTMLElement | null; +} + +function errorMessage(error: unknown): string { + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + return first?.longMessage || first?.message || 'Something went wrong. Please try again.'; + } + return error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'; +} + +export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserProfileWeb3WalletsSectionProps) { + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const environment = useMosaicEnvironment(); + const [createWallet, createReverification] = useReverificationFlow((address: string) => + user?.createWeb3Wallet({ web3Wallet: address }), + ); + const [updatePrimary, primaryReverification] = useReverificationFlow((walletId: string) => + user?.update({ primaryWeb3WalletId: walletId }), + ); + + const projection = + user && environment + ? projectWeb3Wallets({ + wallets: user.web3Wallets.map(wallet => ({ + id: wallet.id, + address: wallet.web3Wallet, + strategy: wallet.verification.strategy ?? '', + status: wallet.verification.status, + expireAt: wallet.verification.expireAt, + })), + primaryId: user.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), + }) + : ({ status: 'hidden' } as const); + + const connect = async (strategy: string, walletName?: string) => { + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); + const manager = clerk.__internal_moduleManager; + if (!provider || !manager || !user) { + throw new Error('This wallet provider is unavailable.'); + } + + try { + const web3 = createWeb3(manager); + const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); + if (!identifier) { + throw new Error('A Web3 Wallet extension cannot be found. Please install one to continue.'); + } + const wallet = await createWallet(identifier); + if (!wallet) { + throw new Error('The wallet could not be created.'); + } + const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); + const nonce = prepared.verification.message; + if (!nonce) { + throw new Error('The wallet verification message is unavailable.'); + } + const signature = await web3.generateWeb3Signature({ + identifier, + nonce, + provider: provider.provider, + walletName, + }); + if (!signature) { + throw new Error('The wallet signature is unavailable.'); + } + await prepared.attemptVerification({ signature }); + } catch (error) { + throw new Error(errorMessage(error)); + } + }; + + const setPrimary = async (walletId: string) => { + if (!user?.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { + return; + } + try { + await updatePrimary(walletId); + } catch (error) { + throw new Error(errorMessage(error)); + } + }; + + const remove = async (walletId: string) => { + try { + await user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(); + } catch (error) { + throw new Error(errorMessage(error)); + } + }; + + const controller = useUserProfileWeb3WalletsController({ + wallets: projection.status === 'ready' ? projection.wallets : [], + availableProviders: projection.status === 'ready' ? projection.availableProviders : [], + connect, + setPrimary, + }); + + if (!isLoaded || !environment) { + return fallback ?? null; + } + if (projection.status === 'hidden') { + return null; + } + + return ( + <> + { + void controller.onConnect(id); + }} + onSetPrimary={id => { + void controller.onSetPrimary(id); + }} + onRemove={remove} + /> + provider.id === 'web3_solana_signature')?.connectError} + onOpenChange={open => { + if (!open) { + controller.closeSolanaPicker(); + } + }} + onConnect={walletName => { + void controller.connectSolana(walletName); + }} + /> + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts index abdfb66e137..4c2bbc2339d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts @@ -7,6 +7,13 @@ export const userProfileWeb3WalletsMessages = { setPrimary: 'Set as primary', primary: 'Primary', unverified: 'Unverified', + solanaDialog: { + title: 'Select a Solana wallet', + description: 'Choose an installed wallet to connect to your account.', + noneAvailable: 'No Solana wallets are available.', + findWallet: 'Find a Solana wallet', + cancel: 'Cancel', + }, removeDialog: { title: 'Remove wallet?', description: '{wallet} will be removed from this account.', diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 9341c6cfe71..69aa840738c 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -20,6 +20,7 @@ import { const flows = [ { title: 'Connected accounts', href: '/live/connected-accounts' }, + { title: 'Web3 wallets', href: '/live/web3-wallets' }, { title: 'Reverification', href: '/live/reverification' }, ]; diff --git a/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx new file mode 100644 index 00000000000..9aac58176d0 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { UserProfileWeb3WalletsSection } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function Web3WalletsLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Web3 wallets

+

+ Connect, verify, and manage the signed-in user's wallets. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to manage wallets. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df232371ea4..3dfef8094f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -798,6 +798,9 @@ importers: '@floating-ui/react': specifier: catalog:repo version: 0.27.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wallet-standard/core': + specifier: catalog:module-manager + version: 1.1.1 devDependencies: '@clerk/clerk-js': specifier: workspace:^ From eaa23b4b9963e8ff02ba962262aac6a5347a597d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:14:15 -0600 Subject: [PATCH 02/10] fix(mosaic): present reverification for wallet actions --- ...-web3-wallets-section.integration.test.tsx | 90 ++++++++ ...user-profile-web3-wallets-section.view.tsx | 4 + ...user-profile-solana-wallet.dialog.test.tsx | 116 +++++++++++ .../user-profile-solana-wallet.dialog.tsx | 196 ++++++++++++------ ...e-web3-wallets-section.controller.test.tsx | 39 ++++ ...profile-web3-wallets-section.controller.ts | 4 + .../user-profile-web3-wallets-section.tsx | 49 +++-- .../user-profile-web3-wallets.messages.ts | 1 + 8 files changed, 423 insertions(+), 76 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx new file mode 100644 index 00000000000..58897f9f5c0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx @@ -0,0 +1,90 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type * as SharedReact from '@clerk/shared/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; + +const request = vi.fn(); +vi.mock('@clerk/shared/internal/clerk-js/web3', () => ({ + createWeb3: () => ({ getWeb3Identifier: () => Promise.resolve('0x1234') }), +})); +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ + isLoaded: true, + user: { + id: 'user_1', + enterpriseAccounts: [], + primaryWeb3WalletId: null, + web3Wallets: [ + { + id: 'wallet_1', + web3Wallet: '0xabcdef', + verification: { strategy: 'admin', status: 'verified' }, + destroy: request, + }, + ], + createWeb3Wallet: request, + update: request, + }, + }), + useClerk: () => ({ + __internal_getOption: () => undefined, + __internal_moduleManager: {}, + __internal_environment: { + userSettings: { web3FirstFactors: ['web3_metamask_signature'], enterpriseSSO: { enabled: false } }, + displayConfig: { supportEmail: 'support@example.com' }, + }, + }), + useSession: () => ({ session: { id: 'session_1' } }), + useReverification: (fetcher: F) => fetcher, + }; +}); + +describe('Web3 wallet cancellation', () => { + beforeEach(() => { + request.mockReset().mockRejectedValue(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })); + }); + it.each(['connect', 'primary', 'remove'] as const)( + 'keeps %s cancellation out of the visible errors', + async action => { + const user = userEvent.setup(); + render( + + + , + ); + if (action === 'connect') { + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + } else { + await user.click(screen.getByRole('button', { name: 'Manage 0xabcdef' })); + await user.click( + screen.getByRole('menuitem', { name: action === 'primary' ? 'Set as primary' : 'Remove wallet' }), + ); + if (action === 'remove') { + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + } + } + await waitFor(() => expect(request).toHaveBeenCalledOnce()); + if (action === 'remove') { + await waitFor(() => + expect( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true }), + ).not.toHaveAttribute('aria-busy', 'true'), + ); + } else { + await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).not.toBeDisabled()); + } + expect(screen.queryByText(/Cancelled/)).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + if (action === 'remove') { + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + } + }, + ); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx index 26222b9ac73..0d65f858dc1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -5,6 +5,7 @@ import { Section } from '../../components/section'; import { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; import { fill, useMessages } from '../../localization'; import { truncateWithEndVisible } from '../../utils/truncateTextWithEndVisible'; +import type { ReverificationController } from '../reverification'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; export interface UserProfileWeb3Provider { @@ -30,6 +31,7 @@ export interface UserProfileWeb3WalletsSectionViewProps { wallets: UserProfileWeb3Wallet[]; availableProviders?: UserProfileWeb3Provider[]; pendingId?: string; + removeReverification?: ReverificationController; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void | Promise; @@ -43,6 +45,7 @@ export function UserProfileWeb3WalletsSectionView({ onConnect, onSetPrimary, onRemove, + removeReverification, }: UserProfileWeb3WalletsSectionViewProps) { const m = useMessages('userProfileWeb3Wallets'); const section = useRef(null); @@ -95,6 +98,7 @@ export function UserProfileWeb3WalletsSectionView({ {onRemove ? ( fill(wallet.isVerified ? m.removeDialog.verifiedDescription : m.removeDialog.description, { diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx new file mode 100644 index 00000000000..6e68d3f4b23 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getWallets } from '@wallet-standard/core'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type { ReverificationController } from '../../reverification'; +import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; + +function renderDialog(reverification: ReverificationController, pending = true) { + const onOpenChange = vi.fn(); + const view = render( + + + , + ); + return { ...view, onOpenChange }; +} + +describe('Solana wallet reverification', () => { + it('keeps the picker visible while verification loads', () => { + renderDialog({ status: 'loading', phase: 'active' }); + expect(screen.getByText(/No Solana wallets are available/)).toBeInTheDocument(); + expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + }); + + it.each(['Back', 'Close', 'Escape'])( + 'returns to the picker when verification is cancelled with %s', + async control => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const { onOpenChange } = renderDialog({ status: 'unavailable', phase: 'active', onCancel }); + expect(screen.getByText('Cannot verify your account')).toBeInTheDocument(); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + if (control === 'Escape') { + await user.keyboard('{Escape}'); + } else { + await user.click(screen.getByRole('button', { name: control, exact: true })); + } + expect(onCancel).toHaveBeenCalledOnce(); + expect(onOpenChange).not.toHaveBeenCalled(); + }, + ); + + it('blocks dismissal while the protected operation retries', async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const { onOpenChange } = renderDialog({ status: 'unavailable', phase: 'retrying', onCancel }); + expect(screen.getByRole('button', { name: 'Back', exact: true })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Close', exact: true })); + await user.keyboard('{Escape}'); + expect(onCancel).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('blocks dismissal while waiting for the wallet provider', async () => { + const user = userEvent.setup(); + const { onOpenChange } = renderDialog({ status: 'idle', phase: 'inactive' }); + await user.click(screen.getByRole('button', { name: 'Close', exact: true })); + await user.keyboard('{Escape}'); + expect(onOpenChange).not.toHaveBeenCalled(); + }); +}); + +it('returns focus to the selected wallet after cancelling verification', async () => { + const unregister = getWallets().register({ + version: '1.0.0', + name: 'Test Solana wallet', + icon: 'data:image/svg+xml;base64,', + chains: ['solana:mainnet'], + accounts: [], + features: { 'solana:signMessage': {} }, + }); + const user = userEvent.setup(); + function Harness() { + const [active, setActive] = useState(false); + return ( + setActive(true)} + reverification={ + active + ? { status: 'unavailable', phase: 'active', onCancel: () => setActive(false) } + : { status: 'idle', phase: 'inactive' } + } + /> + ); + } + try { + render( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Test Solana wallet' })); + expect(screen.getByText('Cannot verify your account')).toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Test Solana wallet', hidden: true })).not.toBeInTheDocument(), + ); + await user.click(screen.getByRole('button', { name: 'Back', exact: true })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Test Solana wallet' })).toHaveFocus()); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + } finally { + unregister(); + } +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx index 0ace24d0634..13cb9b07b34 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx @@ -1,11 +1,13 @@ import { getWallets } from '@wallet-standard/core'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import { Dialog } from '../../../components/dialog'; +import { Flow } from '../../../components/flow'; import { useMessages } from '../../../localization'; +import { Reverification, type ReverificationController } from '../../reverification'; function installedSolanaWallets() { return getWallets() @@ -21,7 +23,9 @@ export function UserProfileSolanaWalletDialog({ error, onOpenChange, onConnect, + reverification, }: { + reverification?: ReverificationController; open: boolean; pending: boolean; error?: string; @@ -30,6 +34,21 @@ export function UserProfileSolanaWalletDialog({ }) { const m = useMessages('userProfileWeb3Wallets'); const [wallets, setWallets] = useState(installedSolanaWallets); + const selectedWalletName = useRef(); + const selectedWallet = useRef(null); + const restorePickerFocus = useRef(false); + const verification = + reverification?.status === 'ready' || reverification?.status === 'unavailable' ? reverification : undefined; + const verifying = verification !== undefined; + + useEffect(() => { + if (verifying) { + restorePickerFocus.current = true; + } else if (restorePickerFocus.current && !pending) { + restorePickerFocus.current = false; + selectedWallet.current?.focus({ preventScroll: true }); + } + }, [verifying, pending]); useEffect(() => { const registry = getWallets(); @@ -46,75 +65,124 @@ export function UserProfileSolanaWalletDialog({ return ( { + if (!nextOpen && reverification?.phase === 'active') { + reverification.onCancel?.(); + return; + } + if (!nextOpen && pending) { + return; + } + onOpenChange(nextOpen); + }} > - - {m.solanaDialog.title} - {m.solanaDialog.description} - - - {error ? ( - - {error} - - ) : null} - {wallets.length === 0 ? ( -

- {m.solanaDialog.noneAvailable}{' '} - - {m.solanaDialog.findWallet} - -

- ) : null} - {wallets.map(wallet => ( - onConnect(wallet.name)} - > - {wallet.icon ? ( - - ) : null} - {wallet.name} - - ))} -
- - - {m.solanaDialog.cancel} - - } - /> - + + {() => ( + <> + + + {m.solanaDialog.title} + {m.solanaDialog.description} + + + {error ? ( + + {error} + + ) : null} + {wallets.length === 0 ? ( +

+ {m.solanaDialog.noneAvailable}{' '} + + {m.solanaDialog.findWallet} + +

+ ) : null} + {wallets.map(wallet => ( + { + if (selectedWalletName.current === wallet.name) { + selectedWallet.current = element; + } + }} + type='button' + variant='outline' + color='neutral' + fullWidth + isPending={pending} + disabled={pending} + onClick={event => { + selectedWalletName.current = wallet.name; + selectedWallet.current = event.currentTarget; + onConnect(wallet.name); + }} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + ))} +
+ + + {m.solanaDialog.cancel} + + } + /> + +
+ + {verification ? ( + <> + + + + + + ) : null} + + + )} +
diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx index 9566865b9aa..2c74b8568ba 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; @@ -72,3 +73,41 @@ describe('Web3 wallet controller', () => { expect(result.current.solanaPickerOpen).toBe(false); }); }); + +it.each(['connect', 'primary', 'solana'] as const)( + 'cancels %s verification without an error and allows retry', + async action => { + const cancelled = new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' }); + const request = vi.fn().mockRejectedValueOnce(cancelled).mockResolvedValueOnce(undefined); + const { result } = renderHook(() => + useUserProfileWeb3WalletsController({ + wallets, + availableProviders: [...availableProviders, { id: 'web3_solana_signature', provider: 'Solana' }], + connect: request, + setPrimary: request, + }), + ); + const run = () => + action === 'primary' + ? result.current.onSetPrimary('wallet_1') + : action === 'solana' + ? result.current.connectSolana('Phantom') + : result.current.onConnect('web3_metamask_signature'); + if (action === 'solana') { + act(() => void result.current.onConnect('web3_solana_signature')); + } + await act(async () => { + await run(); + }); + expect(result.current.pendingId).toBeUndefined(); + expect(result.current.wallets[0].primaryError).toBeUndefined(); + expect(result.current.availableProviders.every(provider => provider.connectError === undefined)).toBe(true); + if (action === 'solana') { + expect(result.current.solanaPickerOpen).toBe(true); + } + await act(async () => { + await run(); + }); + expect(request).toHaveBeenCalledTimes(2); + }, +); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts index 9e0ba769130..e53c4a9b034 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts @@ -1,3 +1,4 @@ +import { isReverificationCancelledError } from '@clerk/shared/error'; import { useRef, useState } from 'react'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; @@ -34,6 +35,9 @@ export function useUserProfileWeb3WalletsController({ await action(); return true; } catch (error) { + if (isReverificationCancelledError(error)) { + return false; + } const message = error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'; setErrors(current => ({ ...current, [id]: message })); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx index 47328911f63..ab0be4241ee 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -1,11 +1,12 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; import { useClerk, useUser } from '@clerk/shared/react'; import { WEB3_PROVIDERS } from '@clerk/shared/web3'; -import type { ReactNode } from 'react'; +import { type ReactNode, useRef } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; -import { Reverification, useReverificationFlow } from '../../reverification'; +import { currentInteractionOrigin } from '../../../primitives/utils/interaction-origin'; +import { ReverificationDialog, useReverificationFlow } from '../../reverification'; import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; @@ -17,18 +18,26 @@ export interface UserProfileWeb3WalletsSectionProps { fallbackFocus?: () => HTMLElement | null; } -function errorMessage(error: unknown): string { +function actionError(error: unknown): unknown { + if (isReverificationCancelledError(error)) { + return error; + } if (isClerkAPIResponseError(error)) { const first = error.errors[0]; - return first?.longMessage || first?.message || 'Something went wrong. Please try again.'; + return new Error(first?.longMessage || first?.message || 'Something went wrong. Please try again.'); } - return error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'; + return new Error(error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'); } export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserProfileWeb3WalletsSectionProps) { const clerk = useClerk(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); + const reverificationFocus = useRef(null); + const captureReverificationFocus = () => { + const active = document.activeElement; + reverificationFocus.current = currentInteractionOrigin() ?? (active instanceof HTMLElement ? active : null); + }; const [createWallet, createReverification] = useReverificationFlow((address: string) => user?.createWeb3Wallet({ web3Wallet: address }), ); @@ -36,6 +45,10 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP user?.update({ primaryWeb3WalletId: walletId }), ); + const [destroyWallet, removeReverification] = useReverificationFlow((walletId: string) => + user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(), + ); + const projection = user && environment ? projectWeb3Wallets({ @@ -53,6 +66,7 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP : ({ status: 'hidden' } as const); const connect = async (strategy: string, walletName?: string) => { + captureReverificationFocus(); const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); const manager = clerk.__internal_moduleManager; if (!provider || !manager || !user) { @@ -85,26 +99,27 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP } await prepared.attemptVerification({ signature }); } catch (error) { - throw new Error(errorMessage(error)); + throw actionError(error); } }; const setPrimary = async (walletId: string) => { + captureReverificationFocus(); if (!user?.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { return; } try { await updatePrimary(walletId); } catch (error) { - throw new Error(errorMessage(error)); + throw actionError(error); } }; const remove = async (walletId: string) => { try { - await user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(); + await destroyWallet(walletId); } catch (error) { - throw new Error(errorMessage(error)); + throw actionError(error); } }; @@ -136,9 +151,11 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP void controller.onSetPrimary(id); }} onRemove={remove} + removeReverification={removeReverification} /> provider.id === 'web3_solana_signature')?.connectError} onOpenChange={open => { @@ -150,8 +167,16 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP void controller.connectSolana(walletName); }} /> - - + {!controller.solanaPickerOpen ? ( + + ) : null} + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts index 4c2bbc2339d..ec91f51cf49 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts @@ -8,6 +8,7 @@ export const userProfileWeb3WalletsMessages = { primary: 'Primary', unverified: 'Unverified', solanaDialog: { + back: 'Back', title: 'Select a Solana wallet', description: 'Choose an installed wallet to connect to your account.', noneAvailable: 'No Solana wallets are available.', From 1d7cd6a44284abaeff97940bd6e5ec0975a9eaeb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:23:34 -0600 Subject: [PATCH 03/10] test(mosaic): cover selected Solana wallet loading --- ...user-profile-solana-wallet.dialog.test.tsx | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx index 6e68d3f4b23..3586ec380b7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx @@ -114,3 +114,69 @@ it('returns focus to the selected wallet after cancelling verification', async ( unregister(); } }); + +it('shows loading only on the selected wallet and disables both choices until it settles', async () => { + const unregister = getWallets().register( + ...['First Solana wallet', 'Second Solana wallet'].map(name => ({ + version: '1.0.0' as const, + name, + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [], + features: { 'solana:signMessage': {} }, + })), + ); + const user = userEvent.setup(); + const onConnect = vi.fn(); + function Harness() { + const [pending, setPending] = useState(false); + return ( + <> + + { + onConnect(name); + setPending(true); + }} + /> + + ); + } + try { + render( + + + , + ); + const first = screen.getByRole('button', { name: 'First Solana wallet' }); + const second = screen.getByRole('button', { name: 'Second Solana wallet' }); + await user.click(first); + expect(first).toHaveAttribute('aria-busy', 'true'); + expect(second).not.toHaveAttribute('aria-busy', 'true'); + expect(first).toBeDisabled(); + expect(second).toBeDisabled(); + await user.click(second); + expect(onConnect).toHaveBeenCalledExactlyOnceWith('First Solana wallet'); + await user.click(screen.getByText('Finish connection')); + expect(first).not.toHaveAttribute('aria-busy', 'true'); + expect(second).not.toHaveAttribute('aria-busy', 'true'); + expect(first).toBeEnabled(); + expect(second).toBeEnabled(); + await user.click(second); + expect(first).not.toHaveAttribute('aria-busy', 'true'); + expect(second).toHaveAttribute('aria-busy', 'true'); + expect(first).toBeDisabled(); + expect(second).toBeDisabled(); + expect(onConnect).toHaveBeenLastCalledWith('Second Solana wallet'); + } finally { + unregister(); + } +}); From bb1c7c03322bb7d844562f8d77859fb2254f6482 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:24:39 -0600 Subject: [PATCH 04/10] fix(mosaic): show loading on the selected Solana wallet --- .../user-profile-solana-wallet.dialog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx index 13cb9b07b34..12798e0026e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx @@ -34,7 +34,7 @@ export function UserProfileSolanaWalletDialog({ }) { const m = useMessages('userProfileWeb3Wallets'); const [wallets, setWallets] = useState(installedSolanaWallets); - const selectedWalletName = useRef(); + const [selectedWalletName, setSelectedWalletName] = useState(); const selectedWallet = useRef(null); const restorePickerFocus = useRef(false); const verification = @@ -118,7 +118,7 @@ export function UserProfileSolanaWalletDialog({ { - if (selectedWalletName.current === wallet.name) { + if (selectedWalletName === wallet.name) { selectedWallet.current = element; } }} @@ -126,10 +126,10 @@ export function UserProfileSolanaWalletDialog({ variant='outline' color='neutral' fullWidth - isPending={pending} + isPending={pending && selectedWalletName === wallet.name} disabled={pending} onClick={event => { - selectedWalletName.current = wallet.name; + setSelectedWalletName(wallet.name); selectedWallet.current = event.currentTarget; onConnect(wallet.name); }} From be0b80e08c2440279e6092e1031b54448b86ec2f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:55:14 -0600 Subject: [PATCH 05/10] fix(mosaic): hide wallets when Web3 is disabled --- .changeset/tidy-wallet-visibility.md | 5 ++++ ...-web3-wallets-section.integration.test.tsx | 25 +++++++++++++++++-- .../user-profile-web3-wallets-section.tsx | 3 +++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-wallet-visibility.md diff --git a/.changeset/tidy-wallet-visibility.md b/.changeset/tidy-wallet-visibility.md new file mode 100644 index 00000000000..aef8bbcb46d --- /dev/null +++ b/.changeset/tidy-wallet-visibility.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Hide the Web3 wallet section when Web3 wallets are disabled in the instance settings. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx index 58897f9f5c0..c450a5188f7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx @@ -8,6 +8,7 @@ import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; const request = vi.fn(); +const attributes: { web3_wallet?: { enabled: boolean } } = {}; vi.mock('@clerk/shared/internal/clerk-js/web3', () => ({ createWeb3: () => ({ getWeb3Identifier: () => Promise.resolve('0x1234') }), })); @@ -37,7 +38,11 @@ vi.mock('@clerk/shared/react', async importOriginal => { __internal_getOption: () => undefined, __internal_moduleManager: {}, __internal_environment: { - userSettings: { web3FirstFactors: ['web3_metamask_signature'], enterpriseSSO: { enabled: false } }, + userSettings: { + attributes, + web3FirstFactors: ['web3_metamask_signature'], + enterpriseSSO: { enabled: false }, + }, displayConfig: { supportEmail: 'support@example.com' }, }, }), @@ -46,10 +51,26 @@ vi.mock('@clerk/shared/react', async importOriginal => { }; }); -describe('Web3 wallet cancellation', () => { +describe('Web3 wallets', () => { beforeEach(() => { + attributes.web3_wallet = { enabled: true }; request.mockReset().mockRejectedValue(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })); }); + + it.each([false, undefined])('hides existing wallets and providers when Web3 enabled is %s', enabled => { + attributes.web3_wallet = enabled === undefined ? undefined : { enabled }; + + render( + + + , + ); + + expect(screen.queryByRole('button', { name: 'Manage 0xabcdef' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).not.toBeInTheDocument(); + expect(request).not.toHaveBeenCalled(); + }); + it.each(['connect', 'primary', 'remove'] as const)( 'keeps %s cancellation out of the visible errors', async action => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx index ab0be4241ee..e0f90d4a0a9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -133,6 +133,9 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP if (!isLoaded || !environment) { return fallback ?? null; } + if (!environment.userSettings.attributes.web3_wallet?.enabled) { + return null; + } if (projection.status === 'hidden') { return null; } From 233dc120eb25ec421e334f5dfcf13d8466638781 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:24:59 -0600 Subject: [PATCH 06/10] fix(mosaic): align Web3 reverification with controller status --- ...profile-web3-wallets-section.view.test.tsx | 38 +++++++++++++++- ...user-profile-web3-wallets-section.view.tsx | 3 +- ...user-profile-solana-wallet.dialog.test.tsx | 45 ++++++++++++++----- .../user-profile-solana-wallet.dialog.tsx | 14 +++--- 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx index 359c4674d02..d446930872d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx @@ -1,5 +1,6 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; @@ -80,6 +81,41 @@ describe('Web3 wallets section', () => { }, ); + it('returns from wallet removal verification to the same confirmation', async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const onRemove = vi.fn(); + function Harness() { + const [verifying, setVerifying] = useState(true); + return ( + { + onCancel(); + setVerifying(false); + }, + } + : { status: 'idle' } + } + /> + ); + } + render(); + + await user.click(screen.getByRole('button', { name: 'Manage 0x1234' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + expect(screen.getByRole('dialog')).toHaveTextContent('Cannot verify your account'); + await user.click(screen.getByRole('button', { name: 'Back', exact: true })); + expect(onCancel).toHaveBeenCalledOnce(); + await waitFor(() => expect(screen.getByRole('alertdialog')).toHaveTextContent('Remove wallet?')); + expect(onRemove).not.toHaveBeenCalled(); + }); + it('shows connection errors while keeping Connect available', () => { render( ) : null} {onRemove ? ( - { it('keeps the picker visible while verification loads', () => { - renderDialog({ status: 'loading', phase: 'active' }); + renderDialog({ status: 'loading' }); expect(screen.getByText(/No Solana wallets are available/)).toBeInTheDocument(); expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); expect(screen.getAllByRole('dialog')).toHaveLength(1); }); + it('cancels a loading verification with Escape when cancellation is available', async () => { + const onCancel = vi.fn(); + const { onOpenChange } = renderDialog({ status: 'loading', onCancel }); + + await userEvent.setup().keyboard('{Escape}'); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + it.each(['Back', 'Close', 'Escape'])( 'returns to the picker when verification is cancelled with %s', async control => { const user = userEvent.setup(); const onCancel = vi.fn(); - const { onOpenChange } = renderDialog({ status: 'unavailable', phase: 'active', onCancel }); + const { onOpenChange } = renderDialog({ status: 'unavailable', onCancel }); expect(screen.getByText('Cannot verify your account')).toBeInTheDocument(); expect(screen.getAllByRole('dialog')).toHaveLength(1); if (control === 'Escape') { @@ -52,18 +81,16 @@ describe('Solana wallet reverification', () => { it('blocks dismissal while the protected operation retries', async () => { const user = userEvent.setup(); - const onCancel = vi.fn(); - const { onOpenChange } = renderDialog({ status: 'unavailable', phase: 'retrying', onCancel }); + const { onOpenChange } = renderDialog(retryingVerification()); expect(screen.getByRole('button', { name: 'Back', exact: true })).toBeDisabled(); await user.click(screen.getByRole('button', { name: 'Close', exact: true })); await user.keyboard('{Escape}'); - expect(onCancel).not.toHaveBeenCalled(); expect(onOpenChange).not.toHaveBeenCalled(); }); it('blocks dismissal while waiting for the wallet provider', async () => { const user = userEvent.setup(); - const { onOpenChange } = renderDialog({ status: 'idle', phase: 'inactive' }); + const { onOpenChange } = renderDialog({ status: 'idle' }); await user.click(screen.getByRole('button', { name: 'Close', exact: true })); await user.keyboard('{Escape}'); expect(onOpenChange).not.toHaveBeenCalled(); @@ -88,11 +115,7 @@ it('returns focus to the selected wallet after cancelling verification', async ( pending={active} onOpenChange={vi.fn()} onConnect={() => setActive(true)} - reverification={ - active - ? { status: 'unavailable', phase: 'active', onCancel: () => setActive(false) } - : { status: 'idle', phase: 'inactive' } - } + reverification={active ? { status: 'unavailable', onCancel: () => setActive(false) } : { status: 'idle' }} /> ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx index 12798e0026e..77d87c55ef6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx @@ -38,7 +38,11 @@ export function UserProfileSolanaWalletDialog({ const selectedWallet = useRef(null); const restorePickerFocus = useRef(false); const verification = - reverification?.status === 'ready' || reverification?.status === 'unavailable' ? reverification : undefined; + reverification?.status === 'ready' || + reverification?.status === 'unavailable' || + reverification?.status === 'retrying' + ? reverification + : undefined; const verifying = verification !== undefined; useEffect(() => { @@ -66,11 +70,11 @@ export function UserProfileSolanaWalletDialog({ { - if (!nextOpen && reverification?.phase === 'active') { - reverification.onCancel?.(); + if (!nextOpen && reverification?.onCancel) { + reverification.onCancel(); return; } - if (!nextOpen && pending) { + if (!nextOpen && (verification || pending)) { return; } onOpenChange(nextOpen); @@ -171,7 +175,7 @@ export function UserProfileSolanaWalletDialog({ variant='outline' color='neutral' fullWidth - disabled={verification.phase === 'retrying'} + disabled={!verification.onCancel} onClick={verification.onCancel} > {m.solanaDialog.back} From 62d1c93d6aa21e8efd319fff5104861826a6b066 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:46:30 -0600 Subject: [PATCH 07/10] refactor(mosaic): remove Web3 reverification flow --- ...-web3-wallets-section.integration.test.tsx | 10 +- ...profile-web3-wallets-section.view.test.tsx | 42 ++-- ...user-profile-web3-wallets-section.view.tsx | 7 +- ...user-profile-solana-wallet.dialog.test.tsx | 132 +++--------- .../user-profile-solana-wallet.dialog.tsx | 198 ++++++------------ ...e-web3-wallets-section.controller.test.tsx | 81 +++---- ...profile-web3-wallets-section.controller.ts | 4 - .../user-profile-web3-wallets-section.tsx | 45 +--- .../user-profile-web3-wallets.messages.ts | 1 - 9 files changed, 157 insertions(+), 363 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx index c450a5188f7..4ebf30cc5ac 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx @@ -1,4 +1,3 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; import type * as SharedReact from '@clerk/shared/react'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -47,14 +46,13 @@ vi.mock('@clerk/shared/react', async importOriginal => { }, }), useSession: () => ({ session: { id: 'session_1' } }), - useReverification: (fetcher: F) => fetcher, }; }); describe('Web3 wallets', () => { beforeEach(() => { attributes.web3_wallet = { enabled: true }; - request.mockReset().mockRejectedValue(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })); + request.mockReset().mockRejectedValue(new Error('Wallet request failed')); }); it.each([false, undefined])('hides existing wallets and providers when Web3 enabled is %s', enabled => { @@ -72,7 +70,7 @@ describe('Web3 wallets', () => { }); it.each(['connect', 'primary', 'remove'] as const)( - 'keeps %s cancellation out of the visible errors', + 'shows a direct %s failure without retrying automatically', async action => { const user = userEvent.setup(); render( @@ -92,6 +90,8 @@ describe('Web3 wallets', () => { } } await waitFor(() => expect(request).toHaveBeenCalledOnce()); + expect(await screen.findByRole('alert')).toHaveTextContent('Wallet request failed'); + expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); if (action === 'remove') { await waitFor(() => expect( @@ -101,8 +101,6 @@ describe('Web3 wallets', () => { } else { await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).not.toBeDisabled()); } - expect(screen.queryByText(/Cancelled/)).not.toBeInTheDocument(); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); if (action === 'remove') { expect(screen.getByRole('alertdialog')).toBeInTheDocument(); } diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx index d446930872d..a9c9699c6c6 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx @@ -1,6 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; @@ -81,39 +80,22 @@ describe('Web3 wallets section', () => { }, ); - it('returns from wallet removal verification to the same confirmation', async () => { + it('removes a wallet directly from its confirmation', async () => { const user = userEvent.setup(); - const onCancel = vi.fn(); const onRemove = vi.fn(); - function Harness() { - const [verifying, setVerifying] = useState(true); - return ( - { - onCancel(); - setVerifying(false); - }, - } - : { status: 'idle' } - } - /> - ); - } - render(); + render( + , + ); await user.click(screen.getByRole('button', { name: 'Manage 0x1234' })); await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - expect(screen.getByRole('dialog')).toHaveTextContent('Cannot verify your account'); - await user.click(screen.getByRole('button', { name: 'Back', exact: true })); - expect(onCancel).toHaveBeenCalledOnce(); - await waitFor(() => expect(screen.getByRole('alertdialog')).toHaveTextContent('Remove wallet?')); - expect(onRemove).not.toHaveBeenCalled(); + expect(screen.getByRole('alertdialog')).toHaveTextContent('Remove wallet?'); + await user.click(screen.getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); + expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); }); it('shows connection errors while keeping Connect available', () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx index 7c56f70d6af..26222b9ac73 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -5,8 +5,6 @@ import { Section } from '../../components/section'; import { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; import { fill, useMessages } from '../../localization'; import { truncateWithEndVisible } from '../../utils/truncateTextWithEndVisible'; -import type { ReverificationController } from '../reverification'; -import { ReverificationConfirmation } from '../reverification/reverification-confirmation'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; export interface UserProfileWeb3Provider { @@ -32,7 +30,6 @@ export interface UserProfileWeb3WalletsSectionViewProps { wallets: UserProfileWeb3Wallet[]; availableProviders?: UserProfileWeb3Provider[]; pendingId?: string; - removeReverification?: ReverificationController; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void | Promise; @@ -46,7 +43,6 @@ export function UserProfileWeb3WalletsSectionView({ onConnect, onSetPrimary, onRemove, - removeReverification, }: UserProfileWeb3WalletsSectionViewProps) { const m = useMessages('userProfileWeb3Wallets'); const section = useRef(null); @@ -97,9 +93,8 @@ export function UserProfileWeb3WalletsSectionView({ ) : null} {onRemove ? ( - fill(wallet.isVerified ? m.removeDialog.verifiedDescription : m.removeDialog.description, { diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx index 0f74dd47f6a..dd03b1f17e6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx @@ -1,141 +1,59 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getWallets } from '@wallet-standard/core'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; -import type { ReverificationController } from '../../reverification'; import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; -function renderDialog(reverification: ReverificationController, pending = true) { +function renderDialog(pending = false, error?: string) { const onOpenChange = vi.fn(); - const view = render( + render( , ); - return { ...view, onOpenChange }; + return onOpenChange; } -function retryingVerification(): ReverificationController { - return { - status: 'retrying', - step: 'password', - value: '', - onValueChange: vi.fn(), - isPending: true, - onSubmit: vi.fn(), - onShowMethods: vi.fn(), - onShowHelp: vi.fn(), - onBack: vi.fn(), - onEmailSupport: vi.fn(), - methods: [], - onSelectMethod: vi.fn(), - onResend: vi.fn(), - canResend: false, - }; -} - -describe('Solana wallet reverification', () => { - it('keeps the picker visible while verification loads', () => { - renderDialog({ status: 'loading' }); - expect(screen.getByText(/No Solana wallets are available/)).toBeInTheDocument(); - expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - }); - - it('cancels a loading verification with Escape when cancellation is available', async () => { - const onCancel = vi.fn(); - const { onOpenChange } = renderDialog({ status: 'loading', onCancel }); +describe('Solana wallet picker', () => { + it.each(['Close', 'Escape'])('closes directly with %s when idle', async control => { + const user = userEvent.setup(); + const onOpenChange = renderDialog(); - await userEvent.setup().keyboard('{Escape}'); + if (control === 'Escape') { + await user.keyboard('{Escape}'); + } else { + await user.click(screen.getByRole('button', { name: 'Close' })); + } - expect(onCancel).toHaveBeenCalledOnce(); - expect(onOpenChange).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); }); - it.each(['Back', 'Close', 'Escape'])( - 'returns to the picker when verification is cancelled with %s', - async control => { - const user = userEvent.setup(); - const onCancel = vi.fn(); - const { onOpenChange } = renderDialog({ status: 'unavailable', onCancel }); - expect(screen.getByText('Cannot verify your account')).toBeInTheDocument(); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - if (control === 'Escape') { - await user.keyboard('{Escape}'); - } else { - await user.click(screen.getByRole('button', { name: control, exact: true })); - } - expect(onCancel).toHaveBeenCalledOnce(); - expect(onOpenChange).not.toHaveBeenCalled(); - }, - ); - - it('blocks dismissal while the protected operation retries', async () => { + it('blocks Close and Escape while the wallet provider is pending', async () => { const user = userEvent.setup(); - const { onOpenChange } = renderDialog(retryingVerification()); - expect(screen.getByRole('button', { name: 'Back', exact: true })).toBeDisabled(); - await user.click(screen.getByRole('button', { name: 'Close', exact: true })); - await user.keyboard('{Escape}'); - expect(onOpenChange).not.toHaveBeenCalled(); - }); + const onOpenChange = renderDialog(true); - it('blocks dismissal while waiting for the wallet provider', async () => { - const user = userEvent.setup(); - const { onOpenChange } = renderDialog({ status: 'idle' }); - await user.click(screen.getByRole('button', { name: 'Close', exact: true })); + await user.click(screen.getByRole('button', { name: 'Close' })); await user.keyboard('{Escape}'); + expect(onOpenChange).not.toHaveBeenCalled(); }); -}); -it('returns focus to the selected wallet after cancelling verification', async () => { - const unregister = getWallets().register({ - version: '1.0.0', - name: 'Test Solana wallet', - icon: 'data:image/svg+xml;base64,', - chains: ['solana:mainnet'], - accounts: [], - features: { 'solana:signMessage': {} }, + it('shows a direct connection error in the picker', () => { + renderDialog(false, 'Wallet connection failed'); + + expect(screen.getByRole('alert')).toHaveTextContent('Wallet connection failed'); + expect(screen.getByRole('dialog', { name: 'Select a Solana wallet' })).toBeInTheDocument(); + expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); }); - const user = userEvent.setup(); - function Harness() { - const [active, setActive] = useState(false); - return ( - setActive(true)} - reverification={active ? { status: 'unavailable', onCancel: () => setActive(false) } : { status: 'idle' }} - /> - ); - } - try { - render( - - - , - ); - await user.click(screen.getByRole('button', { name: 'Test Solana wallet' })); - expect(screen.getByText('Cannot verify your account')).toBeInTheDocument(); - await waitFor(() => - expect(screen.queryByRole('button', { name: 'Test Solana wallet', hidden: true })).not.toBeInTheDocument(), - ); - await user.click(screen.getByRole('button', { name: 'Back', exact: true })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Test Solana wallet' })).toHaveFocus()); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - } finally { - unregister(); - } }); it('shows loading only on the selected wallet and disables both choices until it settles', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx index 77d87c55ef6..24ebc321eb5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx @@ -1,13 +1,11 @@ import { getWallets } from '@wallet-standard/core'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import { Dialog } from '../../../components/dialog'; -import { Flow } from '../../../components/flow'; import { useMessages } from '../../../localization'; -import { Reverification, type ReverificationController } from '../../reverification'; function installedSolanaWallets() { return getWallets() @@ -23,9 +21,7 @@ export function UserProfileSolanaWalletDialog({ error, onOpenChange, onConnect, - reverification, }: { - reverification?: ReverificationController; open: boolean; pending: boolean; error?: string; @@ -35,24 +31,6 @@ export function UserProfileSolanaWalletDialog({ const m = useMessages('userProfileWeb3Wallets'); const [wallets, setWallets] = useState(installedSolanaWallets); const [selectedWalletName, setSelectedWalletName] = useState(); - const selectedWallet = useRef(null); - const restorePickerFocus = useRef(false); - const verification = - reverification?.status === 'ready' || - reverification?.status === 'unavailable' || - reverification?.status === 'retrying' - ? reverification - : undefined; - const verifying = verification !== undefined; - - useEffect(() => { - if (verifying) { - restorePickerFocus.current = true; - } else if (restorePickerFocus.current && !pending) { - restorePickerFocus.current = false; - selectedWallet.current?.focus({ preventScroll: true }); - } - }, [verifying, pending]); useEffect(() => { const registry = getWallets(); @@ -70,11 +48,7 @@ export function UserProfileSolanaWalletDialog({ { - if (!nextOpen && reverification?.onCancel) { - reverification.onCancel(); - return; - } - if (!nextOpen && (verification || pending)) { + if (!nextOpen && pending) { return; } onOpenChange(nextOpen); @@ -85,108 +59,72 @@ export function UserProfileSolanaWalletDialog({ elevation='overlay' renderBranding={false} > - - {() => ( - <> - - - {m.solanaDialog.title} - {m.solanaDialog.description} - - - {error ? ( - - {error} - - ) : null} - {wallets.length === 0 ? ( -

- {m.solanaDialog.noneAvailable}{' '} - - {m.solanaDialog.findWallet} - -

- ) : null} - {wallets.map(wallet => ( - { - if (selectedWalletName === wallet.name) { - selectedWallet.current = element; - } - }} - type='button' - variant='outline' - color='neutral' - fullWidth - isPending={pending && selectedWalletName === wallet.name} - disabled={pending} - onClick={event => { - setSelectedWalletName(wallet.name); - selectedWallet.current = event.currentTarget; - onConnect(wallet.name); - }} - > - {wallet.icon ? ( - - ) : null} - {wallet.name} - - ))} -
- - - {m.solanaDialog.cancel} - - } - /> - -
- - {verification ? ( - <> - - - - - - ) : null} - - - )} -
+ + {m.solanaDialog.title} + {m.solanaDialog.description} + + + {error ? ( + + {error} + + ) : null} + {wallets.length === 0 ? ( +

+ {m.solanaDialog.noneAvailable}{' '} + + {m.solanaDialog.findWallet} + +

+ ) : null} + {wallets.map(wallet => ( + { + setSelectedWalletName(wallet.name); + onConnect(wallet.name); + }} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + ))} +
+ + + {m.solanaDialog.cancel} + + } + /> +
diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx index 2c74b8568ba..b1f0f51d51d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx @@ -1,4 +1,3 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; @@ -53,12 +52,12 @@ describe('Web3 wallet controller', () => { wallets, availableProviders, connect: vi.fn(), - setPrimary: vi.fn().mockRejectedValue(new Error('Reverification canceled')), + setPrimary: vi.fn().mockRejectedValue(new Error('Primary update failed')), }), ); await act(async () => result.current.onSetPrimary('wallet_1')); - expect(result.current.wallets[0].primaryError).toBe('Reverification canceled'); + expect(result.current.wallets[0].primaryError).toBe('Primary update failed'); }); it('WEB3-09 opens the Solana picker before connecting', () => { @@ -74,40 +73,44 @@ describe('Web3 wallet controller', () => { }); }); -it.each(['connect', 'primary', 'solana'] as const)( - 'cancels %s verification without an error and allows retry', - async action => { - const cancelled = new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' }); - const request = vi.fn().mockRejectedValueOnce(cancelled).mockResolvedValueOnce(undefined); - const { result } = renderHook(() => - useUserProfileWeb3WalletsController({ - wallets, - availableProviders: [...availableProviders, { id: 'web3_solana_signature', provider: 'Solana' }], - connect: request, - setPrimary: request, - }), +it.each(['connect', 'primary', 'solana'] as const)('shows a rejected %s request and allows retry', async action => { + const request = vi.fn().mockRejectedValueOnce(new Error('Wallet request failed')).mockResolvedValueOnce(undefined); + const { result } = renderHook(() => + useUserProfileWeb3WalletsController({ + wallets, + availableProviders: [...availableProviders, { id: 'web3_solana_signature', provider: 'Solana' }], + connect: request, + setPrimary: request, + }), + ); + const run = () => + action === 'primary' + ? result.current.onSetPrimary('wallet_1') + : action === 'solana' + ? result.current.connectSolana('Phantom') + : result.current.onConnect('web3_metamask_signature'); + if (action === 'solana') { + act(() => void result.current.onConnect('web3_solana_signature')); + } + await act(async () => { + await run(); + }); + expect(result.current.pendingId).toBeUndefined(); + if (action === 'primary') { + expect(result.current.wallets[0].primaryError).toBe('Wallet request failed'); + } else { + const provider = result.current.availableProviders.find( + item => item.id === (action === 'solana' ? 'web3_solana_signature' : 'web3_metamask_signature'), ); - const run = () => - action === 'primary' - ? result.current.onSetPrimary('wallet_1') - : action === 'solana' - ? result.current.connectSolana('Phantom') - : result.current.onConnect('web3_metamask_signature'); - if (action === 'solana') { - act(() => void result.current.onConnect('web3_solana_signature')); - } - await act(async () => { - await run(); - }); - expect(result.current.pendingId).toBeUndefined(); - expect(result.current.wallets[0].primaryError).toBeUndefined(); - expect(result.current.availableProviders.every(provider => provider.connectError === undefined)).toBe(true); - if (action === 'solana') { - expect(result.current.solanaPickerOpen).toBe(true); - } - await act(async () => { - await run(); - }); - expect(request).toHaveBeenCalledTimes(2); - }, -); + expect(provider?.connectError).toBe('Wallet request failed'); + } + if (action === 'solana') { + expect(result.current.solanaPickerOpen).toBe(true); + } + await act(async () => { + await run(); + }); + expect(request).toHaveBeenCalledTimes(2); + expect(result.current.wallets[0].primaryError).toBeUndefined(); + expect(result.current.availableProviders.every(provider => provider.connectError === undefined)).toBe(true); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts index e53c4a9b034..9e0ba769130 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts @@ -1,4 +1,3 @@ -import { isReverificationCancelledError } from '@clerk/shared/error'; import { useRef, useState } from 'react'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; @@ -35,9 +34,6 @@ export function useUserProfileWeb3WalletsController({ await action(); return true; } catch (error) { - if (isReverificationCancelledError(error)) { - return false; - } const message = error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'; setErrors(current => ({ ...current, [id]: message })); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx index e0f90d4a0a9..928ff8b2c7a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -1,12 +1,10 @@ -import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; import { useClerk, useUser } from '@clerk/shared/react'; import { WEB3_PROVIDERS } from '@clerk/shared/web3'; -import { type ReactNode, useRef } from 'react'; +import type { ReactNode } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; -import { currentInteractionOrigin } from '../../../primitives/utils/interaction-origin'; -import { ReverificationDialog, useReverificationFlow } from '../../reverification'; import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; @@ -19,9 +17,6 @@ export interface UserProfileWeb3WalletsSectionProps { } function actionError(error: unknown): unknown { - if (isReverificationCancelledError(error)) { - return error; - } if (isClerkAPIResponseError(error)) { const first = error.errors[0]; return new Error(first?.longMessage || first?.message || 'Something went wrong. Please try again.'); @@ -33,22 +28,6 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP const clerk = useClerk(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); - const reverificationFocus = useRef(null); - const captureReverificationFocus = () => { - const active = document.activeElement; - reverificationFocus.current = currentInteractionOrigin() ?? (active instanceof HTMLElement ? active : null); - }; - const [createWallet, createReverification] = useReverificationFlow((address: string) => - user?.createWeb3Wallet({ web3Wallet: address }), - ); - const [updatePrimary, primaryReverification] = useReverificationFlow((walletId: string) => - user?.update({ primaryWeb3WalletId: walletId }), - ); - - const [destroyWallet, removeReverification] = useReverificationFlow((walletId: string) => - user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(), - ); - const projection = user && environment ? projectWeb3Wallets({ @@ -66,7 +45,6 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP : ({ status: 'hidden' } as const); const connect = async (strategy: string, walletName?: string) => { - captureReverificationFocus(); const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); const manager = clerk.__internal_moduleManager; if (!provider || !manager || !user) { @@ -79,7 +57,7 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP if (!identifier) { throw new Error('A Web3 Wallet extension cannot be found. Please install one to continue.'); } - const wallet = await createWallet(identifier); + const wallet = await user.createWeb3Wallet({ web3Wallet: identifier }); if (!wallet) { throw new Error('The wallet could not be created.'); } @@ -104,12 +82,11 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP }; const setPrimary = async (walletId: string) => { - captureReverificationFocus(); if (!user?.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { return; } try { - await updatePrimary(walletId); + await user.update({ primaryWeb3WalletId: walletId }); } catch (error) { throw actionError(error); } @@ -117,7 +94,7 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP const remove = async (walletId: string) => { try { - await destroyWallet(walletId); + await user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(); } catch (error) { throw actionError(error); } @@ -154,11 +131,9 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP void controller.onSetPrimary(id); }} onRemove={remove} - removeReverification={removeReverification} /> provider.id === 'web3_solana_signature')?.connectError} onOpenChange={open => { @@ -170,16 +145,6 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP void controller.connectSolana(walletName); }} /> - {!controller.solanaPickerOpen ? ( - - ) : null} - ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts index ec91f51cf49..4c2bbc2339d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts @@ -8,7 +8,6 @@ export const userProfileWeb3WalletsMessages = { primary: 'Primary', unverified: 'Unverified', solanaDialog: { - back: 'Back', title: 'Select a Solana wallet', description: 'Choose an installed wallet to connect to your account.', noneAvailable: 'No Solana wallets are available.', From 6f480af7ec4619496144715ceaa72940f22e0112 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 11:25:11 -0600 Subject: [PATCH 08/10] test(mosaic): cover wallet behavior through real Clerk --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 113 +++++++ packages/mosaic/src/__tests__/feature/fapi.ts | 30 +- .../user-profile-web3-actions.test.tsx | 123 -------- ...file-web3-wallets-section.feature.test.tsx | 293 ++++++++++++++++++ ...-web3-wallets-section.integration.test.tsx | 109 ------- ...profile-web3-wallets-section.view.test.tsx | 65 ---- ...user-profile-solana-wallet.dialog.test.tsx | 123 -------- ...e-web3-wallets-section.controller.test.tsx | 83 ----- packages/mosaic/vitest.config.mts | 1 + 9 files changed, 436 insertions(+), 504 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.test.tsx diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 039e8a04bc8..3b52c224807 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -7,6 +7,7 @@ import type { SessionJSON, UserJSON, UserOrganizationInvitationJSON, + Web3WalletJSON, } from '@clerk/shared/types'; import { http, HttpResponse, type JsonBodyType } from 'msw'; import { setupWorker } from 'msw/browser'; @@ -21,6 +22,7 @@ import { fapiPage, fapiToken, fapiVerification, + fapiWeb3Wallet, } from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; @@ -87,6 +89,20 @@ function updateUser(state: FakeFapiState, user: UserJSON): void { }; } +function findWeb3Wallet(state: FakeFapiState, id: unknown): Web3WalletJSON | undefined { + return activeUser(state)?.web3_wallets.find(wallet => wallet.id === id); +} + +function updateWeb3Wallet(state: FakeFapiState, wallet: Web3WalletJSON): void { + const user = activeUser(state); + if (user) { + updateUser(state, { + ...user, + web3_wallets: user.web3_wallets.map(current => (current.id === wallet.id ? wallet : current)), + }); + } +} + function missing() { return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); } @@ -164,6 +180,103 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { updateUser(state, { ...user, external_accounts: user.external_accounts.filter(item => item.id !== account.id) }); return envelope({ ...account, object: 'external_account' }, state.client); }), + http.post(fapiUrl('/v1/me/web3_wallets'), async ({ request }) => { + const user = activeUser(state); + if (!user) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const address = body.get('web3_wallet'); + if (!address) { + return missing(); + } + const wallet = fapiWeb3Wallet({ + id: `wallet_${user.web3_wallets.length + 1}`, + web3_wallet: address, + verification: { + id: 'verification_1', + object: 'verification', + status: 'unverified', + verified_at_client: '', + strategy: '', + attempts: 0, + expire_at: 0, + error: { code: '', message: '' }, + }, + }); + updateUser(state, { ...user, web3_wallets: [...user.web3_wallets, wallet] }); + return envelope(wallet, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id/prepare_verification'), async ({ params, request }) => { + const wallet = findWeb3Wallet(state, params.id); + if (!wallet) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const prepared = fapiWeb3Wallet({ + ...wallet, + verification: { + id: 'verification_1', + object: 'verification', + status: 'unverified', + verified_at_client: '', + strategy: body.get('strategy') ?? '', + message: 'Sign this wallet challenge', + attempts: 0, + expire_at: 0, + error: { code: '', message: '' }, + }, + }); + updateWeb3Wallet(state, prepared); + return envelope(prepared, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id/attempt_verification'), ({ params }) => { + const wallet = findWeb3Wallet(state, params.id); + if (!wallet) { + return missing(); + } + const verified = fapiWeb3Wallet({ + ...wallet, + verification: { + id: 'verification_1', + object: 'verification', + ...wallet.verification, + status: 'verified', + verified_at_client: '', + strategy: wallet.verification?.strategy ?? '', + attempts: 1, + expire_at: 0, + error: { code: '', message: '' }, + }, + }); + updateWeb3Wallet(state, verified); + return envelope(verified, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id'), ({ params, request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { + return undefined; + } + const user = activeUser(state); + const wallet = findWeb3Wallet(state, params.id); + if (!user || !wallet) { + return missing(); + } + updateUser(state, { ...user, web3_wallets: user.web3_wallets.filter(current => current.id !== wallet.id) }); + return envelope({ object: 'web3_wallet', id: wallet.id, deleted: true }, state.client); + }), + http.post(fapiUrl('/v1/me'), async ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + const user = activeUser(state); + if (!user) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const updated = { ...user, primary_web3_wallet_id: body.get('primary_web3_wallet_id') }; + updateUser(state, updated); + return envelope(updated, state.client); + }), http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { const session = findSession(state, params.id); return session diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index f52d1b12408..fca1f533de5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -20,11 +20,20 @@ import type { UserOrganizationInvitationJSON, UserSettingsJSON, VerificationJSON, + Web3Strategy, + Web3WalletJSON, } from '@clerk/shared/types'; type Settings = Omit; -export type FapiUserSettings = Omit, 'social'> & { social: Partial }; +type FapiWeb3Attribute = Omit & { + first_factors: Array; +}; + +export type FapiUserSettings = Omit, 'social' | 'attributes'> & { + social: Partial; + attributes: Omit & { web3_wallet: FapiWeb3Attribute }; +}; export type FapiEnvironment = Omit & { user_settings: FapiUserSettings; @@ -304,6 +313,25 @@ export function fapiEnterpriseAccount( }; } +export function fapiWeb3Wallet( + overrides: Partial & Pick, +): Web3WalletJSON { + return { + object: 'web3_wallet', + verification: { + id: 'verification_1', + object: 'verification', + status: 'verified', + verified_at_client: '', + strategy: 'web3_metamask_signature', + attempts: 1, + expire_at: 0, + error: { code: '', message: '' }, + }, + ...overrides, + }; +} + export function fapiUser(overrides: Partial & Pick): UserJSON { return { object: 'user', diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx deleted file mode 100644 index a30a938216c..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; - -const wallet = { id: 'wallet_1', provider: 'MetaMask', address: 'test', isVerified: true }; - -function renderWallets(onRemove: (id: string) => void | Promise) { - return render( - - - , - ); -} - -async function openRemoval(user: ReturnType) { - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); -} - -describe('Web3 wallet removal', () => { - it('returns keyboard focus to the menu when canceled', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - renderWallets(onRemove); - const trigger = screen.getByRole('button', { name: 'Manage MetaMask' }); - trigger.focus(); - await user.keyboard('{Enter}'); - await screen.findByRole('menuitem', { name: 'Remove wallet' }); - await user.keyboard('{ArrowDown}{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await user.keyboard('{Escape}'); - await waitFor(() => expect(trigger).toHaveFocus()); - expect(onRemove).not.toHaveBeenCalled(); - }); - - it('focuses the remaining wallet and then Connect when the caller removes rows', async () => { - const user = userEvent.setup(); - function Example() { - const [wallets, setWallets] = useState([wallet, { ...wallet, id: 'second', provider: 'Coinbase Wallet' }]); - return ( - - {}} - onRemove={id => setWallets(current => current.filter(item => item.id !== id))} - /> - - ); - } - render(); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })).toHaveFocus()); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Connect Other' })).toHaveFocus()); - }); - - it('keeps confirmation open while pending and allows retrying a rejected removal', async () => { - const user = userEvent.setup(); - const removal = createDeferredPromise(); - const onRemove = vi - .fn() - .mockImplementationOnce(async () => { - await removal.promise; - }) - .mockResolvedValue(undefined); - renderWallets(onRemove); - await openRemoval(user); - const dialog = screen.getByRole('alertdialog'); - const remove = within(dialog).getByRole('button', { name: 'Remove', exact: true }); - await user.click(remove); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); - expect(dialog).toBeInTheDocument(); - expect(remove).toHaveAttribute('aria-busy', 'true'); - await user.click(remove); - expect(onRemove).toHaveBeenCalledOnce(); - await act(async () => { - removal.reject(new Error('Unable to remove wallet')); - await removal.promise.catch(() => undefined); - }); - expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove wallet'); - await user.click(remove); - expect(onRemove).toHaveBeenNthCalledWith(2, 'wallet_1'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('uses the newly selected wallet and its warning after canceling another removal', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - render( - - - , - ); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - const dialog = screen.getByRole('alertdialog'); - expect(dialog).toHaveTextContent('second-wallet'); - expect(dialog).not.toHaveTextContent('You will no longer be able to sign in using this web3 wallet.'); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_2'); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx new file mode 100644 index 00000000000..c9bfbd6c62e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx @@ -0,0 +1,293 @@ +import type { UserJSON } from '@clerk/shared/types'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { WindowAppReadyEventAPI } from '@wallet-standard/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiEnvironment, fapiSession, fapiUser, fapiWeb3Wallet } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; + +describe('Web3 wallets', () => { + afterEach(() => vi.unstubAllGlobals()); + + function web3Environment() { + const base = fapiEnvironment(); + return fapiEnvironment({ + user_settings: { + attributes: { + ...base.user_settings.attributes, + web3_wallet: { + ...base.user_settings.attributes.web3_wallet, + enabled: true, + used_for_first_factor: true, + first_factors: ['web3_metamask_signature', 'web3_solana_signature'], + }, + }, + }, + }); + } + + async function renderWeb3(overrides: Partial = {}) { + const user = fapiUser({ id: 'user_1', ...overrides }); + const fapi = serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + }); + await renderWithClerk(); + return fapi; + } + + it('shows a saved wallet from the signed-in Clerk user', async () => { + await renderWeb3({ web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })] }); + + expect(await screen.findByText('Web3 wallets')).toBeInTheDocument(); + expect(screen.getByText('0x1234...cdef')).toBeInTheDocument(); + }); + + it('hides existing wallets when Web3 is disabled in the Clerk environment', async () => { + const user = fapiUser({ + id: 'user_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(); + + expect(screen.queryByText('Web3 wallets')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).not.toBeInTheDocument(); + }); + + it('creates and verifies a MetaMask wallet through Clerk and the injected provider', async () => { + const request = vi.fn(({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') { + return Promise.resolve(['0x1234567890abcdef']); + } + if (method === 'personal_sign') { + return Promise.resolve('signature'); + } + throw new Error(`Unexpected wallet method: ${method}`); + }); + vi.stubGlobal('ethereum', { request }); + const fapi = await renderWeb3(); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified')); + expect(request).toHaveBeenCalledWith({ method: 'eth_requestAccounts' }); + expect(request).toHaveBeenCalledWith({ method: 'personal_sign', params: expect.any(Array) }); + expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + }); + + it('holds one provider pending and allows a manual retry after an API error', async () => { + vi.stubGlobal('ethereum', { + request: vi.fn(({ method }: { method: string }) => + Promise.resolve(method === 'personal_sign' ? 'signature' : ['0x1234567890abcdef']), + ), + }); + const fapi = await renderWeb3(); + const hold = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Connect Solana' })).toBeDisabled(); + + hold.fail('wallet_creation_failed'); + expect(await screen.findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'), + ); + expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + }); + + it('sets a verified wallet as primary through Clerk', async () => { + const fapi = await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), + fapiWeb3Wallet({ id: 'wallet_2', web3_wallet: '0xabcdef1234567890' }), + ], + primary_web3_wallet_id: 'wallet_1', + }); + const user = userEvent.setup(); + + await waitFor(() => expect(screen.getAllByRole('button', { name: 'Manage MetaMask' })).toHaveLength(2)); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_2')); + const selectedRow = screen.getByTitle('0xabcdef1234567890').closest('.cl-section-row'); + expect(selectedRow).not.toBeNull(); + if (selectedRow) { + expect(within(selectedRow).getByText('Primary')).toBeInTheDocument(); + } + }); + + it('shows a primary update failure on the row and allows another attempt', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + const hold = holdRequests('post', '/v1/me'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + hold.fail('primary_update_failed'); + + expect(await screen.findByRole('alert')).toHaveTextContent('primary_update_failed'); + expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await waitFor(() => expect(retryFapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_1')); + expect(screen.getByText('Primary')).toBeInTheDocument(); + }); + + it('confirms removal and focuses Connect after deleting the last wallet', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + expect(screen.getByRole('alertdialog')).toHaveTextContent('0x1234...cdef'); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); + await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveFocus()); + }); + + it('keeps a failed wallet removal in its confirmation for a manual retry', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + const hold = holdRequests('post', '/v1/me/web3_wallets/:id'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + const confirm = within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }); + await user.click(confirm); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + expect(confirm).toHaveAttribute('aria-busy', 'true'); + hold.fail('wallet_removal_failed'); + + expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toHaveTextContent( + 'wallet_removal_failed', + ); + await waitFor(() => expect(confirm).not.toHaveAttribute('aria-busy', 'true')); + const retryFapi = serveFapi(fapi); + await user.click(confirm); + await waitFor(() => expect(retryFapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('cancels removal, then removes the newly selected wallet and focuses the remaining row', async () => { + const fapi = await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), + fapiWeb3Wallet({ id: 'wallet_2', web3_wallet: '0xabcdef1234567890' }), + ], + }); + const user = userEvent.setup(); + const first = screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]; + first.focus(); + await user.keyboard('{Enter}'); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + await user.keyboard('{Escape}'); + await waitFor(() => expect(first).toHaveFocus()); + expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(2); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + expect(screen.getByRole('alertdialog')).toHaveTextContent('0xabcd...7890'); + await user.click(screen.getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual(['wallet_1']); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); + }); + + it('connects the chosen Solana wallet and verifies it through Clerk', async () => { + const account = { + address: 'SolanaAddress123', + publicKey: new Uint8Array([1, 2, 3]), + chains: ['solana:mainnet' as const], + features: ['solana:signMessage' as const], + }; + const connect = vi.fn(() => Promise.resolve({ accounts: [account] })); + const signMessage = vi.fn(() => Promise.resolve([{ signature: new Uint8Array([4, 5, 6]) }])); + const wallet = { + version: '1.0.0' as const, + name: 'Test Solana', + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [account], + features: { + 'standard:connect': { version: '1.0.0' as const, connect }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage }, + }, + }; + const secondAccount = { ...account, address: 'SecondSolanaAddress456' }; + const secondConnect = vi.fn(() => Promise.resolve({ accounts: [secondAccount] })); + const secondSignMessage = vi.fn(() => Promise.resolve([{ signature: new Uint8Array([7, 8, 9]) }])); + const secondWallet = { + ...wallet, + name: 'Second Solana', + accounts: [secondAccount], + features: { + 'standard:connect': { version: '1.0.0' as const, connect: secondConnect }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage: secondSignMessage }, + }, + }; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => unregister.push(api.register(wallet), api.register(secondWallet)); + const onAppReady = (event: Event & { detail?: WindowAppReadyEventAPI }) => { + if (event.detail) { + register(event.detail); + } + }; + window.addEventListener('wallet-standard:app-ready', onAppReady); + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); + try { + const fapi = await renderWeb3(); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Connect Solana' })); + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Second Solana' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Test Solana' })).toBeDisabled(); + await user.keyboard('{Escape}'); + expect(screen.getByRole('dialog')).toBeVisible(); + creation.fail('wallet_creation_failed'); + expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); + await waitFor(() => + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'), + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.web3_wallet).toBe('SecondSolanaAddress456'); + expect(connect).not.toHaveBeenCalled(); + expect(signMessage).not.toHaveBeenCalled(); + expect(secondConnect).toHaveBeenCalledTimes(2); + expect(secondSignMessage).toHaveBeenCalledOnce(); + expect(await screen.findByRole('button', { name: 'Manage Solana' })).toBeInTheDocument(); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx deleted file mode 100644 index 4ebf30cc5ac..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.integration.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type * as SharedReact from '@clerk/shared/react'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; - -const request = vi.fn(); -const attributes: { web3_wallet?: { enabled: boolean } } = {}; -vi.mock('@clerk/shared/internal/clerk-js/web3', () => ({ - createWeb3: () => ({ getWeb3Identifier: () => Promise.resolve('0x1234') }), -})); -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ - isLoaded: true, - user: { - id: 'user_1', - enterpriseAccounts: [], - primaryWeb3WalletId: null, - web3Wallets: [ - { - id: 'wallet_1', - web3Wallet: '0xabcdef', - verification: { strategy: 'admin', status: 'verified' }, - destroy: request, - }, - ], - createWeb3Wallet: request, - update: request, - }, - }), - useClerk: () => ({ - __internal_getOption: () => undefined, - __internal_moduleManager: {}, - __internal_environment: { - userSettings: { - attributes, - web3FirstFactors: ['web3_metamask_signature'], - enterpriseSSO: { enabled: false }, - }, - displayConfig: { supportEmail: 'support@example.com' }, - }, - }), - useSession: () => ({ session: { id: 'session_1' } }), - }; -}); - -describe('Web3 wallets', () => { - beforeEach(() => { - attributes.web3_wallet = { enabled: true }; - request.mockReset().mockRejectedValue(new Error('Wallet request failed')); - }); - - it.each([false, undefined])('hides existing wallets and providers when Web3 enabled is %s', enabled => { - attributes.web3_wallet = enabled === undefined ? undefined : { enabled }; - - render( - - - , - ); - - expect(screen.queryByRole('button', { name: 'Manage 0xabcdef' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).not.toBeInTheDocument(); - expect(request).not.toHaveBeenCalled(); - }); - - it.each(['connect', 'primary', 'remove'] as const)( - 'shows a direct %s failure without retrying automatically', - async action => { - const user = userEvent.setup(); - render( - - - , - ); - if (action === 'connect') { - await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); - } else { - await user.click(screen.getByRole('button', { name: 'Manage 0xabcdef' })); - await user.click( - screen.getByRole('menuitem', { name: action === 'primary' ? 'Set as primary' : 'Remove wallet' }), - ); - if (action === 'remove') { - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - } - } - await waitFor(() => expect(request).toHaveBeenCalledOnce()); - expect(await screen.findByRole('alert')).toHaveTextContent('Wallet request failed'); - expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); - if (action === 'remove') { - await waitFor(() => - expect( - within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true }), - ).not.toHaveAttribute('aria-busy', 'true'), - ); - } else { - await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).not.toBeDisabled()); - } - if (action === 'remove') { - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - } - }, - ); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx index a9c9699c6c6..42555956ed4 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx @@ -5,19 +5,6 @@ import { describe, expect, it, vi } from 'vitest'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; describe('Web3 wallets section', () => { - it.each([{ availableProviders: [] }, { availableProviders: [{ id: 'metamask', provider: 'MetaMask' }] }])( - 'hides the entire section without wallets or actionable providers (%j)', - ({ availableProviders }) => { - const { container } = render( - , - ); - expect(container).toBeEmptyDOMElement(); - }, - ); - it.each([ { isPrimary: true, isVerified: true, canSetPrimary: false }, { isPrimary: false, isVerified: true, canSetPrimary: true }, @@ -80,58 +67,6 @@ describe('Web3 wallets section', () => { }, ); - it('removes a wallet directly from its confirmation', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole('button', { name: 'Manage 0x1234' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - expect(screen.getByRole('alertdialog')).toHaveTextContent('Remove wallet?'); - await user.click(screen.getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); - expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); - }); - - it('shows connection errors while keeping Connect available', () => { - render( - , - ); - expect(screen.getByRole('alert')).toHaveTextContent('Wallet extension not found'); - expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); - expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); - }); - - it('shows primary errors while keeping Set as primary available', async () => { - const user = userEvent.setup(); - render( - , - ); - expect(screen.getByRole('alert')).toHaveTextContent('Unable to set primary'); - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeEnabled(); - }); - it('shows an unverified wallet alongside its available provider', () => { render( - - , - ); - return onOpenChange; -} - -describe('Solana wallet picker', () => { - it.each(['Close', 'Escape'])('closes directly with %s when idle', async control => { - const user = userEvent.setup(); - const onOpenChange = renderDialog(); - - if (control === 'Escape') { - await user.keyboard('{Escape}'); - } else { - await user.click(screen.getByRole('button', { name: 'Close' })); - } - - expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); - }); - - it('blocks Close and Escape while the wallet provider is pending', async () => { - const user = userEvent.setup(); - const onOpenChange = renderDialog(true); - - await user.click(screen.getByRole('button', { name: 'Close' })); - await user.keyboard('{Escape}'); - - expect(onOpenChange).not.toHaveBeenCalled(); - }); - - it('shows a direct connection error in the picker', () => { - renderDialog(false, 'Wallet connection failed'); - - expect(screen.getByRole('alert')).toHaveTextContent('Wallet connection failed'); - expect(screen.getByRole('dialog', { name: 'Select a Solana wallet' })).toBeInTheDocument(); - expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); - }); -}); - -it('shows loading only on the selected wallet and disables both choices until it settles', async () => { - const unregister = getWallets().register( - ...['First Solana wallet', 'Second Solana wallet'].map(name => ({ - version: '1.0.0' as const, - name, - icon: 'data:image/svg+xml;base64,' as const, - chains: ['solana:mainnet' as const], - accounts: [], - features: { 'solana:signMessage': {} }, - })), - ); - const user = userEvent.setup(); - const onConnect = vi.fn(); - function Harness() { - const [pending, setPending] = useState(false); - return ( - <> - - { - onConnect(name); - setPending(true); - }} - /> - - ); - } - try { - render( - - - , - ); - const first = screen.getByRole('button', { name: 'First Solana wallet' }); - const second = screen.getByRole('button', { name: 'Second Solana wallet' }); - await user.click(first); - expect(first).toHaveAttribute('aria-busy', 'true'); - expect(second).not.toHaveAttribute('aria-busy', 'true'); - expect(first).toBeDisabled(); - expect(second).toBeDisabled(); - await user.click(second); - expect(onConnect).toHaveBeenCalledExactlyOnceWith('First Solana wallet'); - await user.click(screen.getByText('Finish connection')); - expect(first).not.toHaveAttribute('aria-busy', 'true'); - expect(second).not.toHaveAttribute('aria-busy', 'true'); - expect(first).toBeEnabled(); - expect(second).toBeEnabled(); - await user.click(second); - expect(first).not.toHaveAttribute('aria-busy', 'true'); - expect(second).toHaveAttribute('aria-busy', 'true'); - expect(first).toBeDisabled(); - expect(second).toBeDisabled(); - expect(onConnect).toHaveBeenLastCalledWith('Second Solana wallet'); - } finally { - unregister(); - } -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx index b1f0f51d51d..1d0b4c9872e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx @@ -30,87 +30,4 @@ describe('Web3 wallet controller', () => { }); await waitFor(() => expect(result.current.pendingId).toBeUndefined()); }); - - it('WEB3-07 surfaces connection errors on the provider and allows retry', async () => { - const connect = vi - .fn() - .mockRejectedValueOnce(new Error('Wallet extension missing')) - .mockResolvedValueOnce(undefined); - const { result } = renderHook(() => - useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }), - ); - - await act(async () => result.current.onConnect('web3_metamask_signature')); - expect(result.current.availableProviders[0].connectError).toBe('Wallet extension missing'); - await act(async () => result.current.onConnect('web3_metamask_signature')); - expect(result.current.availableProviders[0].connectError).toBeUndefined(); - }); - - it('WEB3-08 surfaces primary selection errors on the wallet', async () => { - const { result } = renderHook(() => - useUserProfileWeb3WalletsController({ - wallets, - availableProviders, - connect: vi.fn(), - setPrimary: vi.fn().mockRejectedValue(new Error('Primary update failed')), - }), - ); - - await act(async () => result.current.onSetPrimary('wallet_1')); - expect(result.current.wallets[0].primaryError).toBe('Primary update failed'); - }); - - it('WEB3-09 opens the Solana picker before connecting', () => { - const connect = vi.fn(); - const { result } = renderHook(() => - useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }), - ); - act(() => void result.current.onConnect('web3_solana_signature')); - expect(result.current.solanaPickerOpen).toBe(true); - expect(connect).not.toHaveBeenCalled(); - act(() => result.current.closeSolanaPicker()); - expect(result.current.solanaPickerOpen).toBe(false); - }); -}); - -it.each(['connect', 'primary', 'solana'] as const)('shows a rejected %s request and allows retry', async action => { - const request = vi.fn().mockRejectedValueOnce(new Error('Wallet request failed')).mockResolvedValueOnce(undefined); - const { result } = renderHook(() => - useUserProfileWeb3WalletsController({ - wallets, - availableProviders: [...availableProviders, { id: 'web3_solana_signature', provider: 'Solana' }], - connect: request, - setPrimary: request, - }), - ); - const run = () => - action === 'primary' - ? result.current.onSetPrimary('wallet_1') - : action === 'solana' - ? result.current.connectSolana('Phantom') - : result.current.onConnect('web3_metamask_signature'); - if (action === 'solana') { - act(() => void result.current.onConnect('web3_solana_signature')); - } - await act(async () => { - await run(); - }); - expect(result.current.pendingId).toBeUndefined(); - if (action === 'primary') { - expect(result.current.wallets[0].primaryError).toBe('Wallet request failed'); - } else { - const provider = result.current.availableProviders.find( - item => item.id === (action === 'solana' ? 'web3_solana_signature' : 'web3_metamask_signature'), - ); - expect(provider?.connectError).toBe('Wallet request failed'); - } - if (action === 'solana') { - expect(result.current.solanaPickerOpen).toBe(true); - } - await act(async () => { - await run(); - }); - expect(request).toHaveBeenCalledTimes(2); - expect(result.current.wallets[0].primaryError).toBeUndefined(); - expect(result.current.availableProviders.every(provider => provider.connectError === undefined)).toBe(true); }); diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index fd1a0353c67..4c14168ce9d 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -52,6 +52,7 @@ export default defineConfig({ optimizeDeps: { include: [ '@clerk/clerk-js', + '@wallet-standard/core', '@testing-library/jest-dom/matchers', '@testing-library/react', '@testing-library/user-event', From ed6e57731bff83382436641cc3315cce0eea143a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 11:59:48 -0600 Subject: [PATCH 09/10] test(mosaic): replace remaining wallet view tests with feature coverage --- ...file-web3-wallets-section.feature.test.tsx | 55 +++++++++++- ...profile-web3-wallets-section.view.test.tsx | 83 ------------------- 2 files changed, 54 insertions(+), 84 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx index c9bfbd6c62e..e72719ff483 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx @@ -5,7 +5,14 @@ import type { WindowAppReadyEventAPI } from '@wallet-standard/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; -import { fapiClient, fapiEnvironment, fapiSession, fapiUser, fapiWeb3Wallet } from '../../../__tests__/feature/fapi'; +import { + fapiClient, + fapiEnvironment, + fapiSession, + fapiUser, + fapiVerification, + fapiWeb3Wallet, +} from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; @@ -46,6 +53,49 @@ describe('Web3 wallets', () => { expect(screen.getByText('0x1234...cdef')).toBeInTheDocument(); }); + it.each(['verified', 'unverified'] as const)( + 'warns about sign-in loss only when removing a verified admin wallet (%s)', + async status => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ + id: 'admin_wallet', + web3_wallet: address, + verification: fapiVerification({ strategy: 'admin', status }), + }), + ], + }); + const user = userEvent.setup(); + expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: `Manage ${address}` })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + const warning = 'You will no longer be able to sign in using this web3 wallet.'; + if (status === 'verified') { + expect(screen.getByRole('alertdialog')).toHaveTextContent(warning); + } else { + expect(screen.getByRole('alertdialog')).not.toHaveTextContent(warning); + } + }, + ); + + it('keeps an unverified wallet available to connect without offering to make it primary', async () => { + await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ + id: 'wallet_1', + web3_wallet: '0x1234567890abcdef', + verification: fapiVerification({ strategy: 'web3_metamask_signature', status: 'unverified' }), + }), + ], + }); + expect(screen.getByText('Unverified')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Manage MetaMask' })); + expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).toBeNull(); + }); + it('hides existing wallets when Web3 is disabled in the Clerk environment', async () => { const user = fapiUser({ id: 'user_1', @@ -116,6 +166,9 @@ describe('Web3 wallets', () => { const user = userEvent.setup(); await waitFor(() => expect(screen.getAllByRole('button', { name: 'Manage MetaMask' })).toHaveLength(2)); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).toBeNull(); + await user.keyboard('{Escape}'); await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]); await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx deleted file mode 100644 index 42555956ed4..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; - -describe('Web3 wallets section', () => { - it.each([ - { isPrimary: true, isVerified: true, canSetPrimary: false }, - { isPrimary: false, isVerified: true, canSetPrimary: true }, - { isPrimary: false, isVerified: false, canSetPrimary: false }, - ])( - 'offers only applicable legacy wallet actions ($isPrimary, $isVerified)', - async ({ isPrimary, isVerified, canSetPrimary }) => { - const user = userEvent.setup(); - render( - , - ); - expect(screen.getByText('MetaMask')).toBeVisible(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeVisible(); - expect(screen.getAllByRole('menuitem')).toHaveLength(canSetPrimary ? 2 : 1); - if (canSetPrimary) { - expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeVisible(); - } else { - expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); - } - }, - ); - - it.each([true, false])( - 'renders an admin wallet and limits the removal warning to verified wallets (%s)', - async isVerified => { - const user = userEvent.setup(); - const address = '0x1234567890abcdef1234567890abcdef12345678'; - render( - , - ); - expect(screen.getByText('0x1234...5678')).toBeVisible(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: `Manage ${address}` })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - const dialog = screen.getByRole('alertdialog'); - const warning = 'You will no longer be able to sign in using this web3 wallet.'; - if (isVerified) { - expect(dialog).toHaveTextContent(warning); - } else { - expect(dialog).not.toHaveTextContent(warning); - } - }, - ); - - it('shows an unverified wallet alongside its available provider', () => { - render( - , - ); - expect(screen.getByText('Unverified')).toBeVisible(); - expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeVisible(); - }); -}); From bf6258973e667d6c5885cc7de13568da6afa86cc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 17:08:02 -0600 Subject: [PATCH 10/10] fix(mosaic): localize Web3 wallet action errors --- ...file-web3-wallets-section.feature.test.tsx | 20 ++++++++++++++ ...e-web3-wallets-section.controller.test.tsx | 26 ++++++++++++++++++- ...profile-web3-wallets-section.controller.ts | 5 ++-- .../user-profile-web3-wallets-section.tsx | 25 ++++++++++-------- .../user-profile-web3-wallets.messages.ts | 8 ++++++ 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx index e72719ff483..a951c3612f2 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx @@ -14,6 +14,7 @@ import { fapiWeb3Wallet, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; describe('Web3 wallets', () => { @@ -129,6 +130,25 @@ describe('Web3 wallets', () => { expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); }); + it('shows a localized fallback when the wallet provider rejects without a message', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error(''))) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + + + , + ); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connexion impossible.')); + }); + it('holds one provider pending and allows a manual retry after an API error', async () => { vi.stubGlobal('ethereum', { request: vi.fn(({ method }: { method: string }) => diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx index 1d0b4c9872e..f87a5c06f00 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx @@ -12,7 +12,13 @@ describe('Web3 wallet controller', () => { const pending = createDeferredPromise(); const connect = vi.fn(() => pending.promise); const { result } = renderHook(() => - useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }), + useUserProfileWeb3WalletsController({ + wallets, + availableProviders, + connect, + setPrimary: vi.fn(), + fallbackErrorMessage: 'Something went wrong. Please try again.', + }), ); act(() => { @@ -30,4 +36,22 @@ describe('Web3 wallet controller', () => { }); await waitFor(() => expect(result.current.pendingId).toBeUndefined()); }); + + it('uses the localized fallback when a wallet action rejects without a message', async () => { + const { result } = renderHook(() => + useUserProfileWeb3WalletsController({ + wallets: [], + availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask' }], + connect: () => Promise.reject(new Error('')), + setPrimary: () => Promise.resolve(), + fallbackErrorMessage: 'Impossible de connecter le portefeuille.', + }), + ); + + await act(async () => { + await result.current.onConnect('web3_metamask_signature'); + }); + + expect(result.current.availableProviders[0]?.connectError).toBe('Impossible de connecter le portefeuille.'); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts index 9e0ba769130..07ef1667d04 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts @@ -7,11 +7,13 @@ export function useUserProfileWeb3WalletsController({ availableProviders, connect, setPrimary, + fallbackErrorMessage, }: { wallets: UserProfileWeb3Wallet[]; availableProviders: UserProfileWeb3Provider[]; connect: (strategy: string, walletName?: string) => Promise; setPrimary: (walletId: string) => Promise; + fallbackErrorMessage: string; }) { const [pendingId, setPendingId] = useState(); const [connectErrors, setConnectErrors] = useState>({}); @@ -34,8 +36,7 @@ export function useUserProfileWeb3WalletsController({ await action(); return true; } catch (error) { - const message = - error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'; + const message = error instanceof Error && error.message ? error.message : fallbackErrorMessage; setErrors(current => ({ ...current, [id]: message })); return false; } finally { diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx index 928ff8b2c7a..4207ba46508 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -5,6 +5,7 @@ import { WEB3_PROVIDERS } from '@clerk/shared/web3'; import type { ReactNode } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import { useMessages } from '../../../localization'; import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; @@ -16,15 +17,16 @@ export interface UserProfileWeb3WalletsSectionProps { fallbackFocus?: () => HTMLElement | null; } -function actionError(error: unknown): unknown { +function actionError(error: unknown, fallbackMessage: string): Error { if (isClerkAPIResponseError(error)) { const first = error.errors[0]; - return new Error(first?.longMessage || first?.message || 'Something went wrong. Please try again.'); + return new Error(first?.longMessage || first?.message || fallbackMessage); } - return new Error(error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'); + return new Error(error instanceof Error && error.message ? error.message : fallbackMessage); } export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserProfileWeb3WalletsSectionProps) { + const m = useMessages('userProfileWeb3Wallets'); const clerk = useClerk(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); @@ -48,23 +50,23 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); const manager = clerk.__internal_moduleManager; if (!provider || !manager || !user) { - throw new Error('This wallet provider is unavailable.'); + throw new Error(m.errors.providerUnavailable); } try { const web3 = createWeb3(manager); const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); if (!identifier) { - throw new Error('A Web3 Wallet extension cannot be found. Please install one to continue.'); + throw new Error(m.errors.extensionUnavailable); } const wallet = await user.createWeb3Wallet({ web3Wallet: identifier }); if (!wallet) { - throw new Error('The wallet could not be created.'); + throw new Error(m.errors.creationFailed); } const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); const nonce = prepared.verification.message; if (!nonce) { - throw new Error('The wallet verification message is unavailable.'); + throw new Error(m.errors.messageUnavailable); } const signature = await web3.generateWeb3Signature({ identifier, @@ -73,11 +75,11 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP walletName, }); if (!signature) { - throw new Error('The wallet signature is unavailable.'); + throw new Error(m.errors.signatureUnavailable); } await prepared.attemptVerification({ signature }); } catch (error) { - throw actionError(error); + throw actionError(error, m.errors.generic); } }; @@ -88,7 +90,7 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP try { await user.update({ primaryWeb3WalletId: walletId }); } catch (error) { - throw actionError(error); + throw actionError(error, m.errors.generic); } }; @@ -96,7 +98,7 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP try { await user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(); } catch (error) { - throw actionError(error); + throw actionError(error, m.errors.generic); } }; @@ -105,6 +107,7 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP availableProviders: projection.status === 'ready' ? projection.availableProviders : [], connect, setPrimary, + fallbackErrorMessage: m.errors.generic, }); if (!isLoaded || !environment) { diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts index 4c2bbc2339d..88eb67ff032 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts @@ -7,6 +7,14 @@ export const userProfileWeb3WalletsMessages = { setPrimary: 'Set as primary', primary: 'Primary', unverified: 'Unverified', + errors: { + generic: 'Something went wrong. Please try again.', + providerUnavailable: 'This wallet provider is unavailable.', + extensionUnavailable: 'A Web3 Wallet extension cannot be found. Please install one to continue.', + creationFailed: 'The wallet could not be created.', + messageUnavailable: 'The wallet verification message is unavailable.', + signatureUnavailable: 'The wallet signature is unavailable.', + }, solanaDialog: { title: 'Select a Solana wallet', description: 'Choose an installed wallet to connect to your account.',