From e611a655fa5a9a037405d0f3c411877c257efa86 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 13:00:51 -0600 Subject: [PATCH 01/86] feat(mosaic): wire up user profile Web3 wallets --- .changeset/web3-wallets-wire-up.md | 2 + packages/mosaic/package.json | 3 +- .../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 + .../app/(clerk)/live/web3-wallets/page.tsx | 36 ++++ packages/swingset/src/lib/live-navigation.ts | 1 + pnpm-lock.yaml | 3 + 14 files changed, 689 insertions(+), 5 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 0540b25f38b..365fcf73545 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-web3-wallet-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx index 933a4dd5c02..6dd0cf5f09d 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'; @@ -15,12 +15,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; @@ -32,7 +36,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) { @@ -85,10 +89,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 6ed2cb678ee..9ce86c69313 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 ( <> @@ -75,6 +78,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} /> @@ -84,6 +88,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/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/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index f58a02ff76d..cff17ea7102 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -18,6 +18,7 @@ const liveRoutes = new Map([ ['/user-profile/user-profile-danger-section', '/live/user-danger'], ['/organization-profile/organization-profile-danger-section', '/live/organization-danger'], ['/user-profile/user-profile-passkeys-section', '/live/passkeys'], + ['/user-profile/user-profile-web3-wallets-section', '/live/web3-wallets'], ['/reverification/reverification', '/live/reverification'], ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4904ce74827..5f7d51c9f87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,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 e1246d10db8dc8e8b705439d9d67419cc2bf8ec0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:14:15 -0600 Subject: [PATCH 02/86] 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 9ce86c69313..818d4ed75d6 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/use-list-removal-focus'; import { fill, useMessages } from '../../localization'; import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible'; +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); @@ -101,6 +104,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 a82fbec4dd8fa18b716a7ef413203151c813ef9d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:23:34 -0600 Subject: [PATCH 03/86] 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 0affb58ae37a0728c8e23befc7b7b45dd5aa6da2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:24:39 -0600 Subject: [PATCH 04/86] 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 5aead1905c3fa6ec2ad33dcc21bb9af640a8fe66 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:55:14 -0600 Subject: [PATCH 05/86] 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 ff3406fece025ae84b850b3ecc519eff3437f435 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:24:59 -0600 Subject: [PATCH 06/86] 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 cd80524d7f883770f4117c972e4c8f1e37ff0e2c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:46:30 -0600 Subject: [PATCH 07/86] 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 5014b8dbc90..9ce86c69313 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/use-list-removal-focus'; import { fill, useMessages } from '../../localization'; import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible'; -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); @@ -103,9 +99,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 9111953892119d79bb7a6262d94e342585071655 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 11:25:11 -0600 Subject: [PATCH 08/86] test(mosaic): cover wallet behavior through real Clerk --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 117 +++++++ packages/mosaic/src/__tests__/feature/fapi.ts | 30 +- .../user-profile-web3-actions.test.tsx | 124 -------- ...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, 440 insertions(+), 505 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 d6f0cb70c51..cc4c2829394 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -9,6 +9,7 @@ import type { SessionJSON, UserJSON, UserOrganizationInvitationJSON, + Web3WalletJSON, } from '@clerk/shared/types'; import { http, HttpResponse } from 'msw'; import { setupWorker } from 'msw/browser'; @@ -33,6 +34,7 @@ import { fapiPage, fapiToken, fapiVerification, + fapiWeb3Wallet, } from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; @@ -121,6 +123,24 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): 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 }); +} + export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const { verification, enterpriseLinking, passkeys, ...rest } = seed; const state: FakeFapiState = { @@ -221,6 +241,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 0c0744ea63d..65d097460a5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -26,11 +26,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; @@ -354,6 +363,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 fapiSession(overrides: Partial & Pick): SessionJSON { const { user } = overrides; const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); 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 cbc866af4ee..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ /dev/null @@ -1,124 +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 { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -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(clerkApiError('web3_wallet_in_use', '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 d5b2003a5f0c425b9d03c3e784fe356441d1cfe1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 11:59:48 -0600 Subject: [PATCH 09/86] 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 e2c08dcf88718fd26a850bd652999e3a4d6479da Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 17:08:02 -0600 Subject: [PATCH 10/86] 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.', From 3ba219c75f22d0cff6ef174ee7ff49ba895ef5f1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 01:05:09 -0600 Subject: [PATCH 11/86] chore(mosaic): note Web3 error localization follow-up --- .../user-profile-web3-wallets-section.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 4207ba46508..861af9872ea 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 @@ -17,6 +17,8 @@ export interface UserProfileWeb3WalletsSectionProps { fallbackFocus?: () => HTMLElement | null; } +// TODO: Adopt the shared localized error handling from #9844 once it lands on main, +// preserving Clerk error codes for wallet-signature failures. function actionError(error: unknown, fallbackMessage: string): Error { if (isClerkAPIResponseError(error)) { const first = error.errors[0]; From 34137cc5a1bf9d3e4ebadae69516ca8b4d6d3f1c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 01:31:28 -0600 Subject: [PATCH 12/86] chore(mosaic): mark session reverification as deferred --- .../user-profile-web3-wallets-section.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 861af9872ea..51886bb85cc 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 @@ -48,6 +48,8 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP }) : ({ status: 'hidden' } as const); + // TODO: Add session reverification for wallet connection, primary updates, and removal; + // surface API errors until then. const connect = async (strategy: string, walletName?: string) => { const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); const manager = clerk.__internal_moduleManager; From f3e1520854773b98402fe1bd0a0c054c01962c76 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 12:27:17 -0600 Subject: [PATCH 13/86] test(mosaic): align Web3 verification fixtures with main --- .../user-profile-web3-wallets-section.feature.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 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 a951c3612f2..56bddef79fd 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 @@ -63,7 +63,7 @@ describe('Web3 wallets', () => { fapiWeb3Wallet({ id: 'admin_wallet', web3_wallet: address, - verification: fapiVerification({ strategy: 'admin', status }), + verification: fapiVerification('admin', { status }), }), ], }); @@ -86,7 +86,7 @@ describe('Web3 wallets', () => { fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef', - verification: fapiVerification({ strategy: 'web3_metamask_signature', status: 'unverified' }), + verification: fapiVerification('web3_metamask_signature', { status: 'unverified' }), }), ], }); From 52a428bd04c871d0843743d3485507de36841101 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 12:52:10 -0600 Subject: [PATCH 14/86] test(mosaic): record shared Solana follow-up --- .../user-profile-web3-wallets-section.feature.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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 56bddef79fd..4487435b0a2 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 @@ -363,4 +363,13 @@ describe('Web3 wallets', () => { unregister.forEach(remove => remove()); } }); + + // TODO(B24): Extract isSolanaSignInWallet and useInstalledSolanaWallets from PR #9994 into @clerk/shared, + // then use them in legacy UI and Mosaic: https://github.com/clerk/javascript/pull/9994. + // Require a solana: chain, standard:connect, and solana:signMessage. + // Preserve server-safe initial state, registry refresh, registration/unregistration subscriptions, + // subscription cleanup, and name/icon projection. Keep @wallet-standard/core dynamically imported in shared. + // Cover missing-feature combinations and registry registration/unregistration; retain this marker until it lands. + // Agreed follow-up: https://clerkinc.slack.com/archives/C064QJ37LUC/p1790779098886039. + it.todo('uses shared Solana sign-in eligibility and installed-wallet subscriptions in legacy UI and Mosaic'); }); From b22fef3851e9e2e3417c9153d0441b12a044f76c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 13:01:01 -0600 Subject: [PATCH 15/86] fix(mosaic): align Web3 imports with main --- .../user-profile-web3-wallets-section.feature.test.tsx | 2 +- .../user-profile-web3-wallets-section.tsx | 2 +- packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx | 2 +- 3 files changed, 3 insertions(+), 3 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 4487435b0a2..06a71013b91 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,7 +14,7 @@ import { fapiWeb3Wallet, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; -import { MosaicProvider } from '../../../MosaicProvider'; +import { MosaicProvider } from '../../../mosaic-provider'; import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; describe('Web3 wallets', () => { 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 51886bb85cc..a6b3acfccc1 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 @@ -4,7 +4,7 @@ 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 { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; 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'; diff --git a/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx index 9aac58176d0..d268f2082fc 100644 --- a/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx @@ -1,7 +1,7 @@ '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 { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; From ea60ec41087e8a32a0b3ecb2a24bcbbd48f815ff Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 15:28:44 -0600 Subject: [PATCH 16/86] chore(mosaic): keep one empty Web3 changeset --- .changeset/tidy-wallet-visibility.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/tidy-wallet-visibility.md diff --git a/.changeset/tidy-wallet-visibility.md b/.changeset/tidy-wallet-visibility.md deleted file mode 100644 index aef8bbcb46d..00000000000 --- a/.changeset/tidy-wallet-visibility.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/mosaic': patch ---- - -Hide the Web3 wallet section when Web3 wallets are disabled in the instance settings. From 2e145bba741fd844b5d8c044946163ce205e2a1c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 17:01:37 -0600 Subject: [PATCH 17/86] test(mosaic): cover unrelated fake FAPI user updates --- .../feature/fake-fapi-user.feature.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx b/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx new file mode 100644 index 00000000000..ea046f5df0b --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { fapiUrl, serveFapi } from './fake-fapi'; +import { fapiClient, fapiSession, fapiUser, fapiWeb3Wallet } from './fapi'; + +describe('fake FAPI user updates', () => { + it('updates profile fields without clearing omitted identification fields', async () => { + const user = fapiUser({ + id: 'user_1', + first_name: 'Austin', + last_name: 'Original', + username: 'austin', + primary_web3_wallet_id: 'wallet_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234' })], + }); + const fapi = serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + + const response = await fetch(`${fapiUrl('/v1/me')}?_method=PATCH`, { + method: 'POST', + body: new URLSearchParams({ first_name: 'Updated', last_name: 'Name', username: 'updated' }), + }); + + expect(response.ok).toBe(true); + expect(fapi.client.sessions[0]?.user).toMatchObject({ + first_name: 'Updated', + last_name: 'Name', + username: 'updated', + primary_web3_wallet_id: 'wallet_1', + web3_wallets: user.web3_wallets, + }); + }); +}); From 257791ca8e8027accb9efa158b0cface5301cfbe Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 17:02:15 -0600 Subject: [PATCH 18/86] fix(mosaic): address Web3 wallet review feedback --- .changeset/web3-wallets-wire-up.md | 4 + .../mosaic/src/__tests__/feature/fake-fapi.ts | 46 +++-- packages/mosaic/src/__tests__/feature/fapi.ts | 8 +- .../action-menu/action-menu.test.tsx | 31 ++++ .../components/action-menu/action-menu.tsx | 5 +- ...file-web3-wallets-section.feature.test.tsx | 159 ++++++++++++++++-- .../user-profile-web3-wallet-row.view.tsx | 3 +- ...user-profile-web3-wallets-section.view.tsx | 4 +- .../user-profile-solana-wallet.dialog.tsx | 40 +---- .../user-profile-web3-wallets-feedback.ts | 23 +++ ...e-web3-wallets-section.controller.test.tsx | 60 ++++++- ...profile-web3-wallets-section.controller.ts | 101 +++++------ ...profile-web3-wallets-section.model.test.ts | 37 ++-- ...user-profile-web3-wallets-section.model.ts | 151 +++++++++++++---- .../user-profile-web3-wallets-section.tsx | 136 +++------------ ...user-profile-web3-wallets-section.types.ts | 34 ++++ .../use-pending-action-by-id.test.tsx | 47 ++++++ .../src/hooks/use-pending-action-by-id.ts | 34 ++++ packages/shared/src/__tests__/web3.spec.ts | 15 ++ .../useInstalledSolanaWallets.spec.tsx | 102 +++++++++++ packages/shared/src/react/hooks/index.ts | 2 + .../react/hooks/useInstalledSolanaWallets.ts | 44 +++++ ...tIdentificationBasedOnVerification.test.ts | 35 ++++ packages/shared/src/utils/index.ts | 1 + .../sortIdentificationBasedOnVerification.ts | 30 ++++ packages/shared/src/web3.ts | 11 ++ .../components/UserProfile/EmailsSection.tsx | 2 +- .../components/UserProfile/PhoneSection.tsx | 2 +- .../components/UserProfile/Web3Section.tsx | 2 +- .../UserProfile/__tests__/utils.test.ts | 62 +++---- .../ui/src/components/UserProfile/utils.ts | 30 +--- .../src/elements/Web3SolanaWalletButtons.tsx | 34 +--- .../Web3SolanaWalletButtons.test.tsx | 79 +++++---- 33 files changed, 943 insertions(+), 431 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts create mode 100644 packages/mosaic/src/hooks/__tests__/use-pending-action-by-id.test.tsx create mode 100644 packages/mosaic/src/hooks/use-pending-action-by-id.ts create mode 100644 packages/shared/src/__tests__/web3.spec.ts create mode 100644 packages/shared/src/react/hooks/__tests__/useInstalledSolanaWallets.spec.tsx create mode 100644 packages/shared/src/react/hooks/useInstalledSolanaWallets.ts create mode 100644 packages/shared/src/utils/__tests__/sortIdentificationBasedOnVerification.test.ts create mode 100644 packages/shared/src/utils/sortIdentificationBasedOnVerification.ts diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md index a845151cc84..3bb43eba3a9 100644 --- a/.changeset/web3-wallets-wire-up.md +++ b/.changeset/web3-wallets-wire-up.md @@ -1,2 +1,6 @@ --- +'@clerk/mosaic': patch +'@clerk/shared': patch --- + +Keep Web3 wallet action state scoped to the signed-in user and use localized API errors. Share Solana wallet discovery so only wallets supporting connection and message signing are offered. diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index cc4c2829394..54337aeb37e 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -254,16 +254,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { 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: '' }, - }, + verification: fapiVerification('', { expire_at: 0 }), }); updateUser(state, { ...user, web3_wallets: [...user.web3_wallets, wallet] }); return envelope(wallet, state.client); @@ -276,17 +267,10 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { 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') ?? '', + verification: fapiVerification(body.get('strategy') ?? '', { message: 'Sign this wallet challenge', - attempts: 0, expire_at: 0, - error: { code: '', message: '' }, - }, + }), }); updateWeb3Wallet(state, prepared); return envelope(prepared, state.client); @@ -298,17 +282,12 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { } const verified = fapiWeb3Wallet({ ...wallet, - verification: { - id: 'verification_1', - object: 'verification', + verification: fapiVerification(wallet.verification?.strategy ?? '', { ...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); @@ -334,7 +313,20 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { return missing(); } const body = new URLSearchParams(await request.text()); - const updated = { ...user, primary_web3_wallet_id: body.get('primary_web3_wallet_id') }; + const updated = { ...user }; + const fields = [ + 'first_name', + 'last_name', + 'username', + 'primary_email_address_id', + 'primary_phone_number_id', + 'primary_web3_wallet_id', + ] as const; + for (const field of fields) { + if (body.has(field)) { + updated[field] = body.get(field); + } + } updateUser(state, updated); return envelope(updated, state.client); }), diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 65d097460a5..9c562790ce5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -368,16 +368,12 @@ export function fapiWeb3Wallet( ): Web3WalletJSON { return { object: 'web3_wallet', - verification: { - id: 'verification_1', - object: 'verification', + verification: fapiVerification('web3_metamask_signature', { status: 'verified', verified_at_client: '', - strategy: 'web3_metamask_signature', attempts: 1, expire_at: 0, - error: { code: '', message: '' }, - }, + }), ...overrides, }; } diff --git a/packages/mosaic/src/components/action-menu/action-menu.test.tsx b/packages/mosaic/src/components/action-menu/action-menu.test.tsx index f30098d6b3a..8786eee5e68 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.test.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.test.tsx @@ -36,4 +36,35 @@ describe('Mosaic ActionMenu', () => { expect(onClick).toHaveBeenCalledOnce(); }); + it('disables both the trigger and existing menu items while busy', async () => { + const onClick = vi.fn(); + const actions = [{ label: 'Remove', onClick }]; + const view = render( + + + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage wallet' })); + expect(await screen.findByRole('menuitem', { name: 'Remove' })).toBeInTheDocument(); + + view.rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Manage wallet' })).toBeDisabled(); + const remove = screen.getByRole('menuitem', { name: 'Remove' }); + expect(remove).toHaveAttribute('aria-disabled', 'true'); + await user.click(remove); + expect(onClick).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mosaic/src/components/action-menu/action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx index 480c4655625..2cd36e9fa0b 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -15,9 +15,10 @@ export interface ActionMenuProps { label: string; actions: ActionMenuAction[]; triggerRef?: Ref; + disabled?: boolean; } -export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { +export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuProps) { if (actions.length === 0) { return null; } @@ -27,6 +28,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { {actions.map(action => ( @@ -35,6 +37,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { color={action.color} label={action.label} onClick={action.onClick} + disabled={disabled} > {action.icon ? ( 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 06a71013b91..a556f4909bc 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 @@ -1,5 +1,5 @@ import type { UserJSON } from '@clerk/shared/types'; -import { screen, waitFor, within } from '@testing-library/react'; +import { act, 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'; @@ -149,6 +149,91 @@ describe('Web3 wallets', () => { await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connexion impossible.')); }); + it('uses the canonical API-code translation for a wallet connection failure', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.resolve(['0x1234567890abcdef'])) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + + + , + ); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.fail('verification_invalid_strategy', 'Server copy'); + + expect(await screen.findByRole('alert')).toHaveTextContent('Ce portefeuille est indisponible.'); + expect(screen.queryByText('Server copy')).toBeNull(); + }); + + it.each(['switch', 'sign out'] as const)( + 'aborts wallet creation after %s during the provider prompt', + async change => { + const accountRequest = Promise.withResolvers(); + const request = vi.fn(() => accountRequest.promise); + vi.stubGlobal('ethereum', { request }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const original = clerk.user; + if (!original) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(original, 'createWeb3Wallet'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(request).toHaveBeenCalledOnce()); + await act(() => (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut())); + expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined); + if (change === 'switch') { + expect.soft(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + } + + await act(async () => { + accountRequest.resolve(['0x1234567890abcdef']); + await accountRequest.promise; + }); + + expect(create).not.toHaveBeenCalled(); + if (change === 'switch') { + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + expect(screen.queryByRole('alert')).toBeNull(); + } + }, + ); + + it('clears the previous user’s provider error and open wallet picker on session change', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error('First user wallet failure'))) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); + + await act(() => clerk.setActive({ session: 'sess_2' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + }); + it('holds one provider pending and allows a manual retry after an API error', async () => { vi.stubGlobal('ethereum', { request: vi.fn(({ method }: { method: string }) => @@ -210,6 +295,7 @@ describe('Web3 wallets', () => { 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)); + expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeDisabled(); hold.fail('primary_update_failed'); expect(await screen.findByRole('alert')).toHaveTextContent('primary_update_failed'); @@ -285,6 +371,64 @@ describe('Web3 wallets', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); }); + it('shows only eligible Solana wallets and refreshes when wallets register or unregister', async () => { + const wallet = { + version: '1.0.0' as const, + name: 'Eligible Solana', + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [], + features: { + 'standard:connect': { version: '1.0.0' as const, connect: () => Promise.resolve({ accounts: [] }) }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage: () => Promise.resolve([]) }, + }, + }; + const registries: WindowAppReadyEventAPI[] = []; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => { + registries.push(api); + unregister.push( + api.register( + { + ...wallet, + name: 'Cannot connect', + features: { 'solana:signMessage': wallet.features['solana:signMessage'] }, + }, + { ...wallet, name: 'Cannot sign', features: { 'standard:connect': wallet.features['standard:connect'] } }, + { ...wallet, name: 'Wrong chain', chains: ['ethereum:mainnet'] }, + ), + ); + }; + 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 })); + let unregisterEligible: Array<() => void> = []; + try { + await renderWeb3(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(screen.queryByRole('button', { name: 'Cannot connect' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Cannot sign' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Wrong chain' })).toBeNull(); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + + act(() => { + unregisterEligible = registries.map(api => api.register(wallet)); + }); + expect(await screen.findByRole('button', { name: 'Eligible Solana' })).toBeEnabled(); + act(() => unregisterEligible.forEach(remove => remove())); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Eligible Solana' })).toBeNull()); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + unregisterEligible.forEach(remove => remove()); + } + }); + it('connects the chosen Solana wallet and verifies it through Clerk', async () => { const account = { address: 'SolanaAddress123', @@ -341,11 +485,13 @@ describe('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: 'Second Solana' })).toHaveFocus(); 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'); + expect(screen.getAllByRole('alert', { hidden: true })).toHaveLength(1); const retryFapi = serveFapi(fapi); await user.click(screen.getByRole('button', { name: 'Second Solana' })); await waitFor(() => @@ -363,13 +509,6 @@ describe('Web3 wallets', () => { unregister.forEach(remove => remove()); } }); - - // TODO(B24): Extract isSolanaSignInWallet and useInstalledSolanaWallets from PR #9994 into @clerk/shared, - // then use them in legacy UI and Mosaic: https://github.com/clerk/javascript/pull/9994. - // Require a solana: chain, standard:connect, and solana:signMessage. - // Preserve server-safe initial state, registry refresh, registration/unregistration subscriptions, - // subscription cleanup, and name/icon projection. Keep @wallet-standard/core dynamically imported in shared. - // Cover missing-feature combinations and registry registration/unregistration; retain this marker until it lands. - // Agreed follow-up: https://clerkinc.slack.com/archives/C064QJ37LUC/p1790779098886039. - it.todo('uses shared Solana sign-in eligibility and installed-wallet subscriptions in legacy UI and Mosaic'); + // TODO: Add session reverification for wallet connection, primary updates, and removal; + // surface API errors until then. }); 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 6dd0cf5f09d..66adbffbe91 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 @@ -36,7 +36,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 && !isDisabled) { + if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) }); } if (linkedWallet && onRemove && linkedWallet.canRemove !== false) { @@ -111,6 +111,7 @@ export function UserProfileWeb3WalletRowView({
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 9ce86c69313..cf7905b5721 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 @@ -1,3 +1,4 @@ +import type { Web3Strategy } from '@clerk/shared/types'; import { useMemo, useRef } from 'react'; import { Confirmation } from '../../blocks/confirmation'; @@ -8,7 +9,8 @@ import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visib import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; export interface UserProfileWeb3Provider { - id: string; + id: Web3Strategy; + walletPicker?: 'solana'; provider: string; iconUrl?: string; connectError?: string; 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 24ebc321eb5..86e30a94e66 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,5 +1,4 @@ -import { getWallets } from '@wallet-standard/core'; -import { useEffect, useState } from 'react'; +import { useInstalledSolanaWallets } from '@clerk/shared/react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -7,42 +6,22 @@ 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, + pendingWalletName, error, onOpenChange, onConnect, }: { open: boolean; - pending: boolean; + pendingWalletName?: string; error?: string; onOpenChange: (open: boolean) => void; onConnect: (walletName: string) => void; }) { const m = useMessages('userProfileWeb3Wallets'); - const [wallets, setWallets] = useState(installedSolanaWallets); - const [selectedWalletName, setSelectedWalletName] = useState(); - - useEffect(() => { - const registry = getWallets(); - const update = () => setWallets(installedSolanaWallets()); - const unregister = registry.on('register', update); - const unadvertise = registry.on('unregister', update); - update(); - return () => { - unregister(); - unadvertise(); - }; - }, []); + const wallets = useInstalledSolanaWallets(); + const pending = pendingWalletName !== undefined; return ( { - setSelectedWalletName(wallet.name); - onConnect(wallet.name); - }} + isPending={pendingWalletName === wallet.name} + disabled={pending && pendingWalletName !== wallet.name} + onClick={() => onConnect(wallet.name)} > {wallet.icon ? ( string, +): Error { + if (error instanceof Web3WalletActionError) { + return new Web3WalletActionError(error.code, messages.errors[error.code], { cause: error }); + } + let message: string = messages.errors.generic; + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + if (first) { + message = errorText(toLocalizableApiError(first, messages.errors.generic)); + } + } + return new Error(message, { cause: error }); +} 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 f87a5c06f00..18769ad5090 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 @@ -2,10 +2,12 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import { MosaicProvider } from '../../../mosaic-provider'; +import type { UserProfileWeb3Provider } from '../user-profile-web3-wallets-section.view'; 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' }]; +const availableProviders: UserProfileWeb3Provider[] = [{ id: 'web3_metamask_signature', provider: 'MetaMask' }]; describe('Web3 wallet controller', () => { it('WEB3-06 holds a pending connection and blocks another attempt', async () => { @@ -17,7 +19,6 @@ describe('Web3 wallet controller', () => { availableProviders, connect, setPrimary: vi.fn(), - fallbackErrorMessage: 'Something went wrong. Please try again.', }), ); @@ -38,20 +39,61 @@ describe('Web3 wallet controller', () => { }); 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(), + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + await act(async () => { + await result.current.onConnect('web3_metamask_signature'); + }); + + expect(result.current.availableProviders[0]?.connectError).toBe('Impossible de connecter le portefeuille.'); + }); + it('keeps the accepted Solana wallet as the pending owner through repeated calls', async () => { + const pending = Promise.withResolvers(); + const connect = vi.fn(() => pending.promise); const { result } = renderHook(() => useUserProfileWeb3WalletsController({ wallets: [], - availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask' }], - connect: () => Promise.reject(new Error('')), + availableProviders: [{ id: 'web3_solana_signature', provider: 'Solana', walletPicker: 'solana' }], + connect, setPrimary: () => Promise.resolve(), - fallbackErrorMessage: 'Impossible de connecter le portefeuille.', }), ); - + act(() => { + void result.current.onConnect('web3_solana_signature'); + }); + act(() => { + void result.current.connectSolana('First wallet'); + void result.current.connectSolana('Second wallet'); + result.current.closeSolanaPicker(); + }); + expect(result.current.pendingWalletName).toBe('First wallet'); + expect(result.current.solanaPickerOpen).toBe(true); + expect(connect).toHaveBeenCalledOnce(); + expect(connect).toHaveBeenCalledWith('web3_solana_signature', 'First wallet'); await act(async () => { - await result.current.onConnect('web3_metamask_signature'); + pending.resolve(); + await pending.promise; }); - - expect(result.current.availableProviders[0]?.connectError).toBe('Impossible de connecter le portefeuille.'); + expect(result.current.pendingWalletName).toBeUndefined(); + 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 index 07ef1667d04..c0100346d73 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,84 +1,71 @@ -import { useRef, useState } from 'react'; +import { useState } from 'react'; -import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; +import { usePendingActionById } from '../../../hooks/use-pending-action-by-id'; +import { useMessages } from '../../../localization'; +import type { UserProfileWeb3Provider } from '../user-profile-web3-wallets-section.view'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; export function useUserProfileWeb3WalletsController({ wallets, 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>({}); - const [primaryErrors, setPrimaryErrors] = useState>({}); - const [solanaPickerOpen, setSolanaPickerOpen] = useState(false); - const inFlight = useRef(false); +}: Pick) { + const messages = useMessages('userProfileWeb3Wallets'); + const action = usePendingActionById(messages.errors.generic); + const [picker, setPicker] = useState(null); + const [pendingWalletName, setPendingWalletName] = useState(); - 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 : fallbackErrorMessage; - setErrors(current => ({ ...current, [id]: message })); - return false; - } finally { - inFlight.current = false; - setPendingId(undefined); + const onConnect = (id: string) => { + if (action.busy()) { + return; } - }; - - const onConnect = (strategy: string) => { - if (inFlight.current) { + const provider = availableProviders.find(candidate => candidate.id === id); + if (!provider) { return; } - if (strategy === 'web3_solana_signature') { - setSolanaPickerOpen(true); + if (provider.walletPicker === 'solana') { + setPicker(provider); return; } - return run(strategy, () => connect(strategy), setConnectErrors); + return action.run(provider.id, () => connect(provider.id)); }; - const connectSolana = async (walletName: string) => { - const succeeded = await run( - 'web3_solana_signature', - () => connect('web3_solana_signature', walletName), - setConnectErrors, - ); - if (succeeded) { - setSolanaPickerOpen(false); + const connectSolana = (walletName: string) => { + if (!picker) { + return; } + return action.run(picker.id, async () => { + setPendingWalletName(walletName); + try { + await connect(picker.id, walletName); + setPicker(null); + } finally { + setPendingWalletName(undefined); + } + }); }; return { wallets: wallets.map(wallet => - primaryErrors[wallet.id] ? { ...wallet, primaryError: primaryErrors[wallet.id] } : wallet, + action.errors[wallet.id] ? { ...wallet, primaryError: action.errors[wallet.id] } : wallet, ), availableProviders: availableProviders.map(provider => - connectErrors[provider.id] ? { ...provider, connectError: connectErrors[provider.id] } : provider, + action.errors[provider.id] && picker?.id !== provider.id + ? { ...provider, connectError: action.errors[provider.id] } + : provider, ), - pendingId, - solanaPickerOpen, + pendingId: action.pendingId, + solanaPickerOpen: picker !== null, + solanaPickerError: picker ? action.errors[picker.id] : undefined, + pendingWalletName, onConnect, - onSetPrimary: (walletId: string) => run(walletId, () => setPrimary(walletId), setPrimaryErrors), + onSetPrimary: (walletId: string) => action.run(walletId, () => setPrimary(walletId)), connectSolana, - closeSolanaPicker: () => setSolanaPickerOpen(false), + closeSolanaPicker: () => { + if (!action.busy()) { + setPicker(null); + } + }, }; } 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 index 45f69dcc766..197f4ef299b 100644 --- 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 @@ -4,10 +4,9 @@ import { projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; const metamask = { id: 'wallet_metamask', - address: '0x1234567890abcdef', - strategy: 'web3_metamask_signature', - status: 'verified', -}; + web3Wallet: '0x1234567890abcdef', + verification: { strategy: 'web3_metamask_signature', status: 'verified', expireAt: null }, +} as const; describe('Web3 wallet projection', () => { it('WEB3-01 shows configured providers that do not have a verified wallet', () => { @@ -26,7 +25,7 @@ describe('Web3 wallet projection', () => { it('WEB3-02 keeps unverified wallets and their provider available', () => { const result = projectWeb3Wallets({ - wallets: [{ ...metamask, status: 'unverified' }], + wallets: [{ ...metamask, verification: { ...metamask.verification, status: 'unverified' } }], primaryId: null, enabledStrategies: ['web3_metamask_signature'], allowCreation: true, @@ -40,8 +39,12 @@ describe('Web3 wallet projection', () => { 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' }, + { id: 'admin', web3Wallet: '0xadmin', verification: { strategy: 'admin', status: 'verified', expireAt: null } }, + { + id: 'unknown', + web3Wallet: '0xunknown', + verification: { strategy: 'unknown', status: 'verified', expireAt: null }, + }, ], primaryId: null, enabledStrategies: [], @@ -56,7 +59,11 @@ describe('Web3 wallet projection', () => { 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: 'unverified', + verification: { ...metamask.verification, status: 'unverified', expireAt: new Date('2026-01-02') }, + }, { ...metamask, id: 'verified_z' }, { ...metamask, id: 'verified_a' }, { ...metamask, id: 'primary' }, @@ -75,9 +82,17 @@ describe('Web3 wallet projection', () => { 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') }, + { ...metamask, id: 'missing', verification: { ...metamask.verification, status: null } }, + { + ...metamask, + id: 'unverified_without_expiry', + verification: { ...metamask.verification, status: 'unverified' }, + }, + { + ...metamask, + id: 'unverified_with_expiry', + verification: { ...metamask.verification, status: 'unverified', expireAt: new Date('2026-01-02') }, + }, ], primaryId: null, enabledStrategies: [], 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 index 857c08540d0..2606bfade32 100644 --- 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 @@ -1,15 +1,21 @@ import { iconImageUrl } from '@clerk/shared/constants'; +import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; +import { useClerk, useUser } from '@clerk/shared/react'; +import type { VerificationResource, Web3WalletResource } from '@clerk/shared/types'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { WEB3_PROVIDERS } from '@clerk/shared/web3'; +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +import { useErrorText, useMessages } from '../../../localization'; +import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; +import { web3WalletFeedback } from './user-profile-web3-wallets-feedback'; +import type { UserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; +import { Web3WalletActionError } from './user-profile-web3-wallets-section.types'; -export interface Web3WalletEntry { - id: string; - address: string; - strategy: string; - status: string | null; - expireAt?: Date | null; -} +export type Web3WalletEntry = Pick & { + verification: Pick; +}; export type Web3WalletsProjection = | { status: 'hidden' } @@ -32,44 +38,27 @@ export function projectWeb3Wallets({ 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), + wallet => + wallet.verification.strategy === 'admin' || + WEB3_PROVIDERS.some(provider => provider.strategy === wallet.verification.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 sorted = sortIdentificationBasedOnVerification(knownWallets, primaryId); const connectedStrategies = new Set( - wallets.filter(wallet => wallet.status === 'verified').map(wallet => wallet.strategy), + wallets.filter(wallet => wallet.verification.status === 'verified').map(wallet => wallet.verification.strategy), ); return { status: 'ready', wallets: sorted.map(wallet => { - const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === wallet.strategy); + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === wallet.verification.strategy); return { id: wallet.id, - address: wallet.address, + address: wallet.web3Wallet, provider: provider?.name, iconUrl: provider ? iconImageUrl(provider.provider) : undefined, isPrimary: wallet.id === primaryId, - isVerified: wallet.status === 'verified', + isVerified: wallet.verification.status === 'verified', }; }), availableProviders: allowCreation @@ -77,9 +66,107 @@ export function projectWeb3Wallets({ .filter(provider => !connectedStrategies.has(provider.strategy)) .map(provider => ({ id: provider.strategy, + walletPicker: provider.provider === 'solana' ? 'solana' : undefined, provider: provider.name, iconUrl: iconImageUrl(provider.provider), })) : [], }; } + +export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const environment = useMosaicEnvironment(); + const messages = useMessages('userProfileWeb3Wallets'); + const errorText = useErrorText(); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + if (!user || !environment.userSettings.attributes.web3_wallet?.enabled) { + return { status: 'hidden' }; + } + const projection = projectWeb3Wallets({ + wallets: user.web3Wallets, + primaryId: user.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), + }); + if (projection.status === 'hidden') { + return projection; + } + const userId = user.id; + const requireCurrentUser = () => { + const current = clerk.user; + if (!current || current.id !== userId) { + throw new Web3WalletActionError('providerUnavailable'); + } + return current; + }; + const runAction = async (action: () => Promise): Promise => { + try { + await action(); + } catch (error) { + throw web3WalletFeedback(error, messages, errorText); + } + }; + + return { + ...projection, + userId, + connect: (strategy, walletName) => + runAction(async () => { + requireCurrentUser(); + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); + const available = projection.availableProviders.some(candidate => candidate.id === strategy); + const manager = clerk.__internal_moduleManager; + if (!provider || !available || !manager) { + throw new Web3WalletActionError('providerUnavailable'); + } + const web3 = createWeb3(manager); + const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); + if (!identifier) { + throw new Web3WalletActionError('extensionUnavailable'); + } + const wallet = await requireCurrentUser().createWeb3Wallet({ web3Wallet: identifier }); + requireCurrentUser(); + if (!wallet) { + throw new Web3WalletActionError('creationFailed'); + } + const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); + requireCurrentUser(); + const nonce = prepared.verification.message; + if (!nonce) { + throw new Web3WalletActionError('messageUnavailable'); + } + const signature = await web3.generateWeb3Signature({ + identifier, + nonce, + provider: provider.provider, + walletName, + }); + requireCurrentUser(); + if (!signature) { + throw new Web3WalletActionError('signatureUnavailable'); + } + await prepared.attemptVerification({ signature }); + }), + setPrimary: walletId => + runAction(async () => { + const current = requireCurrentUser(); + if (!current.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { + throw new Web3WalletActionError('providerUnavailable'); + } + await current.update({ primaryWeb3WalletId: walletId }); + }), + remove: walletId => + runAction(async () => { + const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); + if (!wallet) { + throw new Web3WalletActionError('providerUnavailable'); + } + await wallet.destroy(); + }), + }; +} 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 a6b3acfccc1..72bd555e6ab 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,129 +1,41 @@ -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/use-mosaic-environment'; -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'; import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; -import { projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; +import { useUserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.model'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; export interface UserProfileWeb3WalletsSectionProps { fallback?: ReactNode; fallbackFocus?: () => HTMLElement | null; } -// TODO: Adopt the shared localized error handling from #9844 once it lands on main, -// preserving Clerk error codes for wallet-signature failures. -function actionError(error: unknown, fallbackMessage: string): Error { - if (isClerkAPIResponseError(error)) { - const first = error.errors[0]; - return new Error(first?.longMessage || first?.message || fallbackMessage); - } - 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(); - 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); - - // TODO: Add session reverification for wallet connection, primary updates, and removal; - // surface API errors until then. - 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(m.errors.providerUnavailable); - } - - try { - const web3 = createWeb3(manager); - const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); - if (!identifier) { - throw new Error(m.errors.extensionUnavailable); - } - const wallet = await user.createWeb3Wallet({ web3Wallet: identifier }); - if (!wallet) { - throw new Error(m.errors.creationFailed); - } - const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); - const nonce = prepared.verification.message; - if (!nonce) { - throw new Error(m.errors.messageUnavailable); - } - const signature = await web3.generateWeb3Signature({ - identifier, - nonce, - provider: provider.provider, - walletName, - }); - if (!signature) { - throw new Error(m.errors.signatureUnavailable); - } - await prepared.attemptVerification({ signature }); - } catch (error) { - throw actionError(error, m.errors.generic); - } - }; - - const setPrimary = async (walletId: string) => { - if (!user?.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { - return; - } - try { - await user.update({ primaryWeb3WalletId: walletId }); - } catch (error) { - throw actionError(error, m.errors.generic); - } - }; - - const remove = async (walletId: string) => { - try { - await user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(); - } catch (error) { - throw actionError(error, m.errors.generic); - } - }; - - const controller = useUserProfileWeb3WalletsController({ - wallets: projection.status === 'ready' ? projection.wallets : [], - availableProviders: projection.status === 'ready' ? projection.availableProviders : [], - connect, - setPrimary, - fallbackErrorMessage: m.errors.generic, - }); - - if (!isLoaded || !environment) { + const model = useUserProfileWeb3WalletsModel(); + if (model.status === 'loading') { return fallback ?? null; } - if (!environment.userSettings.attributes.web3_wallet?.enabled) { - return null; - } - if (projection.status === 'hidden') { + if (model.status === 'hidden') { return null; } + return ( + + ); +} +function Web3Wallets({ + model, + fallbackFocus, +}: { + model: ReadyWeb3WalletsModel; + fallbackFocus?: () => HTMLElement | null; +}) { + const controller = useUserProfileWeb3WalletsController(model); return ( <> { void controller.onSetPrimary(id); }} - onRemove={remove} + onRemove={model.remove} /> provider.id === 'web3_solana_signature')?.connectError} + pendingWalletName={controller.pendingWalletName} + error={controller.solanaPickerError} onOpenChange={open => { if (!open) { controller.closeSolanaPicker(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts new file mode 100644 index 00000000000..43d24ffebae --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -0,0 +1,34 @@ +import type { Web3Strategy } from '@clerk/shared/types'; + +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; + +export type UserProfileWeb3WalletsModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + userId: string; + wallets: UserProfileWeb3Wallet[]; + availableProviders: UserProfileWeb3Provider[]; + connect: (strategy: Web3Strategy, walletName?: string) => Promise; + setPrimary: (walletId: string) => Promise; + remove: (walletId: string) => Promise; + }; + +export type ReadyWeb3WalletsModel = Extract; + +export class Web3WalletActionError extends Error { + constructor( + readonly code: + | 'providerUnavailable' + | 'extensionUnavailable' + | 'creationFailed' + | 'messageUnavailable' + | 'signatureUnavailable', + message: string = code, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'Web3WalletActionError'; + } +} diff --git a/packages/mosaic/src/hooks/__tests__/use-pending-action-by-id.test.tsx b/packages/mosaic/src/hooks/__tests__/use-pending-action-by-id.test.tsx new file mode 100644 index 00000000000..154d97a0f52 --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-pending-action-by-id.test.tsx @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { clerkApiError } from '../../__tests__/clerk-errors'; +import { usePendingActionById } from '../use-pending-action-by-id'; + +describe('usePendingActionById', () => { + it('refuses competing actions and clears a failed action on retry', async () => { + const pending = Promise.withResolvers(); + const execute = vi.fn(() => pending.promise); + const competing = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => usePendingActionById('Try again.')); + act(() => { + void result.current.run('first', execute); + void result.current.run('second', competing); + }); + expect(execute).toHaveBeenCalledOnce(); + expect(competing).not.toHaveBeenCalled(); + expect(result.current.pendingId).toBe('first'); + await act(async () => { + pending.reject(clerkApiError('connection_failed', 'Could not connect.')); + await pending.promise.catch(() => undefined); + }); + expect(result.current.pendingId).toBeUndefined(); + expect(result.current.errors.first).toBe('Could not connect.'); + await act(async () => { + await result.current.run('first', () => Promise.resolve()); + }); + expect(result.current.errors.first).toBeUndefined(); + }); + + it('holds the pending action until a redirect grace period completes', async () => { + vi.useFakeTimers(); + try { + const { result } = renderHook(() => usePendingActionById('Try again.')); + act(() => { + void result.current.run('oauth', () => new Promise(resolve => setTimeout(resolve, 2000))); + }); + await act(() => vi.advanceTimersByTimeAsync(1999)); + expect(result.current.pendingId).toBe('oauth'); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(result.current.pendingId).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/mosaic/src/hooks/use-pending-action-by-id.ts b/packages/mosaic/src/hooks/use-pending-action-by-id.ts new file mode 100644 index 00000000000..e410f3acde0 --- /dev/null +++ b/packages/mosaic/src/hooks/use-pending-action-by-id.ts @@ -0,0 +1,34 @@ +import { useRef, useState } from 'react'; + +import type { ErrorDescription } from '../localization'; +import { useErrorText } from '../localization'; +import { toLocalizableError } from '../utils/errors'; + +export function usePendingActionById(fallbackMessage: string) { + const errorText = useErrorText(); + const [pendingId, setPendingId] = useState(); + const [errors, setErrors] = useState>({}); + const inFlight = useRef(false); + + const run = async (id: string, action: () => Promise): Promise => { + if (inFlight.current) { + return false; + } + inFlight.current = true; + setPendingId(id); + setErrors(({ [id]: _previous, ...rest }) => rest); + try { + await action(); + return true; + } catch (error) { + setErrors(current => ({ ...current, [id]: toLocalizableError(error) })); + return false; + } finally { + inFlight.current = false; + setPendingId(undefined); + } + }; + + const messages = Object.fromEntries(Object.entries(errors).map(([id, error]) => [id, errorText(error, fallbackMessage)])); + return { pendingId, errors: messages, busy: () => inFlight.current, run }; +} diff --git a/packages/shared/src/__tests__/web3.spec.ts b/packages/shared/src/__tests__/web3.spec.ts new file mode 100644 index 00000000000..57e56bc8840 --- /dev/null +++ b/packages/shared/src/__tests__/web3.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { isSolanaSignInWallet } from '../web3'; + +describe('isSolanaSignInWallet', () => { + it.each([ + { chains: ['solana:mainnet'], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: true }, + { chains: ['solana:mainnet'], features: { 'solana:signMessage': {} }, eligible: false }, + { chains: ['solana:mainnet'], features: { 'standard:connect': {} }, eligible: false }, + { chains: ['eip155:1'], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: false }, + { chains: [], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: false }, + ] as const)('requires a Solana chain and both sign-in capabilities for $chains and $features', wallet => { + expect(isSolanaSignInWallet(wallet)).toBe(wallet.eligible); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/useInstalledSolanaWallets.spec.tsx b/packages/shared/src/react/hooks/__tests__/useInstalledSolanaWallets.spec.tsx new file mode 100644 index 00000000000..81426fefec1 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useInstalledSolanaWallets.spec.tsx @@ -0,0 +1,102 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { Wallet } from '@wallet-standard/core'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useInstalledSolanaWallets } from '../useInstalledSolanaWallets'; + +const registry = vi.hoisted(() => { + const wallets: Wallet[] = []; + const listeners = { register: new Set<() => void>(), unregister: new Set<() => void>() }; + const getWallets = vi.fn(() => ({ + get: () => wallets, + on: (event: 'register' | 'unregister', listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + })); + return { wallets, listeners, getWallets }; +}); + +vi.mock('@wallet-standard/core', () => ({ getWallets: registry.getWallets })); + +function wallet( + name: string, + features: Wallet['features'] = { 'standard:connect': {}, 'solana:signMessage': {} }, +): Wallet { + return { + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features, + }; +} + +describe('useInstalledSolanaWallets', () => { + beforeEach(() => { + registry.wallets.splice(0); + registry.listeners.register.clear(); + registry.listeners.unregister.clear(); + registry.getWallets.mockClear(); + }); + + it('renders an empty server snapshot without initializing the browser registry', () => { + registry.wallets.push(wallet('Phantom')); + function WalletNames() { + return createElement( + 'div', + null, + useInstalledSolanaWallets() + .map(wallet => wallet.name) + .join(','), + ); + } + + expect(renderToString(createElement(WalletNames))).toBe('
'); + expect(registry.getWallets).not.toHaveBeenCalled(); + }); + + it('starts empty and projects only eligible wallet names and icons after loading the registry', async () => { + registry.wallets.push(wallet('Phantom'), wallet('Signer only', { 'solana:signMessage': {} })); + const { result } = renderHook(useInstalledSolanaWallets); + + expect(result.current).toEqual([]); + await waitFor(() => expect(result.current).toEqual([{ name: 'Phantom', icon: 'data:image/svg+xml;base64,' }])); + }); + + it('refreshes on registration and unregistration and removes both subscriptions on unmount', async () => { + const { result, unmount } = renderHook(useInstalledSolanaWallets); + await waitFor(() => expect(registry.listeners.register.size).toBe(1)); + + act(() => { + registry.wallets.push(wallet('Backpack')); + registry.listeners.register.forEach(listener => listener()); + }); + expect(result.current).toEqual([{ name: 'Backpack', icon: 'data:image/svg+xml;base64,' }]); + + act(() => { + registry.wallets.splice(0); + registry.listeners.unregister.forEach(listener => listener()); + }); + expect(result.current).toEqual([]); + + unmount(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); + + it('does not initialize or subscribe when unmounted before the module loads', async () => { + const { unmount } = renderHook(useInstalledSolanaWallets); + unmount(); + await act(async () => { + await vi.dynamicImportSettled(); + }); + + expect(registry.getWallets).not.toHaveBeenCalled(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); +}); diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index abe5b258c87..c10c40f43a5 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -16,6 +16,8 @@ export { useSafeLayoutEffect } from './useSafeLayoutEffect'; export { useSession } from './useSession'; export { useSessionList } from './useSessionList'; export { useUser } from './useUser'; +export { useInstalledSolanaWallets } from './useInstalledSolanaWallets'; +export type { InstalledSolanaWallet } from './useInstalledSolanaWallets'; export { useClerk } from './useClerk'; export { useDeepEqualMemo, isDeeplyEqual } from './useDeepEqualMemo'; export { useReverification } from './useReverification'; diff --git a/packages/shared/src/react/hooks/useInstalledSolanaWallets.ts b/packages/shared/src/react/hooks/useInstalledSolanaWallets.ts new file mode 100644 index 00000000000..a2328e8b503 --- /dev/null +++ b/packages/shared/src/react/hooks/useInstalledSolanaWallets.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; + +import { isSolanaSignInWallet } from '../../web3'; + +export type InstalledSolanaWallet = { name: string; icon: string }; + +export function useInstalledSolanaWallets(): readonly InstalledSolanaWallet[] { + const [wallets, setWallets] = useState([]); + + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | undefined; + + void import('@wallet-standard/core').then(({ getWallets }) => { + if (disposed) { + return; + } + + const registry = getWallets(); + const update = () => { + setWallets( + registry + .get() + .filter(isSolanaSignInWallet) + .map(({ name, icon }) => ({ name, icon })), + ); + }; + const offRegister = registry.on('register', update); + const offUnregister = registry.on('unregister', update); + unsubscribe = () => { + offRegister(); + offUnregister(); + }; + update(); + }); + + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); + + return wallets; +} diff --git a/packages/shared/src/utils/__tests__/sortIdentificationBasedOnVerification.test.ts b/packages/shared/src/utils/__tests__/sortIdentificationBasedOnVerification.test.ts new file mode 100644 index 00000000000..af9f7c880a6 --- /dev/null +++ b/packages/shared/src/utils/__tests__/sortIdentificationBasedOnVerification.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import type { VerificationResource } from '../../types'; +import { sortIdentificationBasedOnVerification } from '../sortIdentificationBasedOnVerification'; + +function identification(id: string, status: VerificationResource['status'], expireAt: Date | null = null) { + return { id, verification: { status, expireAt } }; +} + +describe('identification ordering', () => { + it('orders primary, verified, expiring unverified, and absent verification without mutating the input', () => { + const input = Object.freeze([ + identification('missing', null), + identification('later', 'unverified', new Date(200)), + identification('verified-b', 'verified'), + identification('primary', 'unverified'), + identification('earlier', 'unverified', new Date(100)), + identification('verified-a', 'verified'), + ]); + const original = [...input]; + + const sorted = sortIdentificationBasedOnVerification(input, 'primary'); + + expect(sorted.map(item => item.id)).toEqual(['primary', 'verified-a', 'verified-b', 'earlier', 'later', 'missing']); + expect(input).toEqual(original); + expect(sorted[0]).toBe(input[3]); + }); + + it('keeps unverified identifications without expiry in their existing order', () => { + const input = [identification('b', 'unverified'), identification('a', 'failed')]; + + expect(sortIdentificationBasedOnVerification(input, null)).toEqual(input); + expect(sortIdentificationBasedOnVerification(undefined, null)).toEqual([]); + }); +}); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index d6104a73397..80080888604 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -8,3 +8,4 @@ export { handleValueOrFn } from './handleValueOrFn'; export { runIfFunctionOrReturn } from './runIfFunctionOrReturn'; export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge'; export { timeLimit } from './timeLimit'; +export { sortIdentificationBasedOnVerification } from './sortIdentificationBasedOnVerification'; diff --git a/packages/shared/src/utils/sortIdentificationBasedOnVerification.ts b/packages/shared/src/utils/sortIdentificationBasedOnVerification.ts new file mode 100644 index 00000000000..a344f306ec6 --- /dev/null +++ b/packages/shared/src/utils/sortIdentificationBasedOnVerification.ts @@ -0,0 +1,30 @@ +import type { VerificationResource } from '../types'; + +type Identification = { + id: string; + verification: Pick; +}; + +export function sortIdentificationBasedOnVerification( + identifications: readonly T[] | null | undefined, + primaryId: string | null | undefined, +): T[] { + if (!identifications) { + return []; + } + + const primary = identifications.filter(item => item.id === primaryId); + const remaining = identifications.filter(item => item.id !== primaryId); + const verified = remaining.filter(item => item.verification.status === 'verified'); + const unverified = remaining.filter(item => item.verification.status && item.verification.status !== 'verified'); + const withoutStatus = remaining.filter(item => !item.verification.status); + + verified.sort((first, second) => first.id.localeCompare(second.id)); + unverified.sort((first, second) => { + const firstExpiry = first.verification.expireAt; + const secondExpiry = second.verification.expireAt; + return firstExpiry && secondExpiry ? firstExpiry.getTime() - secondExpiry.getTime() : 0; + }); + + return [...primary, ...verified, ...unverified, ...withoutStatus]; +} diff --git a/packages/shared/src/web3.ts b/packages/shared/src/web3.ts index d630edf5f88..b079c919758 100644 --- a/packages/shared/src/web3.ts +++ b/packages/shared/src/web3.ts @@ -1,5 +1,16 @@ import type { Web3ProviderData } from './types'; +export function isSolanaSignInWallet(wallet: { + chains: readonly string[]; + features: Readonly>; +}): boolean { + return ( + wallet.chains.some(chain => chain.startsWith('solana:')) && + 'standard:connect' in wallet.features && + 'solana:signMessage' in wallet.features + ); +} + export const WEB3_PROVIDERS: Web3ProviderData[] = [ { provider: 'metamask', diff --git a/packages/ui/src/components/UserProfile/EmailsSection.tsx b/packages/ui/src/components/UserProfile/EmailsSection.tsx index e37666e7a72..de0eea8fd00 100644 --- a/packages/ui/src/components/UserProfile/EmailsSection.tsx +++ b/packages/ui/src/components/UserProfile/EmailsSection.tsx @@ -1,5 +1,6 @@ import { useReverification, useUser } from '@clerk/shared/react'; import type { EmailAddressResource } from '@clerk/shared/types'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { Fragment } from 'react'; import { useCardState } from '@/ui/elements/contexts'; @@ -7,7 +8,6 @@ import { ProfileSection } from '@/ui/elements/Section'; import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; import { handleError } from '@/ui/utils/errorHandler'; -import { sortIdentificationBasedOnVerification } from '../../components/UserProfile/utils'; import { Badge, Flex, localizationKeys, Text } from '../../customizables'; import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; diff --git a/packages/ui/src/components/UserProfile/PhoneSection.tsx b/packages/ui/src/components/UserProfile/PhoneSection.tsx index f4dc543f6f1..376fbbe8aba 100644 --- a/packages/ui/src/components/UserProfile/PhoneSection.tsx +++ b/packages/ui/src/components/UserProfile/PhoneSection.tsx @@ -1,5 +1,6 @@ import { useReverification, useUser } from '@clerk/shared/react'; import type { PhoneNumberResource, UserResource } from '@clerk/shared/types'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { Fragment } from 'react'; import { useCardState } from '@/ui/elements/contexts'; @@ -14,7 +15,6 @@ import { useActionContext } from '../../elements/Action/ActionRoot'; import type { PropsOfComponent } from '../../styledSystem'; import { PhoneForm } from './PhoneForm'; import { RemovePhoneForm } from './RemoveResourceForm'; -import { sortIdentificationBasedOnVerification } from './utils'; type RemovePhoneScreenProps = { phoneId: string }; const RemovePhoneScreen = (props: RemovePhoneScreenProps) => { diff --git a/packages/ui/src/components/UserProfile/Web3Section.tsx b/packages/ui/src/components/UserProfile/Web3Section.tsx index b20a2314e20..00166275a15 100644 --- a/packages/ui/src/components/UserProfile/Web3Section.tsx +++ b/packages/ui/src/components/UserProfile/Web3Section.tsx @@ -1,4 +1,5 @@ import { useReverification, useUser } from '@clerk/shared/react'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { Fragment, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -14,7 +15,6 @@ import { useActionContext } from '../../elements/Action/ActionRoot'; import { useEnabledThirdPartyProviders } from '../../hooks'; import type { PropsOfComponent } from '../../styledSystem'; import { RemoveWeb3WalletForm } from './RemoveResourceForm'; -import { sortIdentificationBasedOnVerification } from './utils'; import { AddWeb3WalletActionMenu } from './Web3Form'; type RemoveWeb3WalletScreenProps = { walletId: string }; diff --git a/packages/ui/src/components/UserProfile/__tests__/utils.test.ts b/packages/ui/src/components/UserProfile/__tests__/utils.test.ts index 9e7cd95b662..36c8e0c203c 100644 --- a/packages/ui/src/components/UserProfile/__tests__/utils.test.ts +++ b/packages/ui/src/components/UserProfile/__tests__/utils.test.ts @@ -1,19 +1,11 @@ -import type { EmailAddressResource, PhoneNumberResource } from '@clerk/shared/types'; +import type { VerificationResource } from '@clerk/shared/types'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { describe, expect, it } from 'vitest'; -import { sortIdentificationBasedOnVerification } from '../utils'; - -const email = (id: string, status: string, expireAtMs = 0) => - ({ - id, - verification: { status, expireAt: new Date(expireAtMs) }, - }) as EmailAddressResource; - -const phone = (id: string, status: string, expireAtMs = 0) => - ({ - id, - verification: { status, expireAt: new Date(expireAtMs) }, - }) as PhoneNumberResource; +const identification = (id: string, status: VerificationResource['status'], expireAtMs = 0) => ({ + id, + verification: { status, expireAt: new Date(expireAtMs) }, +}); describe('UserProfile utils', () => { describe('sortIdentificationBasedOnVerification', () => { @@ -32,11 +24,11 @@ describe('UserProfile utils', () => { `1) primary, 2) verified (sorted alphabetically by id), 3) unverified (sorted by expiresAt verification property)`, () => { const input = [ - email('1', 'unverified', 200), - email('2', 'verified'), - email('3', 'verified'), - email('4', 'verified'), - email('5', 'unverified', 100), + identification('1', 'unverified', 200), + identification('2', 'verified'), + identification('3', 'verified'), + identification('4', 'verified'), + identification('5', 'unverified', 100), ]; const result = sortIdentificationBasedOnVerification(input, '3'); expect(result[0].id).toEqual('3'); @@ -52,11 +44,11 @@ describe('UserProfile utils', () => { `1) primary, 2) verified (sorted alphabetically by id), 3) unverified (sorted by expiresAt verification property)`, () => { const input = [ - phone('1', 'unverified', 200), - phone('2', 'verified'), - phone('3', 'verified'), - phone('4', 'verified'), - phone('5', 'unverified', 100), + identification('1', 'unverified', 200), + identification('2', 'verified'), + identification('3', 'verified'), + identification('4', 'verified'), + identification('5', 'unverified', 100), ]; const result = sortIdentificationBasedOnVerification(input, '3'); @@ -70,11 +62,11 @@ describe('UserProfile utils', () => { it('should return the correct order if the primaryId is not in the array', () => { const input = [ - phone('1', 'unverified', 200), - phone('2', 'verified'), - phone('3', 'verified'), - phone('4', 'verified'), - phone('5', 'unverified', 100), + identification('1', 'unverified', 200), + identification('2', 'verified'), + identification('3', 'verified'), + identification('4', 'verified'), + identification('5', 'unverified', 100), ]; const result = sortIdentificationBasedOnVerification(input, '10'); @@ -87,12 +79,12 @@ describe('UserProfile utils', () => { it('should return last the item without verification status', () => { const input = [ - phone('1', 'unverified', 200), - phone('2', 'verified'), - phone('3', 'verified'), - phone('4', 'verified'), - phone('5', 'unverified', 100), - phone('6', ''), + identification('1', 'unverified', 200), + identification('2', 'verified'), + identification('3', 'verified'), + identification('4', 'verified'), + identification('5', 'unverified', 100), + identification('6', null), ]; const result = sortIdentificationBasedOnVerification(input, '3'); diff --git a/packages/ui/src/components/UserProfile/utils.ts b/packages/ui/src/components/UserProfile/utils.ts index 13ca67cce73..964c4b1b265 100644 --- a/packages/ui/src/components/UserProfile/utils.ts +++ b/packages/ui/src/components/UserProfile/utils.ts @@ -1,4 +1,4 @@ -import type { AttributeData, EmailAddressResource, PhoneNumberResource, Web3WalletResource } from '@clerk/shared/types'; +import type { AttributeData } from '@clerk/shared/types'; /** * An attribute is "available" in the UserProfile if it's enabled for sign-up @@ -17,31 +17,3 @@ export const primaryIdentificationFirst = (primaryId: string | null) => (val1: I }; export const currentSessionFirst = (id: string) => (a: IDable) => (a.id === id ? -1 : 1); - -export function sortIdentificationBasedOnVerification< - T extends Array, ->(array: T | null | undefined, primaryId: string | null | undefined): T { - if (!array) { - return [] as unknown as T; - } - const primaryItem = array.filter(item => item.id === primaryId); - const itemsWithoutPrimary = array.filter(item => item.id !== primaryId); - const verifiedItems = itemsWithoutPrimary.filter(item => item.verification?.status === 'verified'); - const unverifiedItems = itemsWithoutPrimary.filter( - item => !!item.verification?.status && item.verification?.status !== 'verified', - ); - const unverifiedItemsWithoutVerification = itemsWithoutPrimary.filter(item => !item.verification.status); - - // Sorting verified items alphabetically by name - verifiedItems.sort((a, b) => a.id.localeCompare(b.id)); - - // Sorting unverified items by expireAt, most recent last - unverifiedItems.sort((a, b) => { - if (!a.verification?.expireAt || !b.verification?.expireAt) { - return 0; - } - return a.verification.expireAt.getTime() - b.verification.expireAt.getTime(); - }); - - return [...primaryItem, ...verifiedItems, ...unverifiedItems, ...unverifiedItemsWithoutVerification] as T; -} diff --git a/packages/ui/src/elements/Web3SolanaWalletButtons.tsx b/packages/ui/src/elements/Web3SolanaWalletButtons.tsx index 8ea9c60b9ce..e141949ebcf 100644 --- a/packages/ui/src/elements/Web3SolanaWalletButtons.tsx +++ b/packages/ui/src/elements/Web3SolanaWalletButtons.tsx @@ -1,6 +1,6 @@ -import { getWallets, type Wallet } from '@wallet-standard/core'; +import { useInstalledSolanaWallets } from '@clerk/shared/react'; import type { Ref } from 'react'; -import React, { forwardRef, isValidElement, useEffect, useMemo, useState } from 'react'; +import React, { forwardRef, isValidElement } from 'react'; import { WalletInitialIcon } from '@/ui/common/WalletInitialIcon'; import { @@ -31,34 +31,6 @@ const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2; const SOCIAL_BUTTON_PRE_TEXT_THRESHOLD = 1; const MAX_STRATEGIES_PER_ROW = 5; -const isSolanaSignInWallet = (wallet: Wallet) => - wallet.chains.some(chain => chain.startsWith('solana:')) && - 'standard:connect' in wallet.features && - 'solana:signMessage' in wallet.features; - -const getRegisteredWallets = () => (typeof window === 'undefined' ? [] : getWallets().get()); - -const useInstalledSolanaWallets = () => { - const [wallets, setWallets] = useState(getRegisteredWallets); - - useEffect(() => { - const registry = getWallets(); - const update = () => setWallets(registry.get()); - update(); - const offRegister = registry.on('register', update); - const offUnregister = registry.on('unregister', update); - return () => { - offRegister(); - offUnregister(); - }; - }, []); - - return useMemo( - () => wallets.filter(isSolanaSignInWallet).map(wallet => ({ name: wallet.name, icon: wallet.icon })), - [wallets], - ); -}; - export const Web3SolanaWalletButtons = ({ web3AuthCallback }: Web3WalletButtonsProps) => { const card = useCardState(); const installedWallets = useInstalledSolanaWallets(); @@ -75,7 +47,7 @@ export const Web3SolanaWalletButtons = ({ web3AuthCallback }: Web3WalletButtonsP } }; - const { strategyRows } = distributeStrategiesIntoRows(installedWallets, MAX_STRATEGIES_PER_ROW, undefined); + const { strategyRows } = distributeStrategiesIntoRows([...installedWallets], MAX_STRATEGIES_PER_ROW, undefined); const strategyRowOneLength = strategyRows.at(0)?.length ?? 0; const shouldForceSingleColumnOnMobile = installedWallets.length === 2; const ButtonElement = installedWallets.length <= SOCIAL_BUTTON_BLOCK_THRESHOLD ? WalletButtonBlock : WalletButtonIcon; diff --git a/packages/ui/src/elements/__tests__/Web3SolanaWalletButtons.test.tsx b/packages/ui/src/elements/__tests__/Web3SolanaWalletButtons.test.tsx index ffb2a9f0fa4..ea296d8644b 100644 --- a/packages/ui/src/elements/__tests__/Web3SolanaWalletButtons.test.tsx +++ b/packages/ui/src/elements/__tests__/Web3SolanaWalletButtons.test.tsx @@ -1,6 +1,6 @@ -import type { Wallet } from '@wallet-standard/core'; +import type { Wallet, WindowAppReadyEventAPI } from '@wallet-standard/core'; import { act } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen } from '@/test/utils'; @@ -8,30 +8,32 @@ import { render, screen } from '@/test/utils'; import { withCardStateProvider } from '../contexts'; import { Web3SolanaWalletButtons } from '../Web3SolanaWalletButtons'; -const registry = vi.hoisted(() => ({ - wallets: [] as Wallet[], - listeners: { register: new Set<() => void>(), unregister: new Set<() => void>() }, -})); - -vi.mock('@wallet-standard/core', () => ({ - getWallets: () => ({ - get: () => registry.wallets, - on: (event: 'register' | 'unregister', listener: () => void) => { - registry.listeners[event].add(listener); - return () => registry.listeners[event].delete(listener); - }, - }), -})); - -const makeWallet = (name: string, chains: string[], features: string[]): Wallet => - ({ - name, - icon: 'data:image/svg+xml;base64,', - version: '1.0.0', - chains, - accounts: [], - features: Object.fromEntries(features.map(feature => [feature, {}])), - }) as unknown as Wallet; +let installedWallets: Wallet[] = []; +const unregister: Array<() => void> = []; + +function register(api: WindowAppReadyEventAPI) { + unregister.push(api.register(...installedWallets)); +} + +function onAppReady(event: Event & { detail?: WindowAppReadyEventAPI }) { + if (event.detail) { + register(event.detail); + } +} + +function installWallets(wallets: Wallet[]) { + installedWallets = wallets; + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); +} + +const makeWallet = (name: string, chains: Wallet['chains'], features: string[]): Wallet => ({ + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains, + accounts: [], + features: Object.fromEntries(features.map(feature => [feature, {}])), +}); const SIGN_IN_FEATURES = ['standard:connect', 'solana:signMessage']; @@ -41,18 +43,22 @@ const Buttons = withCardStateProvider(Web3SolanaWalletButtons); describe('Web3SolanaWalletButtons', () => { beforeEach(() => { - registry.wallets = []; - registry.listeners.register.clear(); - registry.listeners.unregister.clear(); + installedWallets = []; + window.addEventListener('wallet-standard:app-ready', onAppReady); + }); + + afterEach(() => { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.splice(0).forEach(off => off()); }); it('lists only Solana wallets that can connect and sign messages', async () => { - registry.wallets = [ + installWallets([ makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES), makeWallet('Solana Viewer', ['solana:mainnet'], ['standard:connect']), makeWallet('Solana Signer', ['solana:mainnet'], ['solana:signMessage']), makeWallet('MetaMask', ['eip155:1'], SIGN_IN_FEATURES), - ]; + ]); const { wrapper } = await createFixtures(); render(, { wrapper }); @@ -71,15 +77,20 @@ describe('Web3SolanaWalletButtons', () => { expect(await screen.findByText(/No Solana Web3 wallets detected/)).toBeInTheDocument(); act(() => { - registry.wallets = [makeWallet('Backpack', ['solana:mainnet'], SIGN_IN_FEATURES)]; - registry.listeners.register.forEach(listener => listener()); + installWallets([makeWallet('Backpack', ['solana:mainnet'], SIGN_IN_FEATURES)]); }); expect(await screen.findByText('Continue with Backpack')).toBeInTheDocument(); + + act(() => { + installedWallets = []; + unregister.splice(0).forEach(off => off()); + }); + expect(await screen.findByText(/No Solana Web3 wallets detected/)).toBeInTheDocument(); }); it('passes the chosen wallet name to the auth callback', async () => { - registry.wallets = [makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES)]; + installWallets([makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES)]); const web3AuthCallback = vi.fn().mockResolvedValue(undefined); const { wrapper } = await createFixtures(); From 4fe4ba1ed03785756ef4a99d96531e7ad0e6acb6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:37:38 -0600 Subject: [PATCH 19/86] test(mosaic): cover wallet retries and immutable settings --- ...file-web3-wallets-section.feature.test.tsx | 156 +++++++++++++++++- 1 file changed, 151 insertions(+), 5 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 a556f4909bc..68755b603f2 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 @@ -1,5 +1,5 @@ import type { UserJSON } from '@clerk/shared/types'; -import { act, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, 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'; @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, + fapiEnterpriseAccount, fapiEnvironment, fapiSession, fapiUser, @@ -128,6 +129,100 @@ describe('Web3 wallets', () => { 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(); + expect(await screen.findByText('Primary')).toBeInTheDocument(); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe( + fapi.client.sessions[0]?.user.web3_wallets[0]?.id, + ); + }); + + it('reuses the created Ethereum wallet when Connect is retried after signature rejection', async () => { + const address = '0xabcdef1234567890'; + let accountRequests = 0; + let signatureRequests = 0; + vi.stubGlobal('ethereum', { + request: vi.fn(({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') { + accountRequests += 1; + return Promise.resolve([accountRequests === 1 ? address : address.toUpperCase().replace('0X', '0x')]); + } + if (method === 'personal_sign') { + signatureRequests += 1; + return signatureRequests === 1 + ? Promise.reject(new Error('Signature rejected')) + : Promise.resolve('signature'); + } + throw new Error(`Unexpected wallet method: ${method}`); + }), + }); + const fapi = await renderWeb3(); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.release(); + expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + const pendingId = fapi.client.sessions[0]?.user.web3_wallets[0]?.id; + expect(pendingId).toBeDefined(); + expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified')); + expect(creation.requests).toHaveLength(1); + expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual([pendingId]); + expect(screen.getByText('Primary')).toBeInTheDocument(); + }); + + it('hides immutable wallet mutations but still permits setting a verified wallet as primary', async () => { + const environment = web3Environment(); + serveFapi({ + environment: fapiEnvironment({ + user_settings: { + attributes: { + ...environment.user_settings.attributes, + web3_wallet: { + ...environment.user_settings.attributes.web3_wallet, + immutable: true, + first_factors: ['web3_metamask_signature', 'web3_coinbase_wallet_signature'], + }, + }, + }, + }), + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ + id: 'user_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }), + }), + ]), + }); + await renderWithClerk(); + + expect(screen.queryByRole('button', { name: 'Connect Coinbase Wallet' })).toBeNull(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Manage MetaMask' })); + expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Remove wallet' })).toBeNull(); + }); + + it('hides an immutable Web3 section without saved wallets', async () => { + const environment = web3Environment(); + serveFapi({ + environment: fapiEnvironment({ + user_settings: { + attributes: { + ...environment.user_settings.attributes, + web3_wallet: { ...environment.user_settings.attributes.web3_wallet, immutable: true }, + }, + }, + }), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk(); + + expect(screen.queryByRole('group', { name: 'Web3 wallets' })).toBeNull(); }); it('shows a localized fallback when the wallet provider rejects without a message', async () => { @@ -211,6 +306,43 @@ describe('Web3 wallets', () => { }, ); + it('rechecks creation policy after the wallet provider prompt', async () => { + const accountRequest = Promise.withResolvers(); + vi.stubGlobal('ethereum', { request: vi.fn(() => accountRequest.promise) }); + const environment = web3Environment(); + const fapi = serveFapi({ + environment: fapiEnvironment({ + user_settings: { + ...environment.user_settings, + enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + }, + }), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ + id: 'sess_2', + user: fapiUser({ id: 'user_1', enterprise_accounts: [fapiEnterpriseAccount({ id: 'sso_1' })] }), + }), + ]), + }); + const { clerk } = await renderWithClerk(); + const initialUser = clerk.user; + if (!initialUser) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(initialUser, 'createWeb3Wallet'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await act(() => clerk.setActive({ session: 'sess_2' })); + await act(async () => { + accountRequest.resolve(['0x1234567890abcdef']); + await accountRequest.promise; + }); + + expect(create).not.toHaveBeenCalled(); + expect(fapi.client.sessions[1]?.user.web3_wallets).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).toBeNull(); + }); + it('clears the previous user’s provider error and open wallet picker on session change', async () => { vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error('First user wallet failure'))) }); serveFapi({ @@ -244,7 +376,11 @@ describe('Web3 wallets', () => { const hold = holdRequests('post', '/v1/me/web3_wallets'); const user = userEvent.setup(); - await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + const metamaskButton = await screen.findByRole('button', { name: 'Connect MetaMask' }); + act(() => { + fireEvent.click(metamaskButton); + fireEvent.click(screen.getByRole('button', { name: 'Connect Solana' })); + }); 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(); @@ -258,6 +394,7 @@ describe('Web3 wallets', () => { expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'), ); expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).toBeNull(); }); it('sets a verified wallet as primary through Clerk', async () => { @@ -310,6 +447,7 @@ describe('Web3 wallets', () => { 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' })], + primary_web3_wallet_id: 'wallet_1', }); const user = userEvent.setup(); @@ -319,6 +457,7 @@ describe('Web3 wallets', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBeNull(); await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveFocus()); }); @@ -353,21 +492,24 @@ describe('Web3 wallets', () => { fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), fapiWeb3Wallet({ id: 'wallet_2', web3_wallet: '0xabcdef1234567890' }), ], + primary_web3_wallet_id: 'wallet_2', }); const user = userEvent.setup(); - const first = screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]; + const first = screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]; 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.getAllByRole('button', { name: 'Manage MetaMask' })[0]); 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']); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_1'); + expect(screen.getByText('Primary')).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); }); @@ -482,7 +624,11 @@ describe('Web3 wallets', () => { 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' })); + screen.getByRole('button', { name: 'Second Solana' }).focus(); + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Second Solana' })); + fireEvent.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: 'Second Solana' })).toHaveFocus(); From 918b3dce729b0f2d9635d6cad005375fb443a6fb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:37:45 -0600 Subject: [PATCH 20/86] test(mosaic): mirror primary-wallet updates in fake FAPI --- packages/mosaic/src/__tests__/feature/fake-fapi.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 54337aeb37e..0326be18223 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -290,6 +290,10 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { }), }); updateWeb3Wallet(state, verified); + const user = activeUser(state); + if (user && !user.primary_web3_wallet_id) { + updateUser(state, { ...user, primary_web3_wallet_id: verified.id }); + } return envelope(verified, state.client); }), http.post(fapiUrl('/v1/me/web3_wallets/:id'), ({ params, request }) => { @@ -301,7 +305,15 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { if (!user || !wallet) { return missing(); } - updateUser(state, { ...user, web3_wallets: user.web3_wallets.filter(current => current.id !== wallet.id) }); + const remainingWallets = user.web3_wallets.filter(current => current.id !== wallet.id); + updateUser(state, { + ...user, + web3_wallets: remainingWallets, + primary_web3_wallet_id: + user.primary_web3_wallet_id === wallet.id + ? (remainingWallets.find(current => current.verification?.status === 'verified')?.id ?? null) + : user.primary_web3_wallet_id, + }); return envelope({ object: 'web3_wallet', id: wallet.id, deleted: true }, state.client); }), http.post(fapiUrl('/v1/me'), async ({ request }) => { From ab14f1be6effd0d7adf46ab92dc4cecd1e1c8ab2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:37:51 -0600 Subject: [PATCH 21/86] fix(mosaic): recover wallet connections and respect immutable settings --- .changeset/web3-wallets-wire-up.md | 2 +- ...profile-web3-wallets-section.model.test.ts | 8 ++- ...user-profile-web3-wallets-section.model.ts | 54 +++++++++++++++---- ...user-profile-web3-wallets-section.types.ts | 2 +- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md index 3bb43eba3a9..3903937ef86 100644 --- a/.changeset/web3-wallets-wire-up.md +++ b/.changeset/web3-wallets-wire-up.md @@ -3,4 +3,4 @@ '@clerk/shared': patch --- -Keep Web3 wallet action state scoped to the signed-in user and use localized API errors. Share Solana wallet discovery so only wallets supporting connection and message signing are offered. +Keep Web3 wallet action state scoped to the signed-in user and use localized API errors. A failed signature can be retried without removing the unverified wallet first, and immutable wallet settings hide unavailable Connect and Remove actions. Share Solana wallet discovery so only wallets supporting connection and message signing are offered. 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 index 197f4ef299b..ddc12a4c1e6 100644 --- 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 @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; +import { normalizedWeb3Wallet, projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; const metamask = { id: 'wallet_metamask', @@ -9,6 +9,12 @@ const metamask = { } as const; describe('Web3 wallet projection', () => { + it('matches the backend wallet sanitizer without folding Solana address case', () => { + expect(normalizedWeb3Wallet(' 0XAbCd ')).toBe('0xabcd'); + expect(normalizedWeb3Wallet(' SolanaAbCd ')).toBe('SolanaAbCd'); + expect(normalizedWeb3Wallet(' Solanaabcd ')).toBe('Solanaabcd'); + }); + it('WEB3-01 shows configured providers that do not have a verified wallet', () => { expect( projectWeb3Wallets({ 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 index 2606bfade32..2e0e74b67a8 100644 --- 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 @@ -21,6 +21,11 @@ export type Web3WalletsProjection = | { status: 'hidden' } | { status: 'ready'; wallets: UserProfileWeb3Wallet[]; availableProviders: UserProfileWeb3Provider[] }; +export function normalizedWeb3Wallet(identifier: string): string { + const trimmed = identifier.trim(); + return /^0x/i.test(trimmed) ? trimmed.toLowerCase() : trimmed; +} + export function projectWeb3Wallets({ wallets, primaryId, @@ -87,11 +92,13 @@ export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { if (!user || !environment.userSettings.attributes.web3_wallet?.enabled) { return { status: 'hidden' }; } + const web3Attribute = environment.userSettings.attributes.web3_wallet; const projection = projectWeb3Wallets({ wallets: user.web3Wallets, primaryId: user.primaryWeb3WalletId, enabledStrategies: environment.userSettings.web3FirstFactors, - allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), + allowCreation: + !web3Attribute.immutable && allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), }); if (projection.status === 'hidden') { return projection; @@ -129,7 +136,29 @@ export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { if (!identifier) { throw new Web3WalletActionError('extensionUnavailable'); } - const wallet = await requireCurrentUser().createWeb3Wallet({ web3Wallet: identifier }); + const current = requireCurrentUser(); + const currentProjection = projectWeb3Wallets({ + wallets: current.web3Wallets, + primaryId: current.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: + Boolean(environment.userSettings.attributes.web3_wallet?.enabled) && + !environment.userSettings.attributes.web3_wallet?.immutable && + allowsIdentificationCreation(current, environment.userSettings.enterpriseSSO), + }); + if ( + currentProjection.status !== 'ready' || + !currentProjection.availableProviders.some(candidate => candidate.id === strategy) + ) { + throw new Web3WalletActionError('providerUnavailable'); + } + const normalizedIdentifier = normalizedWeb3Wallet(identifier); + const existing = current.web3Wallets.find( + wallet => + wallet.verification.status !== 'verified' && + normalizedWeb3Wallet(wallet.web3Wallet) === normalizedIdentifier, + ); + const wallet = existing ?? (await current.createWeb3Wallet({ web3Wallet: identifier })); requireCurrentUser(); if (!wallet) { throw new Web3WalletActionError('creationFailed'); @@ -160,13 +189,18 @@ export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { } await current.update({ primaryWeb3WalletId: walletId }); }), - remove: walletId => - runAction(async () => { - const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); - if (!wallet) { - throw new Web3WalletActionError('providerUnavailable'); - } - await wallet.destroy(); - }), + remove: web3Attribute.immutable + ? undefined + : walletId => + runAction(async () => { + if (environment.userSettings.attributes.web3_wallet?.immutable) { + throw new Web3WalletActionError('providerUnavailable'); + } + const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); + if (!wallet) { + throw new Web3WalletActionError('providerUnavailable'); + } + await wallet.destroy(); + }), }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts index 43d24ffebae..cdb5a5da66d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -12,7 +12,7 @@ export type UserProfileWeb3WalletsModel = availableProviders: UserProfileWeb3Provider[]; connect: (strategy: Web3Strategy, walletName?: string) => Promise; setPrimary: (walletId: string) => Promise; - remove: (walletId: string) => Promise; + remove?: (walletId: string) => Promise; }; export type ReadyWeb3WalletsModel = Extract; From f40eb00f906db1f0b4b4e8d17ad775b33f184ad5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:37:58 -0600 Subject: [PATCH 22/86] test(mosaic): replace wallet hook tests with connected coverage --- ...e-web3-wallets-section.controller.test.tsx | 99 ------------------- 1 file changed, 99 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx 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 deleted file mode 100644 index 18769ad5090..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileWeb3Provider } from '../user-profile-web3-wallets-section.view'; -import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; - -const wallets = [{ id: 'wallet_1', address: '0x1234567890abcdef', isVerified: true }]; -const availableProviders: UserProfileWeb3Provider[] = [{ 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('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(), - }), - { - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - await act(async () => { - await result.current.onConnect('web3_metamask_signature'); - }); - - expect(result.current.availableProviders[0]?.connectError).toBe('Impossible de connecter le portefeuille.'); - }); - it('keeps the accepted Solana wallet as the pending owner through repeated calls', async () => { - const pending = Promise.withResolvers(); - const connect = vi.fn(() => pending.promise); - const { result } = renderHook(() => - useUserProfileWeb3WalletsController({ - wallets: [], - availableProviders: [{ id: 'web3_solana_signature', provider: 'Solana', walletPicker: 'solana' }], - connect, - setPrimary: () => Promise.resolve(), - }), - ); - act(() => { - void result.current.onConnect('web3_solana_signature'); - }); - act(() => { - void result.current.connectSolana('First wallet'); - void result.current.connectSolana('Second wallet'); - result.current.closeSolanaPicker(); - }); - expect(result.current.pendingWalletName).toBe('First wallet'); - expect(result.current.solanaPickerOpen).toBe(true); - expect(connect).toHaveBeenCalledOnce(); - expect(connect).toHaveBeenCalledWith('web3_solana_signature', 'First wallet'); - await act(async () => { - pending.resolve(); - await pending.promise; - }); - expect(result.current.pendingWalletName).toBeUndefined(); - expect(result.current.solanaPickerOpen).toBe(false); - }); -}); From 94d393f333fd98686080e5f00fcfffa517620d31 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:21:19 -0600 Subject: [PATCH 23/86] test(mosaic): focus Solana selection through user interaction --- .../user-profile-web3-wallets-section.feature.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 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 68755b603f2..2b83b29a0df 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 @@ -624,10 +624,9 @@ describe('Web3 wallets', () => { await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); await user.click(screen.getByRole('button', { name: 'Connect Solana' })); const creation = holdRequests('post', '/v1/me/web3_wallets'); - screen.getByRole('button', { name: 'Second Solana' }).focus(); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); act(() => { fireEvent.click(screen.getByRole('button', { name: 'Second Solana' })); - fireEvent.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'); From 6cd2aa3e23adab753d32fb71f53b6828fbf09b02 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:21:36 -0600 Subject: [PATCH 24/86] fix(swingset): align wired Web3 stories with live navigation --- packages/swingset/src/lib/live-navigation.ts | 2 +- .../stories/fixtures/user-profile-web3-wallets.ts | 12 ++++++++---- .../user-profile-web3-wallets-section.stories.tsx | 5 ++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index cff17ea7102..5b464dffcbc 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -18,7 +18,7 @@ const liveRoutes = new Map([ ['/user-profile/user-profile-danger-section', '/live/user-danger'], ['/organization-profile/organization-profile-danger-section', '/live/organization-danger'], ['/user-profile/user-profile-passkeys-section', '/live/passkeys'], - ['/user-profile/user-profile-web3-wallets-section', '/live/web3-wallets'], + ['/user-profile/user-profile-web3wallets-section', '/live/web3-wallets'], ['/reverification/reverification', '/live/reverification'], ]); diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index a02fd0c09dc..bf5fe3e718e 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -12,13 +12,17 @@ interface DemoWallet extends UserProfileWeb3Wallet { } const providers: UserProfileWeb3Provider[] = [ - { id: 'metamask', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, - { id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg' }, + { id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, + { + id: 'web3_coinbase_wallet_signature', + provider: 'Coinbase Wallet', + iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', + }, ]; export const primaryWallet: DemoWallet = { id: 'wallet_1', - providerId: 'metamask', + providerId: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg', address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', @@ -28,7 +32,7 @@ export const primaryWallet: DemoWallet = { export const secondaryWallet: DemoWallet = { id: 'wallet_2', - providerId: 'coinbase-wallet', + providerId: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', address: '0x1234567890abcdef1234567890abcdef12345678', diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index 894cdd5963a..34ed28e8f18 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -9,7 +9,6 @@ export { default as __source } from './user-profile-web3-wallets-section.stories export const meta: StoryMeta = { group: 'User Profile', status: 'wip', - substatus: 'needs wire-up', title: 'UserProfileWeb3WalletsSection', label: 'Web3 wallets', navigation: { category: 'Sections' }, @@ -34,7 +33,7 @@ export function ConnectOnly() { export function UnverifiedWallet() { const fixture = useWeb3WalletsFixture({ initialWallets: [{ ...primaryWallet, isPrimary: false, isVerified: false }], - availableProviders: [{ id: 'metamask', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], + availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], }); return ( Date: Sat, 3 Oct 2026 16:16:01 -0600 Subject: [PATCH 25/86] fix(ui): keep profile helpers outside initial shared entries --- .../user-profile-solana-wallet.dialog.tsx | 2 +- .../user-profile-web3-wallets-section.model.ts | 2 +- packages/shared/src/react/hooks/index.ts | 2 -- packages/shared/src/utils/index.ts | 1 - packages/shared/tsdown.config.mts | 2 ++ .../src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx | 2 +- .../SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx | 2 +- .../ui/src/components/SignUp/SignUpStartSolanaWalletsCard.tsx | 2 +- packages/ui/src/components/UserProfile/EmailsSection.tsx | 2 +- packages/ui/src/components/UserProfile/PhoneSection.tsx | 2 +- packages/ui/src/components/UserProfile/Web3Section.tsx | 2 +- .../src/components/UserProfile/Web3SelectSolanaWalletScreen.tsx | 2 +- packages/ui/src/components/UserProfile/__tests__/utils.test.ts | 2 +- .../ui/src/{elements => components}/Web3SolanaWalletButtons.tsx | 2 +- .../__tests__/Web3SolanaWalletButtons.test.tsx | 2 +- 15 files changed, 14 insertions(+), 15 deletions(-) rename packages/ui/src/{elements => components}/Web3SolanaWalletButtons.tsx (98%) rename packages/ui/src/{elements => components}/__tests__/Web3SolanaWalletButtons.test.tsx (98%) 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 86e30a94e66..23aa50e7ac3 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,4 +1,4 @@ -import { useInstalledSolanaWallets } from '@clerk/shared/react'; +import { useInstalledSolanaWallets } from '@clerk/shared/react/hooks/useInstalledSolanaWallets'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; 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 index 2e0e74b67a8..327a0a1ecf4 100644 --- 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 @@ -2,7 +2,7 @@ import { iconImageUrl } from '@clerk/shared/constants'; import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; import { useClerk, useUser } from '@clerk/shared/react'; import type { VerificationResource, Web3WalletResource } from '@clerk/shared/types'; -import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils/sortIdentificationBasedOnVerification'; import { WEB3_PROVIDERS } from '@clerk/shared/web3'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index c10c40f43a5..abe5b258c87 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -16,8 +16,6 @@ export { useSafeLayoutEffect } from './useSafeLayoutEffect'; export { useSession } from './useSession'; export { useSessionList } from './useSessionList'; export { useUser } from './useUser'; -export { useInstalledSolanaWallets } from './useInstalledSolanaWallets'; -export type { InstalledSolanaWallet } from './useInstalledSolanaWallets'; export { useClerk } from './useClerk'; export { useDeepEqualMemo, isDeeplyEqual } from './useDeepEqualMemo'; export { useReverification } from './useReverification'; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 80080888604..d6104a73397 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -8,4 +8,3 @@ export { handleValueOrFn } from './handleValueOrFn'; export { runIfFunctionOrReturn } from './runIfFunctionOrReturn'; export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge'; export { timeLimit } from './timeLimit'; -export { sortIdentificationBasedOnVerification } from './sortIdentificationBasedOnVerification'; diff --git a/packages/shared/tsdown.config.mts b/packages/shared/tsdown.config.mts index 559b8bbf9a6..8c65e4a25fc 100644 --- a/packages/shared/tsdown.config.mts +++ b/packages/shared/tsdown.config.mts @@ -25,7 +25,9 @@ export default defineConfig(({ watch, env }) => { entry: [ './src/*.{ts,tsx}', './src/react/index.ts', + './src/react/hooks/useInstalledSolanaWallets.ts', './src/utils/index.ts', + './src/utils/sortIdentificationBasedOnVerification.ts', './src/phone/index.ts', './src/workerTimers/index.ts', './src/types/index.ts', diff --git a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx index 56efdfc8341..dcba1aa5ecc 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx @@ -10,7 +10,7 @@ import { Header } from '@/ui/elements/Header'; import { web3CallbackErrorHandler } from '@/ui/utils/web3CallbackErrorHandler'; const Web3SolanaWalletButtons = lazy(() => - import(/* webpackChunkName: "web3-solana-wallet-buttons" */ '@/ui/elements/Web3SolanaWalletButtons').then(m => ({ + import(/* webpackChunkName: "web3-solana-wallet-buttons" */ '@/ui/components/Web3SolanaWalletButtons').then(m => ({ default: m.Web3SolanaWalletButtons, })), ); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx index d5c118653b1..a3f00f4279d 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx @@ -9,7 +9,7 @@ const { createFixtures } = bindCreateFixtures('SignIn'); // The wallet buttons are lazy-loaded; stub them with a button that invokes the card's // `web3AuthCallback` so we can assert the params the card forwards to `authenticateWithWeb3`. -vi.mock('@/ui/elements/Web3SolanaWalletButtons', () => ({ +vi.mock('@/ui/components/Web3SolanaWalletButtons', () => ({ Web3SolanaWalletButtons: ({ web3AuthCallback }: { web3AuthCallback: (a: { walletName: string }) => void }) => ( + + ) : null} + {discovery.status === 'ready' && discovery.wallets.length === 0 ? (

{m.solanaDialog.noneAvailable}{' '}

) : null} - {wallets.map(wallet => ( - onConnect(wallet.name)} - > - {wallet.icon ? ( - - ) : null} - {wallet.name} - - ))} + {discovery.status === 'ready' + ? discovery.wallets.map(wallet => ( + onConnect(wallet.name)} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + )) + : null} { + vi.doUnmock('@wallet-standard/core'); +}); + +describe('Solana wallet discovery failures', () => { + it('handles a registry import that rejects after unmount', async () => { + const load = deferred(); + const unhandled = vi.fn(); + window.addEventListener('unhandledrejection', unhandled); + const loadRegistry = vi.fn(() => load.promise); + vi.doMock('@wallet-standard/core', loadRegistry); + + try { + const { result, unmount } = renderHook(useUserProfileSolanaWalletsModel); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => expect(loadRegistry).toHaveBeenCalledOnce()); + unmount(); + await act(async () => { + load.reject(new Error('Registry import failed after unmount')); + await vi.dynamicImportSettled(); + }); + expect(result.current).toEqual({ status: 'loading' }); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('unhandledrejection', unhandled); + } + }); + + it('reports a failed registry import instead of an empty wallet list and can retry', async () => { + vi.doMock('@wallet-standard/core', () => { + throw new Error('Failed to fetch the wallet registry chunk'); + }); + + const { result } = renderHook(useUserProfileSolanaWalletsModel); + expect(result.current).toEqual({ status: 'loading' }); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(result.current).toMatchObject({ status: 'error' }); + + vi.doMock('@wallet-standard/core', () => ({ + getWallets: () => ({ + get: (): Wallet[] => [ + { + name: 'Recovered Solana', + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features: { 'standard:connect': {}, 'solana:signMessage': {} }, + }, + ], + on: () => () => {}, + }), + })); + + act(() => { + if (result.current.status === 'error') { + result.current.retry(); + } + }); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Recovered Solana', icon: 'data:image/svg+xml;base64,' }], + }), + ); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx index e80de46ae2c..cde8f80714d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx @@ -44,28 +44,27 @@ describe('useUserProfileSolanaWalletsModel', () => { registry.getWallets.mockClear(); }); - it('renders an empty server snapshot without initializing the browser registry', () => { + it('renders a loading server snapshot without initializing the browser registry', () => { registry.wallets.push(wallet('Phantom')); function WalletNames() { - return createElement( - 'div', - null, - useUserProfileSolanaWalletsModel() - .map(wallet => wallet.name) - .join(','), - ); + return createElement('div', null, useUserProfileSolanaWalletsModel().status); } - expect(renderToString(createElement(WalletNames))).toBe('
'); + expect(renderToString(createElement(WalletNames))).toBe('
loading
'); expect(registry.getWallets).not.toHaveBeenCalled(); }); - it('starts empty and projects only eligible wallet names and icons after loading the registry', async () => { + it('starts loading and projects only eligible wallet names and icons after loading the registry', async () => { registry.wallets.push(wallet('Phantom'), wallet('Signer only', { 'solana:signMessage': {} })); const { result } = renderHook(useUserProfileSolanaWalletsModel); - expect(result.current).toEqual([]); - await waitFor(() => expect(result.current).toEqual([{ name: 'Phantom', icon: 'data:image/svg+xml;base64,' }])); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Phantom', icon: 'data:image/svg+xml;base64,' }], + }), + ); }); it.each([ @@ -81,7 +80,10 @@ describe('useUserProfileSolanaWalletsModel', () => { await vi.dynamicImportSettled(); }); expect(registry.getWallets).toHaveBeenCalled(); - expect(result.current).toEqual(entry.eligible ? [{ name: 'Candidate', icon: 'data:image/svg+xml;base64,' }] : []); + expect(result.current).toEqual({ + status: 'ready', + wallets: entry.eligible ? [{ name: 'Candidate', icon: 'data:image/svg+xml;base64,' }] : [], + }); }); it('refreshes on registration and unregistration and removes both subscriptions on unmount', async () => { @@ -92,13 +94,16 @@ describe('useUserProfileSolanaWalletsModel', () => { registry.wallets.push(wallet('Backpack')); registry.listeners.register.forEach(listener => listener()); }); - expect(result.current).toEqual([{ name: 'Backpack', icon: 'data:image/svg+xml;base64,' }]); + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Backpack', icon: 'data:image/svg+xml;base64,' }], + }); act(() => { registry.wallets.splice(0); registry.listeners.unregister.forEach(listener => listener()); }); - expect(result.current).toEqual([]); + expect(result.current).toEqual({ status: 'ready', wallets: [] }); unmount(); expect(registry.listeners.register.size).toBe(0); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts index 68dd5373dee..a38b65586b8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts @@ -1,7 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export type InstalledSolanaWallet = { name: string; icon: string }; +export type SolanaWalletDiscovery = + | { status: 'loading' } + | { status: 'ready'; wallets: readonly InstalledSolanaWallet[] } + | { status: 'error'; retry: () => void }; + function isSolanaSignInWallet(wallet: { chains: readonly string[]; features: Readonly>; @@ -13,41 +18,54 @@ function isSolanaSignInWallet(wallet: { ); } -export function useUserProfileSolanaWalletsModel(): readonly InstalledSolanaWallet[] { - const [wallets, setWallets] = useState([]); +export function useUserProfileSolanaWalletsModel(): SolanaWalletDiscovery { + const [discovery, setDiscovery] = useState({ status: 'loading' }); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => { + setDiscovery({ status: 'loading' }); + setAttempt(attempt => attempt + 1); + }, []); useEffect(() => { let disposed = false; let unsubscribe: (() => void) | undefined; - void import('@wallet-standard/core').then(({ getWallets }) => { - if (disposed) { - return; - } - - const registry = getWallets(); - const update = () => { - setWallets( - registry - .get() - .filter(isSolanaSignInWallet) - .map(({ name, icon }) => ({ name, icon })), - ); - }; - const offRegister = registry.on('register', update); - const offUnregister = registry.on('unregister', update); - unsubscribe = () => { - offRegister(); - offUnregister(); - }; - update(); - }); + void import('@wallet-standard/core') + .then(({ getWallets }) => { + if (disposed) { + return; + } + + const registry = getWallets(); + const update = () => { + setDiscovery({ + status: 'ready', + wallets: registry + .get() + .filter(isSolanaSignInWallet) + .map(({ name, icon }) => ({ name, icon })), + }); + }; + const offRegister = registry.on('register', update); + const offUnregister = registry.on('unregister', update); + unsubscribe = () => { + offRegister(); + offUnregister(); + }; + update(); + }) + .catch(() => { + if (disposed) { + return; + } + setDiscovery({ status: 'error', retry }); + }); return () => { disposed = true; unsubscribe?.(); }; - }, []); + }, [attempt, retry]); - return wallets; + return discovery; } 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 cb6598b3e10..f4a71fc89c3 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 @@ -54,7 +54,7 @@ function Web3Wallets({ onRemove={controller.onRemove} /> Date: Wed, 7 Oct 2026 10:51:15 -0600 Subject: [PATCH 38/86] fix(mosaic): discover Solana wallets when the picker opens --- ...file-web3-wallets-section.feature.test.tsx | 55 +++++--- .../user-profile-solana-wallet.dialog.tsx | 124 ++++-------------- ...r-profile-solana-wallet.lifecycle.test.tsx | 90 +++++++++++++ .../user-profile-solana-wallet.view.tsx | 114 ++++++++++++++++ .../user-profile-web3-wallets-section.tsx | 3 - 5 files changed, 262 insertions(+), 124 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.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 7c9d765917c..9a2ba1d037a 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 @@ -15,8 +15,9 @@ import { fapiWeb3Wallet, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +import { Dialog } from '../../../components/dialog'; import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileSolanaWalletDialog } from '../user-profile-web3-wallets-section/user-profile-solana-wallet.dialog'; +import { UserProfileSolanaWalletView } from '../user-profile-web3-wallets-section/user-profile-solana-wallet.view'; import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; describe('Web3 wallets', () => { @@ -549,7 +550,7 @@ describe('Web3 wallets', () => { }); it('distinguishes localized discovery loading and failure from no installed wallets', async () => { - const props = { open: true, onOpenChange: vi.fn(), onConnect: vi.fn() }; + const props = { onConnect: vi.fn() }; const retry = vi.fn(); const localization = { messages: { @@ -564,10 +565,14 @@ describe('Web3 wallets', () => { }; const { rerender } = render( - + + + + + , ); @@ -577,10 +582,14 @@ describe('Web3 wallets', () => { rerender( - + + + + + , ); @@ -594,13 +603,17 @@ describe('Web3 wallets', () => { }); it('renders caller-supplied Solana wallets and forwards the chosen name', async () => { - const props = { open: true, onOpenChange: vi.fn(), onConnect: vi.fn() }; + const props = { onConnect: vi.fn() }; const { rerender } = render( - + + + + + , ); @@ -609,10 +622,14 @@ describe('Web3 wallets', () => { rerender( - + + + + + , ); expect(screen.queryByRole('button', { name: 'Supplied Solana' })).toBeNull(); 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 ea08deda39e..e4f723c7fa6 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,26 +1,21 @@ -import { Banner } from '../../../components/banner'; -import { Button, SubmitButton } from '../../../components/button'; -import { Card } from '../../../components/card'; +import type { ComponentProps } from 'react'; + import { Dialog } from '../../../components/dialog'; -import { useMessages } from '../../../localization'; -import type { SolanaWalletDiscovery } from './user-profile-solana-wallet.model'; +import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; +import { UserProfileSolanaWalletView } from './user-profile-solana-wallet.view'; + +type SolanaWalletContentProps = Omit, 'discovery'>; export function UserProfileSolanaWalletDialog({ - discovery, open, pendingWalletName, error, onOpenChange, onConnect, -}: { - discovery: SolanaWalletDiscovery; +}: SolanaWalletContentProps & { open: boolean; - pendingWalletName?: string; - error?: string; onOpenChange: (open: boolean) => void; - onConnect: (walletName: string) => void; }) { - const m = useMessages('userProfileWeb3Wallets'); const pending = pendingWalletName !== undefined; return ( @@ -34,97 +29,22 @@ export function UserProfileSolanaWalletDialog({ }} > - - - {m.solanaDialog.title} - {m.solanaDialog.description} - - - {error ? ( - - {error} - - ) : null} - {discovery.status === 'loading' ?

{m.solanaDialog.loading}

: null} - {discovery.status === 'error' ? ( - <> - - {m.solanaDialog.loadError} - - - - ) : null} - {discovery.status === 'ready' && discovery.wallets.length === 0 ? ( -

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

- ) : null} - {discovery.status === 'ready' - ? discovery.wallets.map(wallet => ( - onConnect(wallet.name)} - > - {wallet.icon ? ( - - ) : null} - {wallet.name} - - )) - : null} - - - - {m.solanaDialog.cancel} - - } - /> - - +
); } + +function SolanaWalletContent(props: SolanaWalletContentProps) { + const discovery = useUserProfileSolanaWalletsModel(); + return ( + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx new file mode 100644 index 00000000000..2616c77d86c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx @@ -0,0 +1,90 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Wallet } from '@wallet-standard/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileWeb3WalletsSection } from './user-profile-web3-wallets-section'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; + +const registry = vi.hoisted(() => { + const wallets: Wallet[] = []; + const listeners = { register: new Set<() => void>(), unregister: new Set<() => void>() }; + const getWallets = vi.fn(() => ({ + get: () => wallets, + on: (event: 'register' | 'unregister', listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + })); + return { wallets, listeners, getWallets, loadModule: vi.fn() }; +}); + +vi.mock('@wallet-standard/core', () => { + registry.loadModule(); + return { getWallets: registry.getWallets }; +}); +vi.mock('./user-profile-web3-wallets-section.model', () => ({ + useUserProfileWeb3WalletsModel: () => + ({ + status: 'ready', + userId: 'user_1', + wallets: [], + availableProviders: [{ id: 'web3_solana_signature', provider: 'Solana', walletPicker: 'solana' }], + connect: async () => {}, + setPrimary: async () => {}, + }) satisfies ReadyWeb3WalletsModel, +})); + +function wallet(name: string): Wallet { + return { + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features: { 'standard:connect': {}, 'solana:signMessage': {} }, + }; +} + +describe('Solana picker discovery lifetime', () => { + it('discovers wallets only while the picker content is mounted and refreshes after reopening', async () => { + registry.wallets.push(wallet('Phantom')); + const user = userEvent.setup(); + const { unmount } = render( + + + , + ); + + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(registry.loadModule).not.toHaveBeenCalled(); + expect(registry.getWallets).not.toHaveBeenCalled(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(await screen.findByRole('button', { name: 'Phantom' })).toBeInTheDocument(); + expect(registry.loadModule).toHaveBeenCalledOnce(); + expect(registry.getWallets).toHaveBeenCalledOnce(); + expect(registry.listeners.register.size).toBe(1); + expect(registry.listeners.unregister.size).toBe(1); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + + registry.wallets.splice(0, registry.wallets.length, wallet('Backpack')); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(await screen.findByRole('button', { name: 'Backpack' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Phantom' })).not.toBeInTheDocument(); + expect(registry.getWallets).toHaveBeenCalledTimes(2); + + unmount(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx new file mode 100644 index 00000000000..6decc33aa2a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx @@ -0,0 +1,114 @@ +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'; +import type { SolanaWalletDiscovery } from './user-profile-solana-wallet.model'; + +export function UserProfileSolanaWalletView({ + discovery, + pendingWalletName, + error, + onConnect, +}: { + discovery: SolanaWalletDiscovery; + pendingWalletName?: string; + error?: string; + onConnect: (walletName: string) => void; +}) { + const m = useMessages('userProfileWeb3Wallets'); + const pending = pendingWalletName !== undefined; + + return ( + + + {m.solanaDialog.title} + {m.solanaDialog.description} + + + {error ? ( + + {error} + + ) : null} + {discovery.status === 'loading' ?

{m.solanaDialog.loading}

: null} + {discovery.status === 'error' ? ( + <> + + {m.solanaDialog.loadError} + + + + ) : null} + {discovery.status === 'ready' && discovery.wallets.length === 0 ? ( +

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

+ ) : null} + {discovery.status === 'ready' + ? discovery.wallets.map(wallet => ( + onConnect(wallet.name)} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + )) + : null} +
+ + + {m.solanaDialog.cancel} + + } + /> + +
+ ); +} 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 f4a71fc89c3..a6294740ebc 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 @@ -2,7 +2,6 @@ import type { ReactNode } from 'react'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; -import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; import { useUserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.model'; import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; @@ -37,7 +36,6 @@ function Web3Wallets({ fallbackFocus?: () => HTMLElement | null; }) { const controller = useUserProfileWeb3WalletsController(model); - const solanaWallets = useUserProfileSolanaWalletsModel(); return ( <> Date: Wed, 7 Oct 2026 15:09:52 -0600 Subject: [PATCH 39/86] fix(mosaic): reuse shared fake FAPI missing response --- packages/mosaic/src/__tests__/feature/fake-fapi.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 0326be18223..98c916b1c58 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -137,10 +137,6 @@ function updateWeb3Wallet(state: FakeFapiState, wallet: Web3WalletJSON): void { } } -function missing() { - return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); -} - export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const { verification, enterpriseLinking, passkeys, ...rest } = seed; const state: FakeFapiState = { From cdf7fdfa70d3a92c55ac9354dbb85601f4c19ff9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 13:00:51 -0600 Subject: [PATCH 40/86] feat(mosaic): wire up user profile Web3 wallets --- .changeset/web3-wallets-wire-up.md | 2 + packages/mosaic/package.json | 3 +- .../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 + .../app/(clerk)/live/web3-wallets/page.tsx | 36 ++++ packages/swingset/src/lib/live-navigation.ts | 1 + pnpm-lock.yaml | 3 + 14 files changed, 689 insertions(+), 5 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 52c5fa7fd5f..205f578221e 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-web3-wallet-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx index 933a4dd5c02..6dd0cf5f09d 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'; @@ -15,12 +15,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; @@ -32,7 +36,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) { @@ -85,10 +89,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 6ed2cb678ee..9ce86c69313 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 ( <> @@ -75,6 +78,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} /> @@ -84,6 +88,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/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/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index a330e15c410..493aebd9390 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -19,6 +19,7 @@ const liveRoutes = new Map([ ['/organization-profile/organization-profile-general-panel', '/live/organization-general'], ['/organization-profile/organization-profile-danger-section', '/live/organization-danger'], ['/user-profile/user-profile-passkeys-section', '/live/passkeys'], + ['/user-profile/user-profile-web3-wallets-section', '/live/web3-wallets'], ['/reverification/reverification', '/live/reverification'], ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f8e9f20ee5..474f60c5838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,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 86d4424f7a395d12beb74a69188fe34394c71d38 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:14:15 -0600 Subject: [PATCH 41/86] 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 9ce86c69313..818d4ed75d6 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/use-list-removal-focus'; import { fill, useMessages } from '../../localization'; import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible'; +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); @@ -101,6 +104,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 7c4e1647754b333274f4bb7555e1002af00f3c38 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:23:34 -0600 Subject: [PATCH 42/86] 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 383682e28826eef5c629e51b1fa44918387125af Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:24:39 -0600 Subject: [PATCH 43/86] 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 974ead6c86314d04b27b0ff13445827ccc660453 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:55:14 -0600 Subject: [PATCH 44/86] 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 0caf9d760f940cc8544f0291bb0ca27ed1572f0d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:24:59 -0600 Subject: [PATCH 45/86] 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 9b577418490ebac08e610aa0c471b60850610e9d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 09:46:30 -0600 Subject: [PATCH 46/86] 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 5014b8dbc90..9ce86c69313 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/use-list-removal-focus'; import { fill, useMessages } from '../../localization'; import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible'; -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); @@ -103,9 +99,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 80390747943f176a5ab1a40fc655e58dc23cb43e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 11:25:11 -0600 Subject: [PATCH 47/86] test(mosaic): cover wallet behavior through real Clerk --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 117 +++++++ packages/mosaic/src/__tests__/feature/fapi.ts | 30 +- .../user-profile-web3-actions.test.tsx | 124 -------- ...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, 440 insertions(+), 505 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 edc1fa49b40..01025bf8ace 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -10,6 +10,7 @@ import type { SessionJSON, UserJSON, UserOrganizationInvitationJSON, + Web3WalletJSON, } from '@clerk/shared/types'; import { http, HttpResponse } from 'msw'; import { setupWorker } from 'msw/browser'; @@ -34,6 +35,7 @@ import { fapiPage, fapiToken, fapiVerification, + fapiWeb3Wallet, } from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; @@ -140,6 +142,24 @@ function updateOrganization(state: FakeFapiState, organization: OrganizationJSON }; } +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 }); +} + export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const { verification, enterpriseLinking, passkeys, ...rest } = seed; const state: FakeFapiState = { @@ -240,6 +260,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 0c0744ea63d..65d097460a5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -26,11 +26,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; @@ -354,6 +363,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 fapiSession(overrides: Partial & Pick): SessionJSON { const { user } = overrides; const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); 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 cbc866af4ee..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ /dev/null @@ -1,124 +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 { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -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(clerkApiError('web3_wallet_in_use', '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 f1052b0034ab10664240a98ba3812b9173b9b1e8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 11:59:48 -0600 Subject: [PATCH 48/86] 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 7e68e633d27fc16a7986376a60591340183fe558 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 17:08:02 -0600 Subject: [PATCH 49/86] 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.', From db6e8ce7b683535ad1c92755c1744c789d9c26cf Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 01:05:09 -0600 Subject: [PATCH 50/86] chore(mosaic): note Web3 error localization follow-up --- .../user-profile-web3-wallets-section.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 4207ba46508..861af9872ea 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 @@ -17,6 +17,8 @@ export interface UserProfileWeb3WalletsSectionProps { fallbackFocus?: () => HTMLElement | null; } +// TODO: Adopt the shared localized error handling from #9844 once it lands on main, +// preserving Clerk error codes for wallet-signature failures. function actionError(error: unknown, fallbackMessage: string): Error { if (isClerkAPIResponseError(error)) { const first = error.errors[0]; From 812e1fb1fdbd03f31a0261de421d1e84f1c0d3ac Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 01:31:28 -0600 Subject: [PATCH 51/86] chore(mosaic): mark session reverification as deferred --- .../user-profile-web3-wallets-section.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 861af9872ea..51886bb85cc 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 @@ -48,6 +48,8 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP }) : ({ status: 'hidden' } as const); + // TODO: Add session reverification for wallet connection, primary updates, and removal; + // surface API errors until then. const connect = async (strategy: string, walletName?: string) => { const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); const manager = clerk.__internal_moduleManager; From c775d7844efe9a54d785b1f77b1968b907b5f748 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 12:27:17 -0600 Subject: [PATCH 52/86] test(mosaic): align Web3 verification fixtures with main --- .../user-profile-web3-wallets-section.feature.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 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 a951c3612f2..56bddef79fd 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 @@ -63,7 +63,7 @@ describe('Web3 wallets', () => { fapiWeb3Wallet({ id: 'admin_wallet', web3_wallet: address, - verification: fapiVerification({ strategy: 'admin', status }), + verification: fapiVerification('admin', { status }), }), ], }); @@ -86,7 +86,7 @@ describe('Web3 wallets', () => { fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef', - verification: fapiVerification({ strategy: 'web3_metamask_signature', status: 'unverified' }), + verification: fapiVerification('web3_metamask_signature', { status: 'unverified' }), }), ], }); From 8ba3ce36d3bfc89ad7c91c327aa25679655bc6e4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 12:52:10 -0600 Subject: [PATCH 53/86] test(mosaic): record shared Solana follow-up --- .../user-profile-web3-wallets-section.feature.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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 56bddef79fd..4487435b0a2 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 @@ -363,4 +363,13 @@ describe('Web3 wallets', () => { unregister.forEach(remove => remove()); } }); + + // TODO(B24): Extract isSolanaSignInWallet and useInstalledSolanaWallets from PR #9994 into @clerk/shared, + // then use them in legacy UI and Mosaic: https://github.com/clerk/javascript/pull/9994. + // Require a solana: chain, standard:connect, and solana:signMessage. + // Preserve server-safe initial state, registry refresh, registration/unregistration subscriptions, + // subscription cleanup, and name/icon projection. Keep @wallet-standard/core dynamically imported in shared. + // Cover missing-feature combinations and registry registration/unregistration; retain this marker until it lands. + // Agreed follow-up: https://clerkinc.slack.com/archives/C064QJ37LUC/p1790779098886039. + it.todo('uses shared Solana sign-in eligibility and installed-wallet subscriptions in legacy UI and Mosaic'); }); From ff6eadea96631d2ef3176c92fd80f46f3957e553 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 13:01:01 -0600 Subject: [PATCH 54/86] fix(mosaic): align Web3 imports with main --- .../user-profile-web3-wallets-section.feature.test.tsx | 2 +- .../user-profile-web3-wallets-section.tsx | 2 +- packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx | 2 +- 3 files changed, 3 insertions(+), 3 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 4487435b0a2..06a71013b91 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,7 +14,7 @@ import { fapiWeb3Wallet, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; -import { MosaicProvider } from '../../../MosaicProvider'; +import { MosaicProvider } from '../../../mosaic-provider'; import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; describe('Web3 wallets', () => { 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 51886bb85cc..a6b3acfccc1 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 @@ -4,7 +4,7 @@ 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 { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; 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'; diff --git a/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx index 9aac58176d0..d268f2082fc 100644 --- a/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx @@ -1,7 +1,7 @@ '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 { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; From 06661842870e873d74432ab2662d592a6945c4ba Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 15:28:44 -0600 Subject: [PATCH 55/86] chore(mosaic): keep one empty Web3 changeset --- .changeset/tidy-wallet-visibility.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/tidy-wallet-visibility.md diff --git a/.changeset/tidy-wallet-visibility.md b/.changeset/tidy-wallet-visibility.md deleted file mode 100644 index aef8bbcb46d..00000000000 --- a/.changeset/tidy-wallet-visibility.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/mosaic': patch ---- - -Hide the Web3 wallet section when Web3 wallets are disabled in the instance settings. From 9925256480b8d18b39a82ed64299bc5e928774b3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 17:01:37 -0600 Subject: [PATCH 56/86] test(mosaic): cover unrelated fake FAPI user updates --- .../feature/fake-fapi-user.feature.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx b/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx new file mode 100644 index 00000000000..ea046f5df0b --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { fapiUrl, serveFapi } from './fake-fapi'; +import { fapiClient, fapiSession, fapiUser, fapiWeb3Wallet } from './fapi'; + +describe('fake FAPI user updates', () => { + it('updates profile fields without clearing omitted identification fields', async () => { + const user = fapiUser({ + id: 'user_1', + first_name: 'Austin', + last_name: 'Original', + username: 'austin', + primary_web3_wallet_id: 'wallet_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234' })], + }); + const fapi = serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + + const response = await fetch(`${fapiUrl('/v1/me')}?_method=PATCH`, { + method: 'POST', + body: new URLSearchParams({ first_name: 'Updated', last_name: 'Name', username: 'updated' }), + }); + + expect(response.ok).toBe(true); + expect(fapi.client.sessions[0]?.user).toMatchObject({ + first_name: 'Updated', + last_name: 'Name', + username: 'updated', + primary_web3_wallet_id: 'wallet_1', + web3_wallets: user.web3_wallets, + }); + }); +}); From 527e30819712571ec27e81511f0eec1403a6d805 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 17:02:15 -0600 Subject: [PATCH 57/86] fix(mosaic): address Web3 wallet review feedback --- .changeset/web3-wallets-wire-up.md | 4 + .../mosaic/src/__tests__/feature/fake-fapi.ts | 46 +++-- packages/mosaic/src/__tests__/feature/fapi.ts | 8 +- .../action-menu/action-menu.test.tsx | 31 ++++ .../components/action-menu/action-menu.tsx | 5 +- ...file-web3-wallets-section.feature.test.tsx | 159 ++++++++++++++++-- .../user-profile-web3-wallet-row.view.tsx | 3 +- ...user-profile-web3-wallets-section.view.tsx | 4 +- .../user-profile-solana-wallet.dialog.tsx | 40 +---- .../user-profile-web3-wallets-feedback.ts | 23 +++ ...e-web3-wallets-section.controller.test.tsx | 60 ++++++- ...profile-web3-wallets-section.controller.ts | 101 +++++------ ...profile-web3-wallets-section.model.test.ts | 37 ++-- ...user-profile-web3-wallets-section.model.ts | 151 +++++++++++++---- .../user-profile-web3-wallets-section.tsx | 136 +++------------ ...user-profile-web3-wallets-section.types.ts | 34 ++++ .../use-pending-action-by-id.test.tsx | 47 ++++++ .../src/hooks/use-pending-action-by-id.ts | 34 ++++ packages/shared/src/__tests__/web3.spec.ts | 15 ++ .../useInstalledSolanaWallets.spec.tsx | 102 +++++++++++ packages/shared/src/react/hooks/index.ts | 2 + .../react/hooks/useInstalledSolanaWallets.ts | 44 +++++ ...tIdentificationBasedOnVerification.test.ts | 35 ++++ packages/shared/src/utils/index.ts | 1 + .../sortIdentificationBasedOnVerification.ts | 30 ++++ packages/shared/src/web3.ts | 11 ++ .../components/UserProfile/EmailsSection.tsx | 2 +- .../components/UserProfile/PhoneSection.tsx | 2 +- .../components/UserProfile/Web3Section.tsx | 2 +- .../UserProfile/__tests__/utils.test.ts | 62 +++---- .../ui/src/components/UserProfile/utils.ts | 30 +--- .../src/elements/Web3SolanaWalletButtons.tsx | 34 +--- .../Web3SolanaWalletButtons.test.tsx | 79 +++++---- 33 files changed, 943 insertions(+), 431 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts create mode 100644 packages/mosaic/src/hooks/__tests__/use-pending-action-by-id.test.tsx create mode 100644 packages/mosaic/src/hooks/use-pending-action-by-id.ts create mode 100644 packages/shared/src/__tests__/web3.spec.ts create mode 100644 packages/shared/src/react/hooks/__tests__/useInstalledSolanaWallets.spec.tsx create mode 100644 packages/shared/src/react/hooks/useInstalledSolanaWallets.ts create mode 100644 packages/shared/src/utils/__tests__/sortIdentificationBasedOnVerification.test.ts create mode 100644 packages/shared/src/utils/sortIdentificationBasedOnVerification.ts diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md index a845151cc84..3bb43eba3a9 100644 --- a/.changeset/web3-wallets-wire-up.md +++ b/.changeset/web3-wallets-wire-up.md @@ -1,2 +1,6 @@ --- +'@clerk/mosaic': patch +'@clerk/shared': patch --- + +Keep Web3 wallet action state scoped to the signed-in user and use localized API errors. Share Solana wallet discovery so only wallets supporting connection and message signing are offered. diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 01025bf8ace..723fe711d3d 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -273,16 +273,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { 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: '' }, - }, + verification: fapiVerification('', { expire_at: 0 }), }); updateUser(state, { ...user, web3_wallets: [...user.web3_wallets, wallet] }); return envelope(wallet, state.client); @@ -295,17 +286,10 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { 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') ?? '', + verification: fapiVerification(body.get('strategy') ?? '', { message: 'Sign this wallet challenge', - attempts: 0, expire_at: 0, - error: { code: '', message: '' }, - }, + }), }); updateWeb3Wallet(state, prepared); return envelope(prepared, state.client); @@ -317,17 +301,12 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { } const verified = fapiWeb3Wallet({ ...wallet, - verification: { - id: 'verification_1', - object: 'verification', + verification: fapiVerification(wallet.verification?.strategy ?? '', { ...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); @@ -353,7 +332,20 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { return missing(); } const body = new URLSearchParams(await request.text()); - const updated = { ...user, primary_web3_wallet_id: body.get('primary_web3_wallet_id') }; + const updated = { ...user }; + const fields = [ + 'first_name', + 'last_name', + 'username', + 'primary_email_address_id', + 'primary_phone_number_id', + 'primary_web3_wallet_id', + ] as const; + for (const field of fields) { + if (body.has(field)) { + updated[field] = body.get(field); + } + } updateUser(state, updated); return envelope(updated, state.client); }), diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 65d097460a5..9c562790ce5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -368,16 +368,12 @@ export function fapiWeb3Wallet( ): Web3WalletJSON { return { object: 'web3_wallet', - verification: { - id: 'verification_1', - object: 'verification', + verification: fapiVerification('web3_metamask_signature', { status: 'verified', verified_at_client: '', - strategy: 'web3_metamask_signature', attempts: 1, expire_at: 0, - error: { code: '', message: '' }, - }, + }), ...overrides, }; } diff --git a/packages/mosaic/src/components/action-menu/action-menu.test.tsx b/packages/mosaic/src/components/action-menu/action-menu.test.tsx index f30098d6b3a..8786eee5e68 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.test.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.test.tsx @@ -36,4 +36,35 @@ describe('Mosaic ActionMenu', () => { expect(onClick).toHaveBeenCalledOnce(); }); + it('disables both the trigger and existing menu items while busy', async () => { + const onClick = vi.fn(); + const actions = [{ label: 'Remove', onClick }]; + const view = render( + + + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage wallet' })); + expect(await screen.findByRole('menuitem', { name: 'Remove' })).toBeInTheDocument(); + + view.rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Manage wallet' })).toBeDisabled(); + const remove = screen.getByRole('menuitem', { name: 'Remove' }); + expect(remove).toHaveAttribute('aria-disabled', 'true'); + await user.click(remove); + expect(onClick).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mosaic/src/components/action-menu/action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx index 480c4655625..2cd36e9fa0b 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -15,9 +15,10 @@ export interface ActionMenuProps { label: string; actions: ActionMenuAction[]; triggerRef?: Ref; + disabled?: boolean; } -export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { +export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuProps) { if (actions.length === 0) { return null; } @@ -27,6 +28,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { {actions.map(action => ( @@ -35,6 +37,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { color={action.color} label={action.label} onClick={action.onClick} + disabled={disabled} > {action.icon ? ( 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 06a71013b91..a556f4909bc 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 @@ -1,5 +1,5 @@ import type { UserJSON } from '@clerk/shared/types'; -import { screen, waitFor, within } from '@testing-library/react'; +import { act, 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'; @@ -149,6 +149,91 @@ describe('Web3 wallets', () => { await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connexion impossible.')); }); + it('uses the canonical API-code translation for a wallet connection failure', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.resolve(['0x1234567890abcdef'])) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + + + , + ); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.fail('verification_invalid_strategy', 'Server copy'); + + expect(await screen.findByRole('alert')).toHaveTextContent('Ce portefeuille est indisponible.'); + expect(screen.queryByText('Server copy')).toBeNull(); + }); + + it.each(['switch', 'sign out'] as const)( + 'aborts wallet creation after %s during the provider prompt', + async change => { + const accountRequest = Promise.withResolvers(); + const request = vi.fn(() => accountRequest.promise); + vi.stubGlobal('ethereum', { request }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const original = clerk.user; + if (!original) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(original, 'createWeb3Wallet'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(request).toHaveBeenCalledOnce()); + await act(() => (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut())); + expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined); + if (change === 'switch') { + expect.soft(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + } + + await act(async () => { + accountRequest.resolve(['0x1234567890abcdef']); + await accountRequest.promise; + }); + + expect(create).not.toHaveBeenCalled(); + if (change === 'switch') { + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + expect(screen.queryByRole('alert')).toBeNull(); + } + }, + ); + + it('clears the previous user’s provider error and open wallet picker on session change', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error('First user wallet failure'))) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); + + await act(() => clerk.setActive({ session: 'sess_2' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + }); + it('holds one provider pending and allows a manual retry after an API error', async () => { vi.stubGlobal('ethereum', { request: vi.fn(({ method }: { method: string }) => @@ -210,6 +295,7 @@ describe('Web3 wallets', () => { 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)); + expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeDisabled(); hold.fail('primary_update_failed'); expect(await screen.findByRole('alert')).toHaveTextContent('primary_update_failed'); @@ -285,6 +371,64 @@ describe('Web3 wallets', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); }); + it('shows only eligible Solana wallets and refreshes when wallets register or unregister', async () => { + const wallet = { + version: '1.0.0' as const, + name: 'Eligible Solana', + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [], + features: { + 'standard:connect': { version: '1.0.0' as const, connect: () => Promise.resolve({ accounts: [] }) }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage: () => Promise.resolve([]) }, + }, + }; + const registries: WindowAppReadyEventAPI[] = []; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => { + registries.push(api); + unregister.push( + api.register( + { + ...wallet, + name: 'Cannot connect', + features: { 'solana:signMessage': wallet.features['solana:signMessage'] }, + }, + { ...wallet, name: 'Cannot sign', features: { 'standard:connect': wallet.features['standard:connect'] } }, + { ...wallet, name: 'Wrong chain', chains: ['ethereum:mainnet'] }, + ), + ); + }; + 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 })); + let unregisterEligible: Array<() => void> = []; + try { + await renderWeb3(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(screen.queryByRole('button', { name: 'Cannot connect' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Cannot sign' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Wrong chain' })).toBeNull(); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + + act(() => { + unregisterEligible = registries.map(api => api.register(wallet)); + }); + expect(await screen.findByRole('button', { name: 'Eligible Solana' })).toBeEnabled(); + act(() => unregisterEligible.forEach(remove => remove())); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Eligible Solana' })).toBeNull()); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + unregisterEligible.forEach(remove => remove()); + } + }); + it('connects the chosen Solana wallet and verifies it through Clerk', async () => { const account = { address: 'SolanaAddress123', @@ -341,11 +485,13 @@ describe('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: 'Second Solana' })).toHaveFocus(); 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'); + expect(screen.getAllByRole('alert', { hidden: true })).toHaveLength(1); const retryFapi = serveFapi(fapi); await user.click(screen.getByRole('button', { name: 'Second Solana' })); await waitFor(() => @@ -363,13 +509,6 @@ describe('Web3 wallets', () => { unregister.forEach(remove => remove()); } }); - - // TODO(B24): Extract isSolanaSignInWallet and useInstalledSolanaWallets from PR #9994 into @clerk/shared, - // then use them in legacy UI and Mosaic: https://github.com/clerk/javascript/pull/9994. - // Require a solana: chain, standard:connect, and solana:signMessage. - // Preserve server-safe initial state, registry refresh, registration/unregistration subscriptions, - // subscription cleanup, and name/icon projection. Keep @wallet-standard/core dynamically imported in shared. - // Cover missing-feature combinations and registry registration/unregistration; retain this marker until it lands. - // Agreed follow-up: https://clerkinc.slack.com/archives/C064QJ37LUC/p1790779098886039. - it.todo('uses shared Solana sign-in eligibility and installed-wallet subscriptions in legacy UI and Mosaic'); + // TODO: Add session reverification for wallet connection, primary updates, and removal; + // surface API errors until then. }); 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 6dd0cf5f09d..66adbffbe91 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 @@ -36,7 +36,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 && !isDisabled) { + if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) }); } if (linkedWallet && onRemove && linkedWallet.canRemove !== false) { @@ -111,6 +111,7 @@ export function UserProfileWeb3WalletRowView({
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 9ce86c69313..cf7905b5721 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 @@ -1,3 +1,4 @@ +import type { Web3Strategy } from '@clerk/shared/types'; import { useMemo, useRef } from 'react'; import { Confirmation } from '../../blocks/confirmation'; @@ -8,7 +9,8 @@ import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visib import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; export interface UserProfileWeb3Provider { - id: string; + id: Web3Strategy; + walletPicker?: 'solana'; provider: string; iconUrl?: string; connectError?: string; 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 24ebc321eb5..86e30a94e66 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,5 +1,4 @@ -import { getWallets } from '@wallet-standard/core'; -import { useEffect, useState } from 'react'; +import { useInstalledSolanaWallets } from '@clerk/shared/react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -7,42 +6,22 @@ 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, + pendingWalletName, error, onOpenChange, onConnect, }: { open: boolean; - pending: boolean; + pendingWalletName?: string; error?: string; onOpenChange: (open: boolean) => void; onConnect: (walletName: string) => void; }) { const m = useMessages('userProfileWeb3Wallets'); - const [wallets, setWallets] = useState(installedSolanaWallets); - const [selectedWalletName, setSelectedWalletName] = useState(); - - useEffect(() => { - const registry = getWallets(); - const update = () => setWallets(installedSolanaWallets()); - const unregister = registry.on('register', update); - const unadvertise = registry.on('unregister', update); - update(); - return () => { - unregister(); - unadvertise(); - }; - }, []); + const wallets = useInstalledSolanaWallets(); + const pending = pendingWalletName !== undefined; return ( { - setSelectedWalletName(wallet.name); - onConnect(wallet.name); - }} + isPending={pendingWalletName === wallet.name} + disabled={pending && pendingWalletName !== wallet.name} + onClick={() => onConnect(wallet.name)} > {wallet.icon ? ( string, +): Error { + if (error instanceof Web3WalletActionError) { + return new Web3WalletActionError(error.code, messages.errors[error.code], { cause: error }); + } + let message: string = messages.errors.generic; + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + if (first) { + message = errorText(toLocalizableApiError(first, messages.errors.generic)); + } + } + return new Error(message, { cause: error }); +} 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 f87a5c06f00..18769ad5090 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 @@ -2,10 +2,12 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import { MosaicProvider } from '../../../mosaic-provider'; +import type { UserProfileWeb3Provider } from '../user-profile-web3-wallets-section.view'; 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' }]; +const availableProviders: UserProfileWeb3Provider[] = [{ id: 'web3_metamask_signature', provider: 'MetaMask' }]; describe('Web3 wallet controller', () => { it('WEB3-06 holds a pending connection and blocks another attempt', async () => { @@ -17,7 +19,6 @@ describe('Web3 wallet controller', () => { availableProviders, connect, setPrimary: vi.fn(), - fallbackErrorMessage: 'Something went wrong. Please try again.', }), ); @@ -38,20 +39,61 @@ describe('Web3 wallet controller', () => { }); 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(), + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + await act(async () => { + await result.current.onConnect('web3_metamask_signature'); + }); + + expect(result.current.availableProviders[0]?.connectError).toBe('Impossible de connecter le portefeuille.'); + }); + it('keeps the accepted Solana wallet as the pending owner through repeated calls', async () => { + const pending = Promise.withResolvers(); + const connect = vi.fn(() => pending.promise); const { result } = renderHook(() => useUserProfileWeb3WalletsController({ wallets: [], - availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask' }], - connect: () => Promise.reject(new Error('')), + availableProviders: [{ id: 'web3_solana_signature', provider: 'Solana', walletPicker: 'solana' }], + connect, setPrimary: () => Promise.resolve(), - fallbackErrorMessage: 'Impossible de connecter le portefeuille.', }), ); - + act(() => { + void result.current.onConnect('web3_solana_signature'); + }); + act(() => { + void result.current.connectSolana('First wallet'); + void result.current.connectSolana('Second wallet'); + result.current.closeSolanaPicker(); + }); + expect(result.current.pendingWalletName).toBe('First wallet'); + expect(result.current.solanaPickerOpen).toBe(true); + expect(connect).toHaveBeenCalledOnce(); + expect(connect).toHaveBeenCalledWith('web3_solana_signature', 'First wallet'); await act(async () => { - await result.current.onConnect('web3_metamask_signature'); + pending.resolve(); + await pending.promise; }); - - expect(result.current.availableProviders[0]?.connectError).toBe('Impossible de connecter le portefeuille.'); + expect(result.current.pendingWalletName).toBeUndefined(); + 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 index 07ef1667d04..c0100346d73 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,84 +1,71 @@ -import { useRef, useState } from 'react'; +import { useState } from 'react'; -import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; +import { usePendingActionById } from '../../../hooks/use-pending-action-by-id'; +import { useMessages } from '../../../localization'; +import type { UserProfileWeb3Provider } from '../user-profile-web3-wallets-section.view'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; export function useUserProfileWeb3WalletsController({ wallets, 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>({}); - const [primaryErrors, setPrimaryErrors] = useState>({}); - const [solanaPickerOpen, setSolanaPickerOpen] = useState(false); - const inFlight = useRef(false); +}: Pick) { + const messages = useMessages('userProfileWeb3Wallets'); + const action = usePendingActionById(messages.errors.generic); + const [picker, setPicker] = useState(null); + const [pendingWalletName, setPendingWalletName] = useState(); - 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 : fallbackErrorMessage; - setErrors(current => ({ ...current, [id]: message })); - return false; - } finally { - inFlight.current = false; - setPendingId(undefined); + const onConnect = (id: string) => { + if (action.busy()) { + return; } - }; - - const onConnect = (strategy: string) => { - if (inFlight.current) { + const provider = availableProviders.find(candidate => candidate.id === id); + if (!provider) { return; } - if (strategy === 'web3_solana_signature') { - setSolanaPickerOpen(true); + if (provider.walletPicker === 'solana') { + setPicker(provider); return; } - return run(strategy, () => connect(strategy), setConnectErrors); + return action.run(provider.id, () => connect(provider.id)); }; - const connectSolana = async (walletName: string) => { - const succeeded = await run( - 'web3_solana_signature', - () => connect('web3_solana_signature', walletName), - setConnectErrors, - ); - if (succeeded) { - setSolanaPickerOpen(false); + const connectSolana = (walletName: string) => { + if (!picker) { + return; } + return action.run(picker.id, async () => { + setPendingWalletName(walletName); + try { + await connect(picker.id, walletName); + setPicker(null); + } finally { + setPendingWalletName(undefined); + } + }); }; return { wallets: wallets.map(wallet => - primaryErrors[wallet.id] ? { ...wallet, primaryError: primaryErrors[wallet.id] } : wallet, + action.errors[wallet.id] ? { ...wallet, primaryError: action.errors[wallet.id] } : wallet, ), availableProviders: availableProviders.map(provider => - connectErrors[provider.id] ? { ...provider, connectError: connectErrors[provider.id] } : provider, + action.errors[provider.id] && picker?.id !== provider.id + ? { ...provider, connectError: action.errors[provider.id] } + : provider, ), - pendingId, - solanaPickerOpen, + pendingId: action.pendingId, + solanaPickerOpen: picker !== null, + solanaPickerError: picker ? action.errors[picker.id] : undefined, + pendingWalletName, onConnect, - onSetPrimary: (walletId: string) => run(walletId, () => setPrimary(walletId), setPrimaryErrors), + onSetPrimary: (walletId: string) => action.run(walletId, () => setPrimary(walletId)), connectSolana, - closeSolanaPicker: () => setSolanaPickerOpen(false), + closeSolanaPicker: () => { + if (!action.busy()) { + setPicker(null); + } + }, }; } 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 index 45f69dcc766..197f4ef299b 100644 --- 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 @@ -4,10 +4,9 @@ import { projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; const metamask = { id: 'wallet_metamask', - address: '0x1234567890abcdef', - strategy: 'web3_metamask_signature', - status: 'verified', -}; + web3Wallet: '0x1234567890abcdef', + verification: { strategy: 'web3_metamask_signature', status: 'verified', expireAt: null }, +} as const; describe('Web3 wallet projection', () => { it('WEB3-01 shows configured providers that do not have a verified wallet', () => { @@ -26,7 +25,7 @@ describe('Web3 wallet projection', () => { it('WEB3-02 keeps unverified wallets and their provider available', () => { const result = projectWeb3Wallets({ - wallets: [{ ...metamask, status: 'unverified' }], + wallets: [{ ...metamask, verification: { ...metamask.verification, status: 'unverified' } }], primaryId: null, enabledStrategies: ['web3_metamask_signature'], allowCreation: true, @@ -40,8 +39,12 @@ describe('Web3 wallet projection', () => { 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' }, + { id: 'admin', web3Wallet: '0xadmin', verification: { strategy: 'admin', status: 'verified', expireAt: null } }, + { + id: 'unknown', + web3Wallet: '0xunknown', + verification: { strategy: 'unknown', status: 'verified', expireAt: null }, + }, ], primaryId: null, enabledStrategies: [], @@ -56,7 +59,11 @@ describe('Web3 wallet projection', () => { 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: 'unverified', + verification: { ...metamask.verification, status: 'unverified', expireAt: new Date('2026-01-02') }, + }, { ...metamask, id: 'verified_z' }, { ...metamask, id: 'verified_a' }, { ...metamask, id: 'primary' }, @@ -75,9 +82,17 @@ describe('Web3 wallet projection', () => { 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') }, + { ...metamask, id: 'missing', verification: { ...metamask.verification, status: null } }, + { + ...metamask, + id: 'unverified_without_expiry', + verification: { ...metamask.verification, status: 'unverified' }, + }, + { + ...metamask, + id: 'unverified_with_expiry', + verification: { ...metamask.verification, status: 'unverified', expireAt: new Date('2026-01-02') }, + }, ], primaryId: null, enabledStrategies: [], 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 index 857c08540d0..2606bfade32 100644 --- 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 @@ -1,15 +1,21 @@ import { iconImageUrl } from '@clerk/shared/constants'; +import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; +import { useClerk, useUser } from '@clerk/shared/react'; +import type { VerificationResource, Web3WalletResource } from '@clerk/shared/types'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { WEB3_PROVIDERS } from '@clerk/shared/web3'; +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +import { useErrorText, useMessages } from '../../../localization'; +import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; +import { web3WalletFeedback } from './user-profile-web3-wallets-feedback'; +import type { UserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; +import { Web3WalletActionError } from './user-profile-web3-wallets-section.types'; -export interface Web3WalletEntry { - id: string; - address: string; - strategy: string; - status: string | null; - expireAt?: Date | null; -} +export type Web3WalletEntry = Pick & { + verification: Pick; +}; export type Web3WalletsProjection = | { status: 'hidden' } @@ -32,44 +38,27 @@ export function projectWeb3Wallets({ 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), + wallet => + wallet.verification.strategy === 'admin' || + WEB3_PROVIDERS.some(provider => provider.strategy === wallet.verification.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 sorted = sortIdentificationBasedOnVerification(knownWallets, primaryId); const connectedStrategies = new Set( - wallets.filter(wallet => wallet.status === 'verified').map(wallet => wallet.strategy), + wallets.filter(wallet => wallet.verification.status === 'verified').map(wallet => wallet.verification.strategy), ); return { status: 'ready', wallets: sorted.map(wallet => { - const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === wallet.strategy); + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === wallet.verification.strategy); return { id: wallet.id, - address: wallet.address, + address: wallet.web3Wallet, provider: provider?.name, iconUrl: provider ? iconImageUrl(provider.provider) : undefined, isPrimary: wallet.id === primaryId, - isVerified: wallet.status === 'verified', + isVerified: wallet.verification.status === 'verified', }; }), availableProviders: allowCreation @@ -77,9 +66,107 @@ export function projectWeb3Wallets({ .filter(provider => !connectedStrategies.has(provider.strategy)) .map(provider => ({ id: provider.strategy, + walletPicker: provider.provider === 'solana' ? 'solana' : undefined, provider: provider.name, iconUrl: iconImageUrl(provider.provider), })) : [], }; } + +export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const environment = useMosaicEnvironment(); + const messages = useMessages('userProfileWeb3Wallets'); + const errorText = useErrorText(); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + if (!user || !environment.userSettings.attributes.web3_wallet?.enabled) { + return { status: 'hidden' }; + } + const projection = projectWeb3Wallets({ + wallets: user.web3Wallets, + primaryId: user.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), + }); + if (projection.status === 'hidden') { + return projection; + } + const userId = user.id; + const requireCurrentUser = () => { + const current = clerk.user; + if (!current || current.id !== userId) { + throw new Web3WalletActionError('providerUnavailable'); + } + return current; + }; + const runAction = async (action: () => Promise): Promise => { + try { + await action(); + } catch (error) { + throw web3WalletFeedback(error, messages, errorText); + } + }; + + return { + ...projection, + userId, + connect: (strategy, walletName) => + runAction(async () => { + requireCurrentUser(); + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); + const available = projection.availableProviders.some(candidate => candidate.id === strategy); + const manager = clerk.__internal_moduleManager; + if (!provider || !available || !manager) { + throw new Web3WalletActionError('providerUnavailable'); + } + const web3 = createWeb3(manager); + const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); + if (!identifier) { + throw new Web3WalletActionError('extensionUnavailable'); + } + const wallet = await requireCurrentUser().createWeb3Wallet({ web3Wallet: identifier }); + requireCurrentUser(); + if (!wallet) { + throw new Web3WalletActionError('creationFailed'); + } + const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); + requireCurrentUser(); + const nonce = prepared.verification.message; + if (!nonce) { + throw new Web3WalletActionError('messageUnavailable'); + } + const signature = await web3.generateWeb3Signature({ + identifier, + nonce, + provider: provider.provider, + walletName, + }); + requireCurrentUser(); + if (!signature) { + throw new Web3WalletActionError('signatureUnavailable'); + } + await prepared.attemptVerification({ signature }); + }), + setPrimary: walletId => + runAction(async () => { + const current = requireCurrentUser(); + if (!current.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { + throw new Web3WalletActionError('providerUnavailable'); + } + await current.update({ primaryWeb3WalletId: walletId }); + }), + remove: walletId => + runAction(async () => { + const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); + if (!wallet) { + throw new Web3WalletActionError('providerUnavailable'); + } + await wallet.destroy(); + }), + }; +} 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 a6b3acfccc1..72bd555e6ab 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,129 +1,41 @@ -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/use-mosaic-environment'; -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'; import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; -import { projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; +import { useUserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.model'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; export interface UserProfileWeb3WalletsSectionProps { fallback?: ReactNode; fallbackFocus?: () => HTMLElement | null; } -// TODO: Adopt the shared localized error handling from #9844 once it lands on main, -// preserving Clerk error codes for wallet-signature failures. -function actionError(error: unknown, fallbackMessage: string): Error { - if (isClerkAPIResponseError(error)) { - const first = error.errors[0]; - return new Error(first?.longMessage || first?.message || fallbackMessage); - } - 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(); - 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); - - // TODO: Add session reverification for wallet connection, primary updates, and removal; - // surface API errors until then. - 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(m.errors.providerUnavailable); - } - - try { - const web3 = createWeb3(manager); - const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); - if (!identifier) { - throw new Error(m.errors.extensionUnavailable); - } - const wallet = await user.createWeb3Wallet({ web3Wallet: identifier }); - if (!wallet) { - throw new Error(m.errors.creationFailed); - } - const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); - const nonce = prepared.verification.message; - if (!nonce) { - throw new Error(m.errors.messageUnavailable); - } - const signature = await web3.generateWeb3Signature({ - identifier, - nonce, - provider: provider.provider, - walletName, - }); - if (!signature) { - throw new Error(m.errors.signatureUnavailable); - } - await prepared.attemptVerification({ signature }); - } catch (error) { - throw actionError(error, m.errors.generic); - } - }; - - const setPrimary = async (walletId: string) => { - if (!user?.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { - return; - } - try { - await user.update({ primaryWeb3WalletId: walletId }); - } catch (error) { - throw actionError(error, m.errors.generic); - } - }; - - const remove = async (walletId: string) => { - try { - await user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(); - } catch (error) { - throw actionError(error, m.errors.generic); - } - }; - - const controller = useUserProfileWeb3WalletsController({ - wallets: projection.status === 'ready' ? projection.wallets : [], - availableProviders: projection.status === 'ready' ? projection.availableProviders : [], - connect, - setPrimary, - fallbackErrorMessage: m.errors.generic, - }); - - if (!isLoaded || !environment) { + const model = useUserProfileWeb3WalletsModel(); + if (model.status === 'loading') { return fallback ?? null; } - if (!environment.userSettings.attributes.web3_wallet?.enabled) { - return null; - } - if (projection.status === 'hidden') { + if (model.status === 'hidden') { return null; } + return ( + + ); +} +function Web3Wallets({ + model, + fallbackFocus, +}: { + model: ReadyWeb3WalletsModel; + fallbackFocus?: () => HTMLElement | null; +}) { + const controller = useUserProfileWeb3WalletsController(model); return ( <> { void controller.onSetPrimary(id); }} - onRemove={remove} + onRemove={model.remove} /> provider.id === 'web3_solana_signature')?.connectError} + pendingWalletName={controller.pendingWalletName} + error={controller.solanaPickerError} onOpenChange={open => { if (!open) { controller.closeSolanaPicker(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts new file mode 100644 index 00000000000..43d24ffebae --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -0,0 +1,34 @@ +import type { Web3Strategy } from '@clerk/shared/types'; + +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; + +export type UserProfileWeb3WalletsModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + userId: string; + wallets: UserProfileWeb3Wallet[]; + availableProviders: UserProfileWeb3Provider[]; + connect: (strategy: Web3Strategy, walletName?: string) => Promise; + setPrimary: (walletId: string) => Promise; + remove: (walletId: string) => Promise; + }; + +export type ReadyWeb3WalletsModel = Extract; + +export class Web3WalletActionError extends Error { + constructor( + readonly code: + | 'providerUnavailable' + | 'extensionUnavailable' + | 'creationFailed' + | 'messageUnavailable' + | 'signatureUnavailable', + message: string = code, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'Web3WalletActionError'; + } +} diff --git a/packages/mosaic/src/hooks/__tests__/use-pending-action-by-id.test.tsx b/packages/mosaic/src/hooks/__tests__/use-pending-action-by-id.test.tsx new file mode 100644 index 00000000000..154d97a0f52 --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-pending-action-by-id.test.tsx @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { clerkApiError } from '../../__tests__/clerk-errors'; +import { usePendingActionById } from '../use-pending-action-by-id'; + +describe('usePendingActionById', () => { + it('refuses competing actions and clears a failed action on retry', async () => { + const pending = Promise.withResolvers(); + const execute = vi.fn(() => pending.promise); + const competing = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => usePendingActionById('Try again.')); + act(() => { + void result.current.run('first', execute); + void result.current.run('second', competing); + }); + expect(execute).toHaveBeenCalledOnce(); + expect(competing).not.toHaveBeenCalled(); + expect(result.current.pendingId).toBe('first'); + await act(async () => { + pending.reject(clerkApiError('connection_failed', 'Could not connect.')); + await pending.promise.catch(() => undefined); + }); + expect(result.current.pendingId).toBeUndefined(); + expect(result.current.errors.first).toBe('Could not connect.'); + await act(async () => { + await result.current.run('first', () => Promise.resolve()); + }); + expect(result.current.errors.first).toBeUndefined(); + }); + + it('holds the pending action until a redirect grace period completes', async () => { + vi.useFakeTimers(); + try { + const { result } = renderHook(() => usePendingActionById('Try again.')); + act(() => { + void result.current.run('oauth', () => new Promise(resolve => setTimeout(resolve, 2000))); + }); + await act(() => vi.advanceTimersByTimeAsync(1999)); + expect(result.current.pendingId).toBe('oauth'); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(result.current.pendingId).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/mosaic/src/hooks/use-pending-action-by-id.ts b/packages/mosaic/src/hooks/use-pending-action-by-id.ts new file mode 100644 index 00000000000..e410f3acde0 --- /dev/null +++ b/packages/mosaic/src/hooks/use-pending-action-by-id.ts @@ -0,0 +1,34 @@ +import { useRef, useState } from 'react'; + +import type { ErrorDescription } from '../localization'; +import { useErrorText } from '../localization'; +import { toLocalizableError } from '../utils/errors'; + +export function usePendingActionById(fallbackMessage: string) { + const errorText = useErrorText(); + const [pendingId, setPendingId] = useState(); + const [errors, setErrors] = useState>({}); + const inFlight = useRef(false); + + const run = async (id: string, action: () => Promise): Promise => { + if (inFlight.current) { + return false; + } + inFlight.current = true; + setPendingId(id); + setErrors(({ [id]: _previous, ...rest }) => rest); + try { + await action(); + return true; + } catch (error) { + setErrors(current => ({ ...current, [id]: toLocalizableError(error) })); + return false; + } finally { + inFlight.current = false; + setPendingId(undefined); + } + }; + + const messages = Object.fromEntries(Object.entries(errors).map(([id, error]) => [id, errorText(error, fallbackMessage)])); + return { pendingId, errors: messages, busy: () => inFlight.current, run }; +} diff --git a/packages/shared/src/__tests__/web3.spec.ts b/packages/shared/src/__tests__/web3.spec.ts new file mode 100644 index 00000000000..57e56bc8840 --- /dev/null +++ b/packages/shared/src/__tests__/web3.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { isSolanaSignInWallet } from '../web3'; + +describe('isSolanaSignInWallet', () => { + it.each([ + { chains: ['solana:mainnet'], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: true }, + { chains: ['solana:mainnet'], features: { 'solana:signMessage': {} }, eligible: false }, + { chains: ['solana:mainnet'], features: { 'standard:connect': {} }, eligible: false }, + { chains: ['eip155:1'], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: false }, + { chains: [], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: false }, + ] as const)('requires a Solana chain and both sign-in capabilities for $chains and $features', wallet => { + expect(isSolanaSignInWallet(wallet)).toBe(wallet.eligible); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/useInstalledSolanaWallets.spec.tsx b/packages/shared/src/react/hooks/__tests__/useInstalledSolanaWallets.spec.tsx new file mode 100644 index 00000000000..81426fefec1 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useInstalledSolanaWallets.spec.tsx @@ -0,0 +1,102 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { Wallet } from '@wallet-standard/core'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useInstalledSolanaWallets } from '../useInstalledSolanaWallets'; + +const registry = vi.hoisted(() => { + const wallets: Wallet[] = []; + const listeners = { register: new Set<() => void>(), unregister: new Set<() => void>() }; + const getWallets = vi.fn(() => ({ + get: () => wallets, + on: (event: 'register' | 'unregister', listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + })); + return { wallets, listeners, getWallets }; +}); + +vi.mock('@wallet-standard/core', () => ({ getWallets: registry.getWallets })); + +function wallet( + name: string, + features: Wallet['features'] = { 'standard:connect': {}, 'solana:signMessage': {} }, +): Wallet { + return { + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features, + }; +} + +describe('useInstalledSolanaWallets', () => { + beforeEach(() => { + registry.wallets.splice(0); + registry.listeners.register.clear(); + registry.listeners.unregister.clear(); + registry.getWallets.mockClear(); + }); + + it('renders an empty server snapshot without initializing the browser registry', () => { + registry.wallets.push(wallet('Phantom')); + function WalletNames() { + return createElement( + 'div', + null, + useInstalledSolanaWallets() + .map(wallet => wallet.name) + .join(','), + ); + } + + expect(renderToString(createElement(WalletNames))).toBe('
'); + expect(registry.getWallets).not.toHaveBeenCalled(); + }); + + it('starts empty and projects only eligible wallet names and icons after loading the registry', async () => { + registry.wallets.push(wallet('Phantom'), wallet('Signer only', { 'solana:signMessage': {} })); + const { result } = renderHook(useInstalledSolanaWallets); + + expect(result.current).toEqual([]); + await waitFor(() => expect(result.current).toEqual([{ name: 'Phantom', icon: 'data:image/svg+xml;base64,' }])); + }); + + it('refreshes on registration and unregistration and removes both subscriptions on unmount', async () => { + const { result, unmount } = renderHook(useInstalledSolanaWallets); + await waitFor(() => expect(registry.listeners.register.size).toBe(1)); + + act(() => { + registry.wallets.push(wallet('Backpack')); + registry.listeners.register.forEach(listener => listener()); + }); + expect(result.current).toEqual([{ name: 'Backpack', icon: 'data:image/svg+xml;base64,' }]); + + act(() => { + registry.wallets.splice(0); + registry.listeners.unregister.forEach(listener => listener()); + }); + expect(result.current).toEqual([]); + + unmount(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); + + it('does not initialize or subscribe when unmounted before the module loads', async () => { + const { unmount } = renderHook(useInstalledSolanaWallets); + unmount(); + await act(async () => { + await vi.dynamicImportSettled(); + }); + + expect(registry.getWallets).not.toHaveBeenCalled(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); +}); diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index abe5b258c87..c10c40f43a5 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -16,6 +16,8 @@ export { useSafeLayoutEffect } from './useSafeLayoutEffect'; export { useSession } from './useSession'; export { useSessionList } from './useSessionList'; export { useUser } from './useUser'; +export { useInstalledSolanaWallets } from './useInstalledSolanaWallets'; +export type { InstalledSolanaWallet } from './useInstalledSolanaWallets'; export { useClerk } from './useClerk'; export { useDeepEqualMemo, isDeeplyEqual } from './useDeepEqualMemo'; export { useReverification } from './useReverification'; diff --git a/packages/shared/src/react/hooks/useInstalledSolanaWallets.ts b/packages/shared/src/react/hooks/useInstalledSolanaWallets.ts new file mode 100644 index 00000000000..a2328e8b503 --- /dev/null +++ b/packages/shared/src/react/hooks/useInstalledSolanaWallets.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; + +import { isSolanaSignInWallet } from '../../web3'; + +export type InstalledSolanaWallet = { name: string; icon: string }; + +export function useInstalledSolanaWallets(): readonly InstalledSolanaWallet[] { + const [wallets, setWallets] = useState([]); + + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | undefined; + + void import('@wallet-standard/core').then(({ getWallets }) => { + if (disposed) { + return; + } + + const registry = getWallets(); + const update = () => { + setWallets( + registry + .get() + .filter(isSolanaSignInWallet) + .map(({ name, icon }) => ({ name, icon })), + ); + }; + const offRegister = registry.on('register', update); + const offUnregister = registry.on('unregister', update); + unsubscribe = () => { + offRegister(); + offUnregister(); + }; + update(); + }); + + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); + + return wallets; +} diff --git a/packages/shared/src/utils/__tests__/sortIdentificationBasedOnVerification.test.ts b/packages/shared/src/utils/__tests__/sortIdentificationBasedOnVerification.test.ts new file mode 100644 index 00000000000..af9f7c880a6 --- /dev/null +++ b/packages/shared/src/utils/__tests__/sortIdentificationBasedOnVerification.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import type { VerificationResource } from '../../types'; +import { sortIdentificationBasedOnVerification } from '../sortIdentificationBasedOnVerification'; + +function identification(id: string, status: VerificationResource['status'], expireAt: Date | null = null) { + return { id, verification: { status, expireAt } }; +} + +describe('identification ordering', () => { + it('orders primary, verified, expiring unverified, and absent verification without mutating the input', () => { + const input = Object.freeze([ + identification('missing', null), + identification('later', 'unverified', new Date(200)), + identification('verified-b', 'verified'), + identification('primary', 'unverified'), + identification('earlier', 'unverified', new Date(100)), + identification('verified-a', 'verified'), + ]); + const original = [...input]; + + const sorted = sortIdentificationBasedOnVerification(input, 'primary'); + + expect(sorted.map(item => item.id)).toEqual(['primary', 'verified-a', 'verified-b', 'earlier', 'later', 'missing']); + expect(input).toEqual(original); + expect(sorted[0]).toBe(input[3]); + }); + + it('keeps unverified identifications without expiry in their existing order', () => { + const input = [identification('b', 'unverified'), identification('a', 'failed')]; + + expect(sortIdentificationBasedOnVerification(input, null)).toEqual(input); + expect(sortIdentificationBasedOnVerification(undefined, null)).toEqual([]); + }); +}); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index d6104a73397..80080888604 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -8,3 +8,4 @@ export { handleValueOrFn } from './handleValueOrFn'; export { runIfFunctionOrReturn } from './runIfFunctionOrReturn'; export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge'; export { timeLimit } from './timeLimit'; +export { sortIdentificationBasedOnVerification } from './sortIdentificationBasedOnVerification'; diff --git a/packages/shared/src/utils/sortIdentificationBasedOnVerification.ts b/packages/shared/src/utils/sortIdentificationBasedOnVerification.ts new file mode 100644 index 00000000000..a344f306ec6 --- /dev/null +++ b/packages/shared/src/utils/sortIdentificationBasedOnVerification.ts @@ -0,0 +1,30 @@ +import type { VerificationResource } from '../types'; + +type Identification = { + id: string; + verification: Pick; +}; + +export function sortIdentificationBasedOnVerification( + identifications: readonly T[] | null | undefined, + primaryId: string | null | undefined, +): T[] { + if (!identifications) { + return []; + } + + const primary = identifications.filter(item => item.id === primaryId); + const remaining = identifications.filter(item => item.id !== primaryId); + const verified = remaining.filter(item => item.verification.status === 'verified'); + const unverified = remaining.filter(item => item.verification.status && item.verification.status !== 'verified'); + const withoutStatus = remaining.filter(item => !item.verification.status); + + verified.sort((first, second) => first.id.localeCompare(second.id)); + unverified.sort((first, second) => { + const firstExpiry = first.verification.expireAt; + const secondExpiry = second.verification.expireAt; + return firstExpiry && secondExpiry ? firstExpiry.getTime() - secondExpiry.getTime() : 0; + }); + + return [...primary, ...verified, ...unverified, ...withoutStatus]; +} diff --git a/packages/shared/src/web3.ts b/packages/shared/src/web3.ts index d630edf5f88..b079c919758 100644 --- a/packages/shared/src/web3.ts +++ b/packages/shared/src/web3.ts @@ -1,5 +1,16 @@ import type { Web3ProviderData } from './types'; +export function isSolanaSignInWallet(wallet: { + chains: readonly string[]; + features: Readonly>; +}): boolean { + return ( + wallet.chains.some(chain => chain.startsWith('solana:')) && + 'standard:connect' in wallet.features && + 'solana:signMessage' in wallet.features + ); +} + export const WEB3_PROVIDERS: Web3ProviderData[] = [ { provider: 'metamask', diff --git a/packages/ui/src/components/UserProfile/EmailsSection.tsx b/packages/ui/src/components/UserProfile/EmailsSection.tsx index e37666e7a72..de0eea8fd00 100644 --- a/packages/ui/src/components/UserProfile/EmailsSection.tsx +++ b/packages/ui/src/components/UserProfile/EmailsSection.tsx @@ -1,5 +1,6 @@ import { useReverification, useUser } from '@clerk/shared/react'; import type { EmailAddressResource } from '@clerk/shared/types'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { Fragment } from 'react'; import { useCardState } from '@/ui/elements/contexts'; @@ -7,7 +8,6 @@ import { ProfileSection } from '@/ui/elements/Section'; import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; import { handleError } from '@/ui/utils/errorHandler'; -import { sortIdentificationBasedOnVerification } from '../../components/UserProfile/utils'; import { Badge, Flex, localizationKeys, Text } from '../../customizables'; import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; diff --git a/packages/ui/src/components/UserProfile/PhoneSection.tsx b/packages/ui/src/components/UserProfile/PhoneSection.tsx index f4dc543f6f1..376fbbe8aba 100644 --- a/packages/ui/src/components/UserProfile/PhoneSection.tsx +++ b/packages/ui/src/components/UserProfile/PhoneSection.tsx @@ -1,5 +1,6 @@ import { useReverification, useUser } from '@clerk/shared/react'; import type { PhoneNumberResource, UserResource } from '@clerk/shared/types'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { Fragment } from 'react'; import { useCardState } from '@/ui/elements/contexts'; @@ -14,7 +15,6 @@ import { useActionContext } from '../../elements/Action/ActionRoot'; import type { PropsOfComponent } from '../../styledSystem'; import { PhoneForm } from './PhoneForm'; import { RemovePhoneForm } from './RemoveResourceForm'; -import { sortIdentificationBasedOnVerification } from './utils'; type RemovePhoneScreenProps = { phoneId: string }; const RemovePhoneScreen = (props: RemovePhoneScreenProps) => { diff --git a/packages/ui/src/components/UserProfile/Web3Section.tsx b/packages/ui/src/components/UserProfile/Web3Section.tsx index b20a2314e20..00166275a15 100644 --- a/packages/ui/src/components/UserProfile/Web3Section.tsx +++ b/packages/ui/src/components/UserProfile/Web3Section.tsx @@ -1,4 +1,5 @@ import { useReverification, useUser } from '@clerk/shared/react'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { Fragment, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -14,7 +15,6 @@ import { useActionContext } from '../../elements/Action/ActionRoot'; import { useEnabledThirdPartyProviders } from '../../hooks'; import type { PropsOfComponent } from '../../styledSystem'; import { RemoveWeb3WalletForm } from './RemoveResourceForm'; -import { sortIdentificationBasedOnVerification } from './utils'; import { AddWeb3WalletActionMenu } from './Web3Form'; type RemoveWeb3WalletScreenProps = { walletId: string }; diff --git a/packages/ui/src/components/UserProfile/__tests__/utils.test.ts b/packages/ui/src/components/UserProfile/__tests__/utils.test.ts index 9e7cd95b662..36c8e0c203c 100644 --- a/packages/ui/src/components/UserProfile/__tests__/utils.test.ts +++ b/packages/ui/src/components/UserProfile/__tests__/utils.test.ts @@ -1,19 +1,11 @@ -import type { EmailAddressResource, PhoneNumberResource } from '@clerk/shared/types'; +import type { VerificationResource } from '@clerk/shared/types'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; import { describe, expect, it } from 'vitest'; -import { sortIdentificationBasedOnVerification } from '../utils'; - -const email = (id: string, status: string, expireAtMs = 0) => - ({ - id, - verification: { status, expireAt: new Date(expireAtMs) }, - }) as EmailAddressResource; - -const phone = (id: string, status: string, expireAtMs = 0) => - ({ - id, - verification: { status, expireAt: new Date(expireAtMs) }, - }) as PhoneNumberResource; +const identification = (id: string, status: VerificationResource['status'], expireAtMs = 0) => ({ + id, + verification: { status, expireAt: new Date(expireAtMs) }, +}); describe('UserProfile utils', () => { describe('sortIdentificationBasedOnVerification', () => { @@ -32,11 +24,11 @@ describe('UserProfile utils', () => { `1) primary, 2) verified (sorted alphabetically by id), 3) unverified (sorted by expiresAt verification property)`, () => { const input = [ - email('1', 'unverified', 200), - email('2', 'verified'), - email('3', 'verified'), - email('4', 'verified'), - email('5', 'unverified', 100), + identification('1', 'unverified', 200), + identification('2', 'verified'), + identification('3', 'verified'), + identification('4', 'verified'), + identification('5', 'unverified', 100), ]; const result = sortIdentificationBasedOnVerification(input, '3'); expect(result[0].id).toEqual('3'); @@ -52,11 +44,11 @@ describe('UserProfile utils', () => { `1) primary, 2) verified (sorted alphabetically by id), 3) unverified (sorted by expiresAt verification property)`, () => { const input = [ - phone('1', 'unverified', 200), - phone('2', 'verified'), - phone('3', 'verified'), - phone('4', 'verified'), - phone('5', 'unverified', 100), + identification('1', 'unverified', 200), + identification('2', 'verified'), + identification('3', 'verified'), + identification('4', 'verified'), + identification('5', 'unverified', 100), ]; const result = sortIdentificationBasedOnVerification(input, '3'); @@ -70,11 +62,11 @@ describe('UserProfile utils', () => { it('should return the correct order if the primaryId is not in the array', () => { const input = [ - phone('1', 'unverified', 200), - phone('2', 'verified'), - phone('3', 'verified'), - phone('4', 'verified'), - phone('5', 'unverified', 100), + identification('1', 'unverified', 200), + identification('2', 'verified'), + identification('3', 'verified'), + identification('4', 'verified'), + identification('5', 'unverified', 100), ]; const result = sortIdentificationBasedOnVerification(input, '10'); @@ -87,12 +79,12 @@ describe('UserProfile utils', () => { it('should return last the item without verification status', () => { const input = [ - phone('1', 'unverified', 200), - phone('2', 'verified'), - phone('3', 'verified'), - phone('4', 'verified'), - phone('5', 'unverified', 100), - phone('6', ''), + identification('1', 'unverified', 200), + identification('2', 'verified'), + identification('3', 'verified'), + identification('4', 'verified'), + identification('5', 'unverified', 100), + identification('6', null), ]; const result = sortIdentificationBasedOnVerification(input, '3'); diff --git a/packages/ui/src/components/UserProfile/utils.ts b/packages/ui/src/components/UserProfile/utils.ts index 13ca67cce73..964c4b1b265 100644 --- a/packages/ui/src/components/UserProfile/utils.ts +++ b/packages/ui/src/components/UserProfile/utils.ts @@ -1,4 +1,4 @@ -import type { AttributeData, EmailAddressResource, PhoneNumberResource, Web3WalletResource } from '@clerk/shared/types'; +import type { AttributeData } from '@clerk/shared/types'; /** * An attribute is "available" in the UserProfile if it's enabled for sign-up @@ -17,31 +17,3 @@ export const primaryIdentificationFirst = (primaryId: string | null) => (val1: I }; export const currentSessionFirst = (id: string) => (a: IDable) => (a.id === id ? -1 : 1); - -export function sortIdentificationBasedOnVerification< - T extends Array, ->(array: T | null | undefined, primaryId: string | null | undefined): T { - if (!array) { - return [] as unknown as T; - } - const primaryItem = array.filter(item => item.id === primaryId); - const itemsWithoutPrimary = array.filter(item => item.id !== primaryId); - const verifiedItems = itemsWithoutPrimary.filter(item => item.verification?.status === 'verified'); - const unverifiedItems = itemsWithoutPrimary.filter( - item => !!item.verification?.status && item.verification?.status !== 'verified', - ); - const unverifiedItemsWithoutVerification = itemsWithoutPrimary.filter(item => !item.verification.status); - - // Sorting verified items alphabetically by name - verifiedItems.sort((a, b) => a.id.localeCompare(b.id)); - - // Sorting unverified items by expireAt, most recent last - unverifiedItems.sort((a, b) => { - if (!a.verification?.expireAt || !b.verification?.expireAt) { - return 0; - } - return a.verification.expireAt.getTime() - b.verification.expireAt.getTime(); - }); - - return [...primaryItem, ...verifiedItems, ...unverifiedItems, ...unverifiedItemsWithoutVerification] as T; -} diff --git a/packages/ui/src/elements/Web3SolanaWalletButtons.tsx b/packages/ui/src/elements/Web3SolanaWalletButtons.tsx index 8ea9c60b9ce..e141949ebcf 100644 --- a/packages/ui/src/elements/Web3SolanaWalletButtons.tsx +++ b/packages/ui/src/elements/Web3SolanaWalletButtons.tsx @@ -1,6 +1,6 @@ -import { getWallets, type Wallet } from '@wallet-standard/core'; +import { useInstalledSolanaWallets } from '@clerk/shared/react'; import type { Ref } from 'react'; -import React, { forwardRef, isValidElement, useEffect, useMemo, useState } from 'react'; +import React, { forwardRef, isValidElement } from 'react'; import { WalletInitialIcon } from '@/ui/common/WalletInitialIcon'; import { @@ -31,34 +31,6 @@ const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2; const SOCIAL_BUTTON_PRE_TEXT_THRESHOLD = 1; const MAX_STRATEGIES_PER_ROW = 5; -const isSolanaSignInWallet = (wallet: Wallet) => - wallet.chains.some(chain => chain.startsWith('solana:')) && - 'standard:connect' in wallet.features && - 'solana:signMessage' in wallet.features; - -const getRegisteredWallets = () => (typeof window === 'undefined' ? [] : getWallets().get()); - -const useInstalledSolanaWallets = () => { - const [wallets, setWallets] = useState(getRegisteredWallets); - - useEffect(() => { - const registry = getWallets(); - const update = () => setWallets(registry.get()); - update(); - const offRegister = registry.on('register', update); - const offUnregister = registry.on('unregister', update); - return () => { - offRegister(); - offUnregister(); - }; - }, []); - - return useMemo( - () => wallets.filter(isSolanaSignInWallet).map(wallet => ({ name: wallet.name, icon: wallet.icon })), - [wallets], - ); -}; - export const Web3SolanaWalletButtons = ({ web3AuthCallback }: Web3WalletButtonsProps) => { const card = useCardState(); const installedWallets = useInstalledSolanaWallets(); @@ -75,7 +47,7 @@ export const Web3SolanaWalletButtons = ({ web3AuthCallback }: Web3WalletButtonsP } }; - const { strategyRows } = distributeStrategiesIntoRows(installedWallets, MAX_STRATEGIES_PER_ROW, undefined); + const { strategyRows } = distributeStrategiesIntoRows([...installedWallets], MAX_STRATEGIES_PER_ROW, undefined); const strategyRowOneLength = strategyRows.at(0)?.length ?? 0; const shouldForceSingleColumnOnMobile = installedWallets.length === 2; const ButtonElement = installedWallets.length <= SOCIAL_BUTTON_BLOCK_THRESHOLD ? WalletButtonBlock : WalletButtonIcon; diff --git a/packages/ui/src/elements/__tests__/Web3SolanaWalletButtons.test.tsx b/packages/ui/src/elements/__tests__/Web3SolanaWalletButtons.test.tsx index ffb2a9f0fa4..ea296d8644b 100644 --- a/packages/ui/src/elements/__tests__/Web3SolanaWalletButtons.test.tsx +++ b/packages/ui/src/elements/__tests__/Web3SolanaWalletButtons.test.tsx @@ -1,6 +1,6 @@ -import type { Wallet } from '@wallet-standard/core'; +import type { Wallet, WindowAppReadyEventAPI } from '@wallet-standard/core'; import { act } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen } from '@/test/utils'; @@ -8,30 +8,32 @@ import { render, screen } from '@/test/utils'; import { withCardStateProvider } from '../contexts'; import { Web3SolanaWalletButtons } from '../Web3SolanaWalletButtons'; -const registry = vi.hoisted(() => ({ - wallets: [] as Wallet[], - listeners: { register: new Set<() => void>(), unregister: new Set<() => void>() }, -})); - -vi.mock('@wallet-standard/core', () => ({ - getWallets: () => ({ - get: () => registry.wallets, - on: (event: 'register' | 'unregister', listener: () => void) => { - registry.listeners[event].add(listener); - return () => registry.listeners[event].delete(listener); - }, - }), -})); - -const makeWallet = (name: string, chains: string[], features: string[]): Wallet => - ({ - name, - icon: 'data:image/svg+xml;base64,', - version: '1.0.0', - chains, - accounts: [], - features: Object.fromEntries(features.map(feature => [feature, {}])), - }) as unknown as Wallet; +let installedWallets: Wallet[] = []; +const unregister: Array<() => void> = []; + +function register(api: WindowAppReadyEventAPI) { + unregister.push(api.register(...installedWallets)); +} + +function onAppReady(event: Event & { detail?: WindowAppReadyEventAPI }) { + if (event.detail) { + register(event.detail); + } +} + +function installWallets(wallets: Wallet[]) { + installedWallets = wallets; + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); +} + +const makeWallet = (name: string, chains: Wallet['chains'], features: string[]): Wallet => ({ + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains, + accounts: [], + features: Object.fromEntries(features.map(feature => [feature, {}])), +}); const SIGN_IN_FEATURES = ['standard:connect', 'solana:signMessage']; @@ -41,18 +43,22 @@ const Buttons = withCardStateProvider(Web3SolanaWalletButtons); describe('Web3SolanaWalletButtons', () => { beforeEach(() => { - registry.wallets = []; - registry.listeners.register.clear(); - registry.listeners.unregister.clear(); + installedWallets = []; + window.addEventListener('wallet-standard:app-ready', onAppReady); + }); + + afterEach(() => { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.splice(0).forEach(off => off()); }); it('lists only Solana wallets that can connect and sign messages', async () => { - registry.wallets = [ + installWallets([ makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES), makeWallet('Solana Viewer', ['solana:mainnet'], ['standard:connect']), makeWallet('Solana Signer', ['solana:mainnet'], ['solana:signMessage']), makeWallet('MetaMask', ['eip155:1'], SIGN_IN_FEATURES), - ]; + ]); const { wrapper } = await createFixtures(); render(, { wrapper }); @@ -71,15 +77,20 @@ describe('Web3SolanaWalletButtons', () => { expect(await screen.findByText(/No Solana Web3 wallets detected/)).toBeInTheDocument(); act(() => { - registry.wallets = [makeWallet('Backpack', ['solana:mainnet'], SIGN_IN_FEATURES)]; - registry.listeners.register.forEach(listener => listener()); + installWallets([makeWallet('Backpack', ['solana:mainnet'], SIGN_IN_FEATURES)]); }); expect(await screen.findByText('Continue with Backpack')).toBeInTheDocument(); + + act(() => { + installedWallets = []; + unregister.splice(0).forEach(off => off()); + }); + expect(await screen.findByText(/No Solana Web3 wallets detected/)).toBeInTheDocument(); }); it('passes the chosen wallet name to the auth callback', async () => { - registry.wallets = [makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES)]; + installWallets([makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES)]); const web3AuthCallback = vi.fn().mockResolvedValue(undefined); const { wrapper } = await createFixtures(); From 51045b6523692e93c6453ef6d58532f324208925 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:37:38 -0600 Subject: [PATCH 58/86] test(mosaic): cover wallet retries and immutable settings --- ...file-web3-wallets-section.feature.test.tsx | 156 +++++++++++++++++- 1 file changed, 151 insertions(+), 5 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 a556f4909bc..68755b603f2 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 @@ -1,5 +1,5 @@ import type { UserJSON } from '@clerk/shared/types'; -import { act, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, 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'; @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, + fapiEnterpriseAccount, fapiEnvironment, fapiSession, fapiUser, @@ -128,6 +129,100 @@ describe('Web3 wallets', () => { 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(); + expect(await screen.findByText('Primary')).toBeInTheDocument(); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe( + fapi.client.sessions[0]?.user.web3_wallets[0]?.id, + ); + }); + + it('reuses the created Ethereum wallet when Connect is retried after signature rejection', async () => { + const address = '0xabcdef1234567890'; + let accountRequests = 0; + let signatureRequests = 0; + vi.stubGlobal('ethereum', { + request: vi.fn(({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') { + accountRequests += 1; + return Promise.resolve([accountRequests === 1 ? address : address.toUpperCase().replace('0X', '0x')]); + } + if (method === 'personal_sign') { + signatureRequests += 1; + return signatureRequests === 1 + ? Promise.reject(new Error('Signature rejected')) + : Promise.resolve('signature'); + } + throw new Error(`Unexpected wallet method: ${method}`); + }), + }); + const fapi = await renderWeb3(); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.release(); + expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + const pendingId = fapi.client.sessions[0]?.user.web3_wallets[0]?.id; + expect(pendingId).toBeDefined(); + expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified')); + expect(creation.requests).toHaveLength(1); + expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual([pendingId]); + expect(screen.getByText('Primary')).toBeInTheDocument(); + }); + + it('hides immutable wallet mutations but still permits setting a verified wallet as primary', async () => { + const environment = web3Environment(); + serveFapi({ + environment: fapiEnvironment({ + user_settings: { + attributes: { + ...environment.user_settings.attributes, + web3_wallet: { + ...environment.user_settings.attributes.web3_wallet, + immutable: true, + first_factors: ['web3_metamask_signature', 'web3_coinbase_wallet_signature'], + }, + }, + }, + }), + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ + id: 'user_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }), + }), + ]), + }); + await renderWithClerk(); + + expect(screen.queryByRole('button', { name: 'Connect Coinbase Wallet' })).toBeNull(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Manage MetaMask' })); + expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Remove wallet' })).toBeNull(); + }); + + it('hides an immutable Web3 section without saved wallets', async () => { + const environment = web3Environment(); + serveFapi({ + environment: fapiEnvironment({ + user_settings: { + attributes: { + ...environment.user_settings.attributes, + web3_wallet: { ...environment.user_settings.attributes.web3_wallet, immutable: true }, + }, + }, + }), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk(); + + expect(screen.queryByRole('group', { name: 'Web3 wallets' })).toBeNull(); }); it('shows a localized fallback when the wallet provider rejects without a message', async () => { @@ -211,6 +306,43 @@ describe('Web3 wallets', () => { }, ); + it('rechecks creation policy after the wallet provider prompt', async () => { + const accountRequest = Promise.withResolvers(); + vi.stubGlobal('ethereum', { request: vi.fn(() => accountRequest.promise) }); + const environment = web3Environment(); + const fapi = serveFapi({ + environment: fapiEnvironment({ + user_settings: { + ...environment.user_settings, + enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + }, + }), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ + id: 'sess_2', + user: fapiUser({ id: 'user_1', enterprise_accounts: [fapiEnterpriseAccount({ id: 'sso_1' })] }), + }), + ]), + }); + const { clerk } = await renderWithClerk(); + const initialUser = clerk.user; + if (!initialUser) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(initialUser, 'createWeb3Wallet'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await act(() => clerk.setActive({ session: 'sess_2' })); + await act(async () => { + accountRequest.resolve(['0x1234567890abcdef']); + await accountRequest.promise; + }); + + expect(create).not.toHaveBeenCalled(); + expect(fapi.client.sessions[1]?.user.web3_wallets).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).toBeNull(); + }); + it('clears the previous user’s provider error and open wallet picker on session change', async () => { vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error('First user wallet failure'))) }); serveFapi({ @@ -244,7 +376,11 @@ describe('Web3 wallets', () => { const hold = holdRequests('post', '/v1/me/web3_wallets'); const user = userEvent.setup(); - await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + const metamaskButton = await screen.findByRole('button', { name: 'Connect MetaMask' }); + act(() => { + fireEvent.click(metamaskButton); + fireEvent.click(screen.getByRole('button', { name: 'Connect Solana' })); + }); 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(); @@ -258,6 +394,7 @@ describe('Web3 wallets', () => { expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'), ); expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).toBeNull(); }); it('sets a verified wallet as primary through Clerk', async () => { @@ -310,6 +447,7 @@ describe('Web3 wallets', () => { 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' })], + primary_web3_wallet_id: 'wallet_1', }); const user = userEvent.setup(); @@ -319,6 +457,7 @@ describe('Web3 wallets', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBeNull(); await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveFocus()); }); @@ -353,21 +492,24 @@ describe('Web3 wallets', () => { fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), fapiWeb3Wallet({ id: 'wallet_2', web3_wallet: '0xabcdef1234567890' }), ], + primary_web3_wallet_id: 'wallet_2', }); const user = userEvent.setup(); - const first = screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]; + const first = screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]; 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.getAllByRole('button', { name: 'Manage MetaMask' })[0]); 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']); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_1'); + expect(screen.getByText('Primary')).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); }); @@ -482,7 +624,11 @@ describe('Web3 wallets', () => { 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' })); + screen.getByRole('button', { name: 'Second Solana' }).focus(); + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Second Solana' })); + fireEvent.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: 'Second Solana' })).toHaveFocus(); From b9a714a3172a4493368575b40a94055663461b5e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:37:45 -0600 Subject: [PATCH 59/86] test(mosaic): mirror primary-wallet updates in fake FAPI --- packages/mosaic/src/__tests__/feature/fake-fapi.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 723fe711d3d..0ecc80cc4be 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -309,6 +309,10 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { }), }); updateWeb3Wallet(state, verified); + const user = activeUser(state); + if (user && !user.primary_web3_wallet_id) { + updateUser(state, { ...user, primary_web3_wallet_id: verified.id }); + } return envelope(verified, state.client); }), http.post(fapiUrl('/v1/me/web3_wallets/:id'), ({ params, request }) => { @@ -320,7 +324,15 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { if (!user || !wallet) { return missing(); } - updateUser(state, { ...user, web3_wallets: user.web3_wallets.filter(current => current.id !== wallet.id) }); + const remainingWallets = user.web3_wallets.filter(current => current.id !== wallet.id); + updateUser(state, { + ...user, + web3_wallets: remainingWallets, + primary_web3_wallet_id: + user.primary_web3_wallet_id === wallet.id + ? (remainingWallets.find(current => current.verification?.status === 'verified')?.id ?? null) + : user.primary_web3_wallet_id, + }); return envelope({ object: 'web3_wallet', id: wallet.id, deleted: true }, state.client); }), http.post(fapiUrl('/v1/me'), async ({ request }) => { From ad353ecf7a755bc658058ff790ae276d7f02fe76 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:37:51 -0600 Subject: [PATCH 60/86] fix(mosaic): recover wallet connections and respect immutable settings --- .changeset/web3-wallets-wire-up.md | 2 +- ...profile-web3-wallets-section.model.test.ts | 8 ++- ...user-profile-web3-wallets-section.model.ts | 54 +++++++++++++++---- ...user-profile-web3-wallets-section.types.ts | 2 +- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md index 3bb43eba3a9..3903937ef86 100644 --- a/.changeset/web3-wallets-wire-up.md +++ b/.changeset/web3-wallets-wire-up.md @@ -3,4 +3,4 @@ '@clerk/shared': patch --- -Keep Web3 wallet action state scoped to the signed-in user and use localized API errors. Share Solana wallet discovery so only wallets supporting connection and message signing are offered. +Keep Web3 wallet action state scoped to the signed-in user and use localized API errors. A failed signature can be retried without removing the unverified wallet first, and immutable wallet settings hide unavailable Connect and Remove actions. Share Solana wallet discovery so only wallets supporting connection and message signing are offered. 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 index 197f4ef299b..ddc12a4c1e6 100644 --- 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 @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; +import { normalizedWeb3Wallet, projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; const metamask = { id: 'wallet_metamask', @@ -9,6 +9,12 @@ const metamask = { } as const; describe('Web3 wallet projection', () => { + it('matches the backend wallet sanitizer without folding Solana address case', () => { + expect(normalizedWeb3Wallet(' 0XAbCd ')).toBe('0xabcd'); + expect(normalizedWeb3Wallet(' SolanaAbCd ')).toBe('SolanaAbCd'); + expect(normalizedWeb3Wallet(' Solanaabcd ')).toBe('Solanaabcd'); + }); + it('WEB3-01 shows configured providers that do not have a verified wallet', () => { expect( projectWeb3Wallets({ 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 index 2606bfade32..2e0e74b67a8 100644 --- 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 @@ -21,6 +21,11 @@ export type Web3WalletsProjection = | { status: 'hidden' } | { status: 'ready'; wallets: UserProfileWeb3Wallet[]; availableProviders: UserProfileWeb3Provider[] }; +export function normalizedWeb3Wallet(identifier: string): string { + const trimmed = identifier.trim(); + return /^0x/i.test(trimmed) ? trimmed.toLowerCase() : trimmed; +} + export function projectWeb3Wallets({ wallets, primaryId, @@ -87,11 +92,13 @@ export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { if (!user || !environment.userSettings.attributes.web3_wallet?.enabled) { return { status: 'hidden' }; } + const web3Attribute = environment.userSettings.attributes.web3_wallet; const projection = projectWeb3Wallets({ wallets: user.web3Wallets, primaryId: user.primaryWeb3WalletId, enabledStrategies: environment.userSettings.web3FirstFactors, - allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), + allowCreation: + !web3Attribute.immutable && allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), }); if (projection.status === 'hidden') { return projection; @@ -129,7 +136,29 @@ export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { if (!identifier) { throw new Web3WalletActionError('extensionUnavailable'); } - const wallet = await requireCurrentUser().createWeb3Wallet({ web3Wallet: identifier }); + const current = requireCurrentUser(); + const currentProjection = projectWeb3Wallets({ + wallets: current.web3Wallets, + primaryId: current.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: + Boolean(environment.userSettings.attributes.web3_wallet?.enabled) && + !environment.userSettings.attributes.web3_wallet?.immutable && + allowsIdentificationCreation(current, environment.userSettings.enterpriseSSO), + }); + if ( + currentProjection.status !== 'ready' || + !currentProjection.availableProviders.some(candidate => candidate.id === strategy) + ) { + throw new Web3WalletActionError('providerUnavailable'); + } + const normalizedIdentifier = normalizedWeb3Wallet(identifier); + const existing = current.web3Wallets.find( + wallet => + wallet.verification.status !== 'verified' && + normalizedWeb3Wallet(wallet.web3Wallet) === normalizedIdentifier, + ); + const wallet = existing ?? (await current.createWeb3Wallet({ web3Wallet: identifier })); requireCurrentUser(); if (!wallet) { throw new Web3WalletActionError('creationFailed'); @@ -160,13 +189,18 @@ export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { } await current.update({ primaryWeb3WalletId: walletId }); }), - remove: walletId => - runAction(async () => { - const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); - if (!wallet) { - throw new Web3WalletActionError('providerUnavailable'); - } - await wallet.destroy(); - }), + remove: web3Attribute.immutable + ? undefined + : walletId => + runAction(async () => { + if (environment.userSettings.attributes.web3_wallet?.immutable) { + throw new Web3WalletActionError('providerUnavailable'); + } + const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); + if (!wallet) { + throw new Web3WalletActionError('providerUnavailable'); + } + await wallet.destroy(); + }), }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts index 43d24ffebae..cdb5a5da66d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -12,7 +12,7 @@ export type UserProfileWeb3WalletsModel = availableProviders: UserProfileWeb3Provider[]; connect: (strategy: Web3Strategy, walletName?: string) => Promise; setPrimary: (walletId: string) => Promise; - remove: (walletId: string) => Promise; + remove?: (walletId: string) => Promise; }; export type ReadyWeb3WalletsModel = Extract; From 71afc2fd40aacd573b4be78a07f5d793c011cc0d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:37:58 -0600 Subject: [PATCH 61/86] test(mosaic): replace wallet hook tests with connected coverage --- ...e-web3-wallets-section.controller.test.tsx | 99 ------------------- 1 file changed, 99 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx 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 deleted file mode 100644 index 18769ad5090..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileWeb3Provider } from '../user-profile-web3-wallets-section.view'; -import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; - -const wallets = [{ id: 'wallet_1', address: '0x1234567890abcdef', isVerified: true }]; -const availableProviders: UserProfileWeb3Provider[] = [{ 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('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(), - }), - { - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - await act(async () => { - await result.current.onConnect('web3_metamask_signature'); - }); - - expect(result.current.availableProviders[0]?.connectError).toBe('Impossible de connecter le portefeuille.'); - }); - it('keeps the accepted Solana wallet as the pending owner through repeated calls', async () => { - const pending = Promise.withResolvers(); - const connect = vi.fn(() => pending.promise); - const { result } = renderHook(() => - useUserProfileWeb3WalletsController({ - wallets: [], - availableProviders: [{ id: 'web3_solana_signature', provider: 'Solana', walletPicker: 'solana' }], - connect, - setPrimary: () => Promise.resolve(), - }), - ); - act(() => { - void result.current.onConnect('web3_solana_signature'); - }); - act(() => { - void result.current.connectSolana('First wallet'); - void result.current.connectSolana('Second wallet'); - result.current.closeSolanaPicker(); - }); - expect(result.current.pendingWalletName).toBe('First wallet'); - expect(result.current.solanaPickerOpen).toBe(true); - expect(connect).toHaveBeenCalledOnce(); - expect(connect).toHaveBeenCalledWith('web3_solana_signature', 'First wallet'); - await act(async () => { - pending.resolve(); - await pending.promise; - }); - expect(result.current.pendingWalletName).toBeUndefined(); - expect(result.current.solanaPickerOpen).toBe(false); - }); -}); From 1ae70a118f732150074c8831aefd1c3e35751749 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:21:19 -0600 Subject: [PATCH 62/86] test(mosaic): focus Solana selection through user interaction --- .../user-profile-web3-wallets-section.feature.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 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 68755b603f2..2b83b29a0df 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 @@ -624,10 +624,9 @@ describe('Web3 wallets', () => { await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); await user.click(screen.getByRole('button', { name: 'Connect Solana' })); const creation = holdRequests('post', '/v1/me/web3_wallets'); - screen.getByRole('button', { name: 'Second Solana' }).focus(); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); act(() => { fireEvent.click(screen.getByRole('button', { name: 'Second Solana' })); - fireEvent.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'); From 7071b6afc43bb170b7fafcdb741e8b56edf76a15 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:21:36 -0600 Subject: [PATCH 63/86] fix(swingset): align wired Web3 stories with live navigation --- packages/swingset/src/lib/live-navigation.ts | 2 +- .../stories/fixtures/user-profile-web3-wallets.ts | 12 ++++++++---- .../user-profile-web3-wallets-section.stories.tsx | 5 ++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index 493aebd9390..f62a4cb9aae 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -19,7 +19,7 @@ const liveRoutes = new Map([ ['/organization-profile/organization-profile-general-panel', '/live/organization-general'], ['/organization-profile/organization-profile-danger-section', '/live/organization-danger'], ['/user-profile/user-profile-passkeys-section', '/live/passkeys'], - ['/user-profile/user-profile-web3-wallets-section', '/live/web3-wallets'], + ['/user-profile/user-profile-web3wallets-section', '/live/web3-wallets'], ['/reverification/reverification', '/live/reverification'], ]); diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index a02fd0c09dc..bf5fe3e718e 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -12,13 +12,17 @@ interface DemoWallet extends UserProfileWeb3Wallet { } const providers: UserProfileWeb3Provider[] = [ - { id: 'metamask', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, - { id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg' }, + { id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, + { + id: 'web3_coinbase_wallet_signature', + provider: 'Coinbase Wallet', + iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', + }, ]; export const primaryWallet: DemoWallet = { id: 'wallet_1', - providerId: 'metamask', + providerId: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg', address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', @@ -28,7 +32,7 @@ export const primaryWallet: DemoWallet = { export const secondaryWallet: DemoWallet = { id: 'wallet_2', - providerId: 'coinbase-wallet', + providerId: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', address: '0x1234567890abcdef1234567890abcdef12345678', diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index 894cdd5963a..34ed28e8f18 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -9,7 +9,6 @@ export { default as __source } from './user-profile-web3-wallets-section.stories export const meta: StoryMeta = { group: 'User Profile', status: 'wip', - substatus: 'needs wire-up', title: 'UserProfileWeb3WalletsSection', label: 'Web3 wallets', navigation: { category: 'Sections' }, @@ -34,7 +33,7 @@ export function ConnectOnly() { export function UnverifiedWallet() { const fixture = useWeb3WalletsFixture({ initialWallets: [{ ...primaryWallet, isPrimary: false, isVerified: false }], - availableProviders: [{ id: 'metamask', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], + availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], }); return ( Date: Sat, 3 Oct 2026 16:16:01 -0600 Subject: [PATCH 64/86] fix(ui): keep profile helpers outside initial shared entries --- .../user-profile-solana-wallet.dialog.tsx | 2 +- .../user-profile-web3-wallets-section.model.ts | 2 +- packages/shared/src/react/hooks/index.ts | 2 -- packages/shared/src/utils/index.ts | 1 - packages/shared/tsdown.config.mts | 2 ++ .../src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx | 2 +- .../SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx | 2 +- .../ui/src/components/SignUp/SignUpStartSolanaWalletsCard.tsx | 2 +- packages/ui/src/components/UserProfile/EmailsSection.tsx | 2 +- packages/ui/src/components/UserProfile/PhoneSection.tsx | 2 +- packages/ui/src/components/UserProfile/Web3Section.tsx | 2 +- .../src/components/UserProfile/Web3SelectSolanaWalletScreen.tsx | 2 +- packages/ui/src/components/UserProfile/__tests__/utils.test.ts | 2 +- .../ui/src/{elements => components}/Web3SolanaWalletButtons.tsx | 2 +- .../__tests__/Web3SolanaWalletButtons.test.tsx | 2 +- 15 files changed, 14 insertions(+), 15 deletions(-) rename packages/ui/src/{elements => components}/Web3SolanaWalletButtons.tsx (98%) rename packages/ui/src/{elements => components}/__tests__/Web3SolanaWalletButtons.test.tsx (98%) 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 86e30a94e66..23aa50e7ac3 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,4 +1,4 @@ -import { useInstalledSolanaWallets } from '@clerk/shared/react'; +import { useInstalledSolanaWallets } from '@clerk/shared/react/hooks/useInstalledSolanaWallets'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; 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 index 2e0e74b67a8..327a0a1ecf4 100644 --- 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 @@ -2,7 +2,7 @@ import { iconImageUrl } from '@clerk/shared/constants'; import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; import { useClerk, useUser } from '@clerk/shared/react'; import type { VerificationResource, Web3WalletResource } from '@clerk/shared/types'; -import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils'; +import { sortIdentificationBasedOnVerification } from '@clerk/shared/utils/sortIdentificationBasedOnVerification'; import { WEB3_PROVIDERS } from '@clerk/shared/web3'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index c10c40f43a5..abe5b258c87 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -16,8 +16,6 @@ export { useSafeLayoutEffect } from './useSafeLayoutEffect'; export { useSession } from './useSession'; export { useSessionList } from './useSessionList'; export { useUser } from './useUser'; -export { useInstalledSolanaWallets } from './useInstalledSolanaWallets'; -export type { InstalledSolanaWallet } from './useInstalledSolanaWallets'; export { useClerk } from './useClerk'; export { useDeepEqualMemo, isDeeplyEqual } from './useDeepEqualMemo'; export { useReverification } from './useReverification'; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 80080888604..d6104a73397 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -8,4 +8,3 @@ export { handleValueOrFn } from './handleValueOrFn'; export { runIfFunctionOrReturn } from './runIfFunctionOrReturn'; export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge'; export { timeLimit } from './timeLimit'; -export { sortIdentificationBasedOnVerification } from './sortIdentificationBasedOnVerification'; diff --git a/packages/shared/tsdown.config.mts b/packages/shared/tsdown.config.mts index 559b8bbf9a6..8c65e4a25fc 100644 --- a/packages/shared/tsdown.config.mts +++ b/packages/shared/tsdown.config.mts @@ -25,7 +25,9 @@ export default defineConfig(({ watch, env }) => { entry: [ './src/*.{ts,tsx}', './src/react/index.ts', + './src/react/hooks/useInstalledSolanaWallets.ts', './src/utils/index.ts', + './src/utils/sortIdentificationBasedOnVerification.ts', './src/phone/index.ts', './src/workerTimers/index.ts', './src/types/index.ts', diff --git a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx index 56efdfc8341..dcba1aa5ecc 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx @@ -10,7 +10,7 @@ import { Header } from '@/ui/elements/Header'; import { web3CallbackErrorHandler } from '@/ui/utils/web3CallbackErrorHandler'; const Web3SolanaWalletButtons = lazy(() => - import(/* webpackChunkName: "web3-solana-wallet-buttons" */ '@/ui/elements/Web3SolanaWalletButtons').then(m => ({ + import(/* webpackChunkName: "web3-solana-wallet-buttons" */ '@/ui/components/Web3SolanaWalletButtons').then(m => ({ default: m.Web3SolanaWalletButtons, })), ); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx index d5c118653b1..a3f00f4279d 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneSolanaWalletsCard.test.tsx @@ -9,7 +9,7 @@ const { createFixtures } = bindCreateFixtures('SignIn'); // The wallet buttons are lazy-loaded; stub them with a button that invokes the card's // `web3AuthCallback` so we can assert the params the card forwards to `authenticateWithWeb3`. -vi.mock('@/ui/elements/Web3SolanaWalletButtons', () => ({ +vi.mock('@/ui/components/Web3SolanaWalletButtons', () => ({ Web3SolanaWalletButtons: ({ web3AuthCallback }: { web3AuthCallback: (a: { walletName: string }) => void }) => ( + + ) : null} + {discovery.status === 'ready' && discovery.wallets.length === 0 ? (

{m.solanaDialog.noneAvailable}{' '}

) : null} - {wallets.map(wallet => ( - onConnect(wallet.name)} - > - {wallet.icon ? ( - - ) : null} - {wallet.name} - - ))} + {discovery.status === 'ready' + ? discovery.wallets.map(wallet => ( + onConnect(wallet.name)} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + )) + : null} { + vi.doUnmock('@wallet-standard/core'); +}); + +describe('Solana wallet discovery failures', () => { + it('handles a registry import that rejects after unmount', async () => { + const load = deferred(); + const unhandled = vi.fn(); + window.addEventListener('unhandledrejection', unhandled); + const loadRegistry = vi.fn(() => load.promise); + vi.doMock('@wallet-standard/core', loadRegistry); + + try { + const { result, unmount } = renderHook(useUserProfileSolanaWalletsModel); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => expect(loadRegistry).toHaveBeenCalledOnce()); + unmount(); + await act(async () => { + load.reject(new Error('Registry import failed after unmount')); + await vi.dynamicImportSettled(); + }); + expect(result.current).toEqual({ status: 'loading' }); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('unhandledrejection', unhandled); + } + }); + + it('reports a failed registry import instead of an empty wallet list and can retry', async () => { + vi.doMock('@wallet-standard/core', () => { + throw new Error('Failed to fetch the wallet registry chunk'); + }); + + const { result } = renderHook(useUserProfileSolanaWalletsModel); + expect(result.current).toEqual({ status: 'loading' }); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(result.current).toMatchObject({ status: 'error' }); + + vi.doMock('@wallet-standard/core', () => ({ + getWallets: () => ({ + get: (): Wallet[] => [ + { + name: 'Recovered Solana', + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features: { 'standard:connect': {}, 'solana:signMessage': {} }, + }, + ], + on: () => () => {}, + }), + })); + + act(() => { + if (result.current.status === 'error') { + result.current.retry(); + } + }); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Recovered Solana', icon: 'data:image/svg+xml;base64,' }], + }), + ); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx index e80de46ae2c..cde8f80714d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx @@ -44,28 +44,27 @@ describe('useUserProfileSolanaWalletsModel', () => { registry.getWallets.mockClear(); }); - it('renders an empty server snapshot without initializing the browser registry', () => { + it('renders a loading server snapshot without initializing the browser registry', () => { registry.wallets.push(wallet('Phantom')); function WalletNames() { - return createElement( - 'div', - null, - useUserProfileSolanaWalletsModel() - .map(wallet => wallet.name) - .join(','), - ); + return createElement('div', null, useUserProfileSolanaWalletsModel().status); } - expect(renderToString(createElement(WalletNames))).toBe('
'); + expect(renderToString(createElement(WalletNames))).toBe('
loading
'); expect(registry.getWallets).not.toHaveBeenCalled(); }); - it('starts empty and projects only eligible wallet names and icons after loading the registry', async () => { + it('starts loading and projects only eligible wallet names and icons after loading the registry', async () => { registry.wallets.push(wallet('Phantom'), wallet('Signer only', { 'solana:signMessage': {} })); const { result } = renderHook(useUserProfileSolanaWalletsModel); - expect(result.current).toEqual([]); - await waitFor(() => expect(result.current).toEqual([{ name: 'Phantom', icon: 'data:image/svg+xml;base64,' }])); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Phantom', icon: 'data:image/svg+xml;base64,' }], + }), + ); }); it.each([ @@ -81,7 +80,10 @@ describe('useUserProfileSolanaWalletsModel', () => { await vi.dynamicImportSettled(); }); expect(registry.getWallets).toHaveBeenCalled(); - expect(result.current).toEqual(entry.eligible ? [{ name: 'Candidate', icon: 'data:image/svg+xml;base64,' }] : []); + expect(result.current).toEqual({ + status: 'ready', + wallets: entry.eligible ? [{ name: 'Candidate', icon: 'data:image/svg+xml;base64,' }] : [], + }); }); it('refreshes on registration and unregistration and removes both subscriptions on unmount', async () => { @@ -92,13 +94,16 @@ describe('useUserProfileSolanaWalletsModel', () => { registry.wallets.push(wallet('Backpack')); registry.listeners.register.forEach(listener => listener()); }); - expect(result.current).toEqual([{ name: 'Backpack', icon: 'data:image/svg+xml;base64,' }]); + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Backpack', icon: 'data:image/svg+xml;base64,' }], + }); act(() => { registry.wallets.splice(0); registry.listeners.unregister.forEach(listener => listener()); }); - expect(result.current).toEqual([]); + expect(result.current).toEqual({ status: 'ready', wallets: [] }); unmount(); expect(registry.listeners.register.size).toBe(0); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts index 68dd5373dee..a38b65586b8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts @@ -1,7 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export type InstalledSolanaWallet = { name: string; icon: string }; +export type SolanaWalletDiscovery = + | { status: 'loading' } + | { status: 'ready'; wallets: readonly InstalledSolanaWallet[] } + | { status: 'error'; retry: () => void }; + function isSolanaSignInWallet(wallet: { chains: readonly string[]; features: Readonly>; @@ -13,41 +18,54 @@ function isSolanaSignInWallet(wallet: { ); } -export function useUserProfileSolanaWalletsModel(): readonly InstalledSolanaWallet[] { - const [wallets, setWallets] = useState([]); +export function useUserProfileSolanaWalletsModel(): SolanaWalletDiscovery { + const [discovery, setDiscovery] = useState({ status: 'loading' }); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => { + setDiscovery({ status: 'loading' }); + setAttempt(attempt => attempt + 1); + }, []); useEffect(() => { let disposed = false; let unsubscribe: (() => void) | undefined; - void import('@wallet-standard/core').then(({ getWallets }) => { - if (disposed) { - return; - } - - const registry = getWallets(); - const update = () => { - setWallets( - registry - .get() - .filter(isSolanaSignInWallet) - .map(({ name, icon }) => ({ name, icon })), - ); - }; - const offRegister = registry.on('register', update); - const offUnregister = registry.on('unregister', update); - unsubscribe = () => { - offRegister(); - offUnregister(); - }; - update(); - }); + void import('@wallet-standard/core') + .then(({ getWallets }) => { + if (disposed) { + return; + } + + const registry = getWallets(); + const update = () => { + setDiscovery({ + status: 'ready', + wallets: registry + .get() + .filter(isSolanaSignInWallet) + .map(({ name, icon }) => ({ name, icon })), + }); + }; + const offRegister = registry.on('register', update); + const offUnregister = registry.on('unregister', update); + unsubscribe = () => { + offRegister(); + offUnregister(); + }; + update(); + }) + .catch(() => { + if (disposed) { + return; + } + setDiscovery({ status: 'error', retry }); + }); return () => { disposed = true; unsubscribe?.(); }; - }, []); + }, [attempt, retry]); - return wallets; + return discovery; } 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 cb6598b3e10..f4a71fc89c3 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 @@ -54,7 +54,7 @@ function Web3Wallets({ onRemove={controller.onRemove} /> Date: Wed, 7 Oct 2026 10:51:15 -0600 Subject: [PATCH 77/86] fix(mosaic): discover Solana wallets when the picker opens --- ...file-web3-wallets-section.feature.test.tsx | 55 +++++--- .../user-profile-solana-wallet.dialog.tsx | 124 ++++-------------- ...r-profile-solana-wallet.lifecycle.test.tsx | 90 +++++++++++++ .../user-profile-solana-wallet.view.tsx | 114 ++++++++++++++++ .../user-profile-web3-wallets-section.tsx | 3 - 5 files changed, 262 insertions(+), 124 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.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 7c9d765917c..9a2ba1d037a 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 @@ -15,8 +15,9 @@ import { fapiWeb3Wallet, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +import { Dialog } from '../../../components/dialog'; import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileSolanaWalletDialog } from '../user-profile-web3-wallets-section/user-profile-solana-wallet.dialog'; +import { UserProfileSolanaWalletView } from '../user-profile-web3-wallets-section/user-profile-solana-wallet.view'; import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; describe('Web3 wallets', () => { @@ -549,7 +550,7 @@ describe('Web3 wallets', () => { }); it('distinguishes localized discovery loading and failure from no installed wallets', async () => { - const props = { open: true, onOpenChange: vi.fn(), onConnect: vi.fn() }; + const props = { onConnect: vi.fn() }; const retry = vi.fn(); const localization = { messages: { @@ -564,10 +565,14 @@ describe('Web3 wallets', () => { }; const { rerender } = render( - + + + + + , ); @@ -577,10 +582,14 @@ describe('Web3 wallets', () => { rerender( - + + + + + , ); @@ -594,13 +603,17 @@ describe('Web3 wallets', () => { }); it('renders caller-supplied Solana wallets and forwards the chosen name', async () => { - const props = { open: true, onOpenChange: vi.fn(), onConnect: vi.fn() }; + const props = { onConnect: vi.fn() }; const { rerender } = render( - + + + + + , ); @@ -609,10 +622,14 @@ describe('Web3 wallets', () => { rerender( - + + + + + , ); expect(screen.queryByRole('button', { name: 'Supplied Solana' })).toBeNull(); 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 ea08deda39e..e4f723c7fa6 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,26 +1,21 @@ -import { Banner } from '../../../components/banner'; -import { Button, SubmitButton } from '../../../components/button'; -import { Card } from '../../../components/card'; +import type { ComponentProps } from 'react'; + import { Dialog } from '../../../components/dialog'; -import { useMessages } from '../../../localization'; -import type { SolanaWalletDiscovery } from './user-profile-solana-wallet.model'; +import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; +import { UserProfileSolanaWalletView } from './user-profile-solana-wallet.view'; + +type SolanaWalletContentProps = Omit, 'discovery'>; export function UserProfileSolanaWalletDialog({ - discovery, open, pendingWalletName, error, onOpenChange, onConnect, -}: { - discovery: SolanaWalletDiscovery; +}: SolanaWalletContentProps & { open: boolean; - pendingWalletName?: string; - error?: string; onOpenChange: (open: boolean) => void; - onConnect: (walletName: string) => void; }) { - const m = useMessages('userProfileWeb3Wallets'); const pending = pendingWalletName !== undefined; return ( @@ -34,97 +29,22 @@ export function UserProfileSolanaWalletDialog({ }} > - - - {m.solanaDialog.title} - {m.solanaDialog.description} - - - {error ? ( - - {error} - - ) : null} - {discovery.status === 'loading' ?

{m.solanaDialog.loading}

: null} - {discovery.status === 'error' ? ( - <> - - {m.solanaDialog.loadError} - - - - ) : null} - {discovery.status === 'ready' && discovery.wallets.length === 0 ? ( -

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

- ) : null} - {discovery.status === 'ready' - ? discovery.wallets.map(wallet => ( - onConnect(wallet.name)} - > - {wallet.icon ? ( - - ) : null} - {wallet.name} - - )) - : null} - - - - {m.solanaDialog.cancel} - - } - /> - - +
); } + +function SolanaWalletContent(props: SolanaWalletContentProps) { + const discovery = useUserProfileSolanaWalletsModel(); + return ( + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx new file mode 100644 index 00000000000..2616c77d86c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx @@ -0,0 +1,90 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Wallet } from '@wallet-standard/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileWeb3WalletsSection } from './user-profile-web3-wallets-section'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; + +const registry = vi.hoisted(() => { + const wallets: Wallet[] = []; + const listeners = { register: new Set<() => void>(), unregister: new Set<() => void>() }; + const getWallets = vi.fn(() => ({ + get: () => wallets, + on: (event: 'register' | 'unregister', listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + })); + return { wallets, listeners, getWallets, loadModule: vi.fn() }; +}); + +vi.mock('@wallet-standard/core', () => { + registry.loadModule(); + return { getWallets: registry.getWallets }; +}); +vi.mock('./user-profile-web3-wallets-section.model', () => ({ + useUserProfileWeb3WalletsModel: () => + ({ + status: 'ready', + userId: 'user_1', + wallets: [], + availableProviders: [{ id: 'web3_solana_signature', provider: 'Solana', walletPicker: 'solana' }], + connect: async () => {}, + setPrimary: async () => {}, + }) satisfies ReadyWeb3WalletsModel, +})); + +function wallet(name: string): Wallet { + return { + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features: { 'standard:connect': {}, 'solana:signMessage': {} }, + }; +} + +describe('Solana picker discovery lifetime', () => { + it('discovers wallets only while the picker content is mounted and refreshes after reopening', async () => { + registry.wallets.push(wallet('Phantom')); + const user = userEvent.setup(); + const { unmount } = render( + + + , + ); + + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(registry.loadModule).not.toHaveBeenCalled(); + expect(registry.getWallets).not.toHaveBeenCalled(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(await screen.findByRole('button', { name: 'Phantom' })).toBeInTheDocument(); + expect(registry.loadModule).toHaveBeenCalledOnce(); + expect(registry.getWallets).toHaveBeenCalledOnce(); + expect(registry.listeners.register.size).toBe(1); + expect(registry.listeners.unregister.size).toBe(1); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + + registry.wallets.splice(0, registry.wallets.length, wallet('Backpack')); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(await screen.findByRole('button', { name: 'Backpack' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Phantom' })).not.toBeInTheDocument(); + expect(registry.getWallets).toHaveBeenCalledTimes(2); + + unmount(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx new file mode 100644 index 00000000000..6decc33aa2a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx @@ -0,0 +1,114 @@ +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'; +import type { SolanaWalletDiscovery } from './user-profile-solana-wallet.model'; + +export function UserProfileSolanaWalletView({ + discovery, + pendingWalletName, + error, + onConnect, +}: { + discovery: SolanaWalletDiscovery; + pendingWalletName?: string; + error?: string; + onConnect: (walletName: string) => void; +}) { + const m = useMessages('userProfileWeb3Wallets'); + const pending = pendingWalletName !== undefined; + + return ( + + + {m.solanaDialog.title} + {m.solanaDialog.description} + + + {error ? ( + + {error} + + ) : null} + {discovery.status === 'loading' ?

{m.solanaDialog.loading}

: null} + {discovery.status === 'error' ? ( + <> + + {m.solanaDialog.loadError} + + + + ) : null} + {discovery.status === 'ready' && discovery.wallets.length === 0 ? ( +

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

+ ) : null} + {discovery.status === 'ready' + ? discovery.wallets.map(wallet => ( + onConnect(wallet.name)} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + )) + : null} +
+ + + {m.solanaDialog.cancel} + + } + /> + +
+ ); +} 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 f4a71fc89c3..a6294740ebc 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 @@ -2,7 +2,6 @@ import type { ReactNode } from 'react'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; -import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; import { useUserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.model'; import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; @@ -37,7 +36,6 @@ function Web3Wallets({ fallbackFocus?: () => HTMLElement | null; }) { const controller = useUserProfileWeb3WalletsController(model); - const solanaWallets = useUserProfileSolanaWalletsModel(); return ( <> Date: Wed, 7 Oct 2026 15:09:52 -0600 Subject: [PATCH 78/86] fix(mosaic): reuse shared fake FAPI missing response --- packages/mosaic/src/__tests__/feature/fake-fapi.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 0ecc80cc4be..f730da6e6be 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -156,10 +156,6 @@ function updateWeb3Wallet(state: FakeFapiState, wallet: Web3WalletJSON): void { } } -function missing() { - return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); -} - export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const { verification, enterpriseLinking, passkeys, ...rest } = seed; const state: FakeFapiState = { From ccf4feb0ad753fdc60949ac859495d51f5d3f291 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 11:43:40 -0600 Subject: [PATCH 79/86] fix(mosaic): handle Solana wallet discovery failures --- .changeset/web3-wallets-wire-up.md | 3 ++ ...file-web3-wallets-section.feature.test.tsx | 9 ++--- ...user-profile-solana-wallet.dialog.test.tsx | 38 +++++++++++++++++++ ...r-profile-solana-wallet.lifecycle.test.tsx | 8 ++-- ...rofile-solana-wallet.model-errors.test.tsx | 36 ++---------------- .../user-profile-solana-wallet.model.test.tsx | 2 +- ...user-profile-web3-wallets-feedback.test.ts | 10 ++--- ...profile-web3-wallets-section.model.test.ts | 2 +- .../user-profile-solana-wallet.model.ts | 14 ++----- .../user-profile-solana-wallet.view.tsx | 34 +++-------------- .../user-profile-web3-wallets.messages.ts | 3 +- 11 files changed, 69 insertions(+), 90 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.dialog.test.tsx rename packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/{ => __tests__}/user-profile-solana-wallet.lifecycle.test.tsx (91%) rename packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/{ => __tests__}/user-profile-solana-wallet.model-errors.test.tsx (60%) rename packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/{ => __tests__}/user-profile-solana-wallet.model.test.tsx (98%) rename packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/{ => __tests__}/user-profile-web3-wallets-feedback.test.ts (77%) rename packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/{ => __tests__}/user-profile-web3-wallets-section.model.test.ts (97%) diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md index a845151cc84..c5fb6eff987 100644 --- a/.changeset/web3-wallets-wire-up.md +++ b/.changeset/web3-wallets-wire-up.md @@ -1,2 +1,5 @@ --- +"@clerk/mosaic": patch --- + +Show wallet discovery errors consistently with other dialogs and ask users to refresh the page when Solana wallets cannot load. 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 9a2ba1d037a..838ab44aa6d 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 @@ -551,14 +551,12 @@ describe('Web3 wallets', () => { it('distinguishes localized discovery loading and failure from no installed wallets', async () => { const props = { onConnect: vi.fn() }; - const retry = vi.fn(); const localization = { messages: { userProfileWeb3Wallets: { solanaDialog: { loading: 'Recherche des portefeuilles…', loadError: 'Chargement impossible.', - retry: 'Réessayer', }, }, }, @@ -586,7 +584,7 @@ describe('Web3 wallets', () => { @@ -597,8 +595,7 @@ describe('Web3 wallets', () => { expect(screen.queryByRole('status')).not.toBeInTheDocument(); expect(screen.queryByText('No Solana wallets are available.')).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Find a Solana wallet' })).not.toBeInTheDocument(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Réessayer' })); - expect(retry).toHaveBeenCalledOnce(); + expect(screen.queryByRole('button', { name: /Retry|Try again/ })).not.toBeInTheDocument(); expect(props.onConnect).not.toHaveBeenCalled(); }); @@ -823,7 +820,7 @@ describe('Web3 wallets', () => { expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); await user.click(screen.getByRole('button', { name: 'Connect Solana' })); - expect(within(screen.getByRole('dialog')).queryByRole('alert')).toBeNull(); + expect(within(screen.getByRole('dialog')).queryByText('Something went wrong. Please try again.')).toBeNull(); expect(screen.getByRole('button', { name: 'Test Solana' })).toBeEnabled(); await user.click(screen.getByRole('button', { name: 'Test Solana' })); expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent( diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.dialog.test.tsx new file mode 100644 index 00000000000..76461c9c944 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.dialog.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../../mosaic-provider'; +import { UserProfileSolanaWalletDialog } from '../user-profile-solana-wallet.dialog'; + +afterEach(() => { + vi.doUnmock('@wallet-standard/core'); +}); + +describe('Solana wallet discovery failures', () => { + it('asks for a page refresh after a failed import and lets the user cancel', async () => { + vi.doMock('@wallet-standard/core', () => { + throw new Error('Failed to fetch wallet registry'); + }); + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + + , + ); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'Wallets could not be loaded. Please refresh the page and try again.', + ), + ); + expect(screen.queryByText('No Solana wallets are available.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Retry|Try again/ })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.lifecycle.test.tsx similarity index 91% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.lifecycle.test.tsx index 2616c77d86c..a286d6a8f2e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.lifecycle.test.tsx @@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event'; import type { Wallet } from '@wallet-standard/core'; import { describe, expect, it, vi } from 'vitest'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileWeb3WalletsSection } from './user-profile-web3-wallets-section'; -import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; +import { MosaicProvider } from '../../../../mosaic-provider'; +import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section'; +import type { ReadyWeb3WalletsModel } from '../user-profile-web3-wallets-section.types'; const registry = vi.hoisted(() => { const wallets: Wallet[] = []; @@ -24,7 +24,7 @@ vi.mock('@wallet-standard/core', () => { registry.loadModule(); return { getWallets: registry.getWallets }; }); -vi.mock('./user-profile-web3-wallets-section.model', () => ({ +vi.mock('../user-profile-web3-wallets-section.model', () => ({ useUserProfileWeb3WalletsModel: () => ({ status: 'ready', diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model-errors.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model-errors.test.tsx similarity index 60% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model-errors.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model-errors.test.tsx index 44fdbc3f457..97a093331bf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model-errors.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model-errors.test.tsx @@ -1,9 +1,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'; -import type { Wallet } from '@wallet-standard/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { deferred } from '../../../__tests__/async'; -import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; +import { deferred } from '../../../../__tests__/async'; +import { useUserProfileSolanaWalletsModel } from '../user-profile-solana-wallet.model'; afterEach(() => { vi.doUnmock('@wallet-standard/core'); @@ -33,7 +32,7 @@ describe('Solana wallet discovery failures', () => { } }); - it('reports a failed registry import instead of an empty wallet list and can retry', async () => { + it('reports a failed registry import instead of an empty wallet list', async () => { vi.doMock('@wallet-standard/core', () => { throw new Error('Failed to fetch the wallet registry chunk'); }); @@ -44,34 +43,5 @@ describe('Solana wallet discovery failures', () => { await vi.dynamicImportSettled(); }); expect(result.current).toMatchObject({ status: 'error' }); - - vi.doMock('@wallet-standard/core', () => ({ - getWallets: () => ({ - get: (): Wallet[] => [ - { - name: 'Recovered Solana', - icon: 'data:image/svg+xml;base64,', - version: '1.0.0', - chains: ['solana:mainnet'], - accounts: [], - features: { 'standard:connect': {}, 'solana:signMessage': {} }, - }, - ], - on: () => () => {}, - }), - })); - - act(() => { - if (result.current.status === 'error') { - result.current.retry(); - } - }); - expect(result.current).toEqual({ status: 'loading' }); - await waitFor(() => - expect(result.current).toEqual({ - status: 'ready', - wallets: [{ name: 'Recovered Solana', icon: 'data:image/svg+xml;base64,' }], - }), - ); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model.test.tsx similarity index 98% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model.test.tsx index cde8f80714d..23851a0884e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model.test.tsx @@ -4,7 +4,7 @@ import { createElement } from 'react'; import { renderToString } from 'react-dom/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; +import { useUserProfileSolanaWalletsModel } from '../user-profile-solana-wallet.model'; const registry = vi.hoisted(() => { const wallets: Wallet[] = []; diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-feedback.test.ts similarity index 77% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.test.ts rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-feedback.test.ts index fe35d1f01f5..7cb77001b13 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-feedback.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { toLocalizableError } from '../../../utils/errors'; -import { userProfileWeb3WalletsMessages } from '../user-profile-web3-wallets.messages'; -import { web3WalletFeedback } from './user-profile-web3-wallets-feedback'; -import { Web3WalletActionError } from './user-profile-web3-wallets-section.types'; +import { clerkApiError } from '../../../../__tests__/clerk-errors'; +import { toLocalizableError } from '../../../../utils/errors'; +import { userProfileWeb3WalletsMessages } from '../../user-profile-web3-wallets.messages'; +import { web3WalletFeedback } from '../user-profile-web3-wallets-feedback'; +import { Web3WalletActionError } from '../user-profile-web3-wallets-section.types'; describe('Web3 wallet feedback', () => { it('preserves Clerk error codes for the shared localization boundary', () => { 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/__tests__/user-profile-web3-wallets-section.model.test.ts similarity index 97% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.test.ts rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.model.test.ts index a38886c68b1..201fd3c48c7 100644 --- 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/__tests__/user-profile-web3-wallets-section.model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { normalizedWeb3Wallet, projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; +import { normalizedWeb3Wallet, projectWeb3Wallets } from '../user-profile-web3-wallets-section.model'; const metamask = { id: 'wallet_metamask', diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts index a38b65586b8..85d2e80e2c0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; export type InstalledSolanaWallet = { name: string; icon: string }; export type SolanaWalletDiscovery = | { status: 'loading' } | { status: 'ready'; wallets: readonly InstalledSolanaWallet[] } - | { status: 'error'; retry: () => void }; + | { status: 'error' }; function isSolanaSignInWallet(wallet: { chains: readonly string[]; @@ -20,12 +20,6 @@ function isSolanaSignInWallet(wallet: { export function useUserProfileSolanaWalletsModel(): SolanaWalletDiscovery { const [discovery, setDiscovery] = useState({ status: 'loading' }); - const [attempt, setAttempt] = useState(0); - const retry = useCallback(() => { - setDiscovery({ status: 'loading' }); - setAttempt(attempt => attempt + 1); - }, []); - useEffect(() => { let disposed = false; let unsubscribe: (() => void) | undefined; @@ -58,14 +52,14 @@ export function useUserProfileSolanaWalletsModel(): SolanaWalletDiscovery { if (disposed) { return; } - setDiscovery({ status: 'error', retry }); + setDiscovery({ status: 'error' }); }); return () => { disposed = true; unsubscribe?.(); }; - }, [attempt, retry]); + }, []); return discovery; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx index 6decc33aa2a..0a19e2499b8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx @@ -1,4 +1,3 @@ -import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import { Dialog } from '../../../components/dialog'; @@ -28,35 +27,14 @@ export function UserProfileSolanaWalletView({ {m.solanaDialog.title} {m.solanaDialog.description} + + {discovery.status === 'error' ? m.solanaDialog.loadError : error} + - {error ? ( - - {error} - - ) : null} {discovery.status === 'loading' ?

{m.solanaDialog.loading}

: null} - {discovery.status === 'error' ? ( - <> - - {m.solanaDialog.loadError} - - - - ) : null} {discovery.status === 'ready' && discovery.wallets.length === 0 ? (

{m.solanaDialog.noneAvailable}{' '} 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 aa38e73ea96..1b0b09abce2 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 @@ -19,8 +19,7 @@ export const userProfileWeb3WalletsMessages = { title: 'Select a Solana wallet', description: 'Choose an installed wallet to connect to your account.', loading: 'Loading wallets…', - loadError: 'Wallets could not be loaded. Please try again.', - retry: 'Retry', + loadError: 'Wallets could not be loaded. Please refresh the page and try again.', noneAvailable: 'No Solana wallets are available.', findWallet: 'Find a Solana wallet', cancel: 'Cancel', From c81e01f98296cdbcf7d43ae348f71eacc45196f5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 12:59:47 -0600 Subject: [PATCH 80/86] fix(mosaic): use shared Web3 error localization --- .changeset/web3-wallets-wire-up.md | 1 + ...file-web3-wallets-section.feature.test.tsx | 32 ++++ ...user-profile-web3-wallets-feedback.test.ts | 43 ----- .../user-profile-web3-wallets-feedback.ts | 10 -- ...user-profile-web3-wallets-section.model.ts | 167 ++++++++---------- ...user-profile-web3-wallets-section.types.ts | 16 -- .../user-profile-web3-wallets.messages.ts | 5 - .../src/localization/errors.messages.ts | 4 + 8 files changed, 114 insertions(+), 164 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-feedback.test.ts delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md index c5fb6eff987..3f8f79d2977 100644 --- a/.changeset/web3-wallets-wire-up.md +++ b/.changeset/web3-wallets-wire-up.md @@ -3,3 +3,4 @@ --- Show wallet discovery errors consistently with other dialogs and ask users to refresh the page when Solana wallets cannot load. +Localize Web3 wallet connection errors through the shared error catalog, including custom translations for unavailable providers and missing wallet extensions. 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 838ab44aa6d..b9bc5b693a4 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 @@ -246,6 +246,38 @@ describe('Web3 wallets', () => { await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connexion impossible.')); }); + it('resolves a missing wallet identifier from the shared error catalog when localization changes', async () => { + const request = vi.fn(() => Promise.resolve([])); + vi.stubGlobal('ethereum', { request }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + const section = ; + const { rerender } = await renderWithClerk( + + {section} + , + ); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Installez un portefeuille.'); + + rerender( + + {section} + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Portefeuille introuvable.'); + expect(request).toHaveBeenCalledOnce(); + + rerender({section}); + expect(screen.getByRole('alert')).toHaveTextContent( + 'A Web3 Wallet extension cannot be found. Please install one to continue.', + ); + expect(request).toHaveBeenCalledOnce(); + }); + it('uses the canonical API-code translation for a wallet connection failure', async () => { vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.resolve(['0x1234567890abcdef'])) }); serveFapi({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-feedback.test.ts deleted file mode 100644 index 7cb77001b13..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-feedback.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { clerkApiError } from '../../../../__tests__/clerk-errors'; -import { toLocalizableError } from '../../../../utils/errors'; -import { userProfileWeb3WalletsMessages } from '../../user-profile-web3-wallets.messages'; -import { web3WalletFeedback } from '../user-profile-web3-wallets-feedback'; -import { Web3WalletActionError } from '../user-profile-web3-wallets-section.types'; - -describe('Web3 wallet feedback', () => { - it('preserves Clerk error codes for the shared localization boundary', () => { - const cause = clerkApiError('verification_invalid_strategy', 'Server copy'); - const error = web3WalletFeedback(cause, userProfileWeb3WalletsMessages); - - expect(toLocalizableError(error)).toMatchObject({ - code: 'verification_invalid_strategy', - message: 'Server copy', - }); - }); - - it.each([ - 'providerUnavailable', - 'extensionUnavailable', - 'creationFailed', - 'messageUnavailable', - 'signatureUnavailable', - ] as const)('preserves owned %s feedback through the shared boundary', code => { - const error = web3WalletFeedback(new Web3WalletActionError(code), userProfileWeb3WalletsMessages); - - expect(toLocalizableError(error)).toEqual({ code, message: userProfileWeb3WalletsMessages.errors[code] }); - }); - - it('keeps unexpected provider failures unlocalizable', () => { - const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const cause = new Error('Private provider details'); - try { - const error = web3WalletFeedback(cause, userProfileWeb3WalletsMessages); - - expect(toLocalizableError(error)).toEqual({ cause }); - } finally { - log.mockRestore(); - } - }); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts deleted file mode 100644 index 116704ac97e..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MosaicMessages } from '../../../localization'; -import { SaveError } from '../../../utils/errors'; -import { Web3WalletActionError } from './user-profile-web3-wallets-section.types'; - -export function web3WalletFeedback(error: unknown, messages: MosaicMessages['userProfileWeb3Wallets']): unknown { - if (error instanceof Web3WalletActionError) { - return new SaveError({ global: { code: error.code, message: messages.errors[error.code] } }); - } - return error; -} 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 index 992bb0502e6..1dec3950ea9 100644 --- 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 @@ -1,16 +1,14 @@ import { iconImageUrl } from '@clerk/shared/constants'; +import { ClerkRuntimeError } from '@clerk/shared/error'; import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; import { useClerk, useUser } from '@clerk/shared/react'; import type { VerificationResource, Web3WalletResource } from '@clerk/shared/types'; import { WEB3_PROVIDERS } from '@clerk/shared/web3'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; -import { useMessages } from '../../../localization'; import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; -import { web3WalletFeedback } from './user-profile-web3-wallets-feedback'; import type { UserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; -import { Web3WalletActionError } from './user-profile-web3-wallets-section.types'; export type Web3WalletEntry = Pick & { verification: Pick; @@ -97,7 +95,6 @@ export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { const clerk = useClerk(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); - const messages = useMessages('userProfileWeb3Wallets'); if (!isLoaded || !environment) { return { status: 'loading' }; @@ -120,100 +117,90 @@ export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { const requireCurrentUser = () => { const current = clerk.user; if (!current || current.id !== userId) { - throw new Web3WalletActionError('providerUnavailable'); + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); } return current; }; - const runAction = async (action: () => Promise): Promise => { - try { - await action(); - } catch (error) { - throw web3WalletFeedback(error, messages); - } - }; - return { ...projection, userId, - connect: (strategy, walletName) => - runAction(async () => { - requireCurrentUser(); - const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); - const available = projection.availableProviders.some(candidate => candidate.id === strategy); - const manager = clerk.__internal_moduleManager; - if (!provider || !available || !manager) { - throw new Web3WalletActionError('providerUnavailable'); - } - const web3 = createWeb3(manager); - const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); - if (!identifier) { - throw new Web3WalletActionError('extensionUnavailable'); - } - const current = requireCurrentUser(); - const currentProjection = projectWeb3Wallets({ - wallets: current.web3Wallets, - primaryId: current.primaryWeb3WalletId, - enabledStrategies: environment.userSettings.web3FirstFactors, - allowCreation: - Boolean(environment.userSettings.attributes.web3_wallet?.enabled) && - !environment.userSettings.attributes.web3_wallet?.immutable && - allowsIdentificationCreation(current, environment.userSettings.enterpriseSSO), - }); - if ( - currentProjection.status !== 'ready' || - !currentProjection.availableProviders.some(candidate => candidate.id === strategy) - ) { - throw new Web3WalletActionError('providerUnavailable'); - } - const normalizedIdentifier = normalizedWeb3Wallet(identifier); - const existing = current.web3Wallets.find( - wallet => - wallet.verification.status !== 'verified' && - normalizedWeb3Wallet(wallet.web3Wallet) === normalizedIdentifier, - ); - const wallet = existing ?? (await current.createWeb3Wallet({ web3Wallet: identifier })); - requireCurrentUser(); - if (!wallet) { - throw new Web3WalletActionError('creationFailed'); - } - const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); - requireCurrentUser(); - const nonce = prepared.verification.message; - if (!nonce) { - throw new Web3WalletActionError('messageUnavailable'); - } - const signature = await web3.generateWeb3Signature({ - identifier, - nonce, - provider: provider.provider, - walletName, + connect: async (strategy, walletName) => { + requireCurrentUser(); + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); + const available = projection.availableProviders.some(candidate => candidate.id === strategy); + const manager = clerk.__internal_moduleManager; + if (!provider || !available || !manager) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + const web3 = createWeb3(manager); + const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); + if (!identifier) { + throw new ClerkRuntimeError('A Web3 Wallet extension cannot be found.', { code: 'web3_missing_identifier' }); + } + const current = requireCurrentUser(); + const currentProjection = projectWeb3Wallets({ + wallets: current.web3Wallets, + primaryId: current.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: + Boolean(environment.userSettings.attributes.web3_wallet?.enabled) && + !environment.userSettings.attributes.web3_wallet?.immutable && + allowsIdentificationCreation(current, environment.userSettings.enterpriseSSO), + }); + if ( + currentProjection.status !== 'ready' || + !currentProjection.availableProviders.some(candidate => candidate.id === strategy) + ) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + const normalizedIdentifier = normalizedWeb3Wallet(identifier); + const existing = current.web3Wallets.find( + wallet => + wallet.verification.status !== 'verified' && normalizedWeb3Wallet(wallet.web3Wallet) === normalizedIdentifier, + ); + const wallet = existing ?? (await current.createWeb3Wallet({ web3Wallet: identifier })); + requireCurrentUser(); + if (!wallet) { + throw new ClerkRuntimeError('The wallet could not be created.', { code: 'web3_wallet_creation_failed' }); + } + const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); + requireCurrentUser(); + const nonce = prepared.verification.message; + if (!nonce) { + throw new ClerkRuntimeError('The wallet verification message is unavailable.', { + code: 'web3_verification_message_unavailable', }); - requireCurrentUser(); - if (!signature) { - throw new Web3WalletActionError('signatureUnavailable'); - } - await prepared.attemptVerification({ signature }); - }), - setPrimary: walletId => - runAction(async () => { - const current = requireCurrentUser(); - if (!current.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { - throw new Web3WalletActionError('providerUnavailable'); - } - await current.update({ primaryWeb3WalletId: walletId }); - }), + } + const signature = await web3.generateWeb3Signature({ + identifier, + nonce, + provider: provider.provider, + walletName, + }); + requireCurrentUser(); + if (!signature) { + throw new ClerkRuntimeError('The wallet signature is unavailable.', { code: 'web3_signature_unavailable' }); + } + await prepared.attemptVerification({ signature }); + }, + setPrimary: async walletId => { + const current = requireCurrentUser(); + if (!current.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + await current.update({ primaryWeb3WalletId: walletId }); + }, remove: web3Attribute.immutable ? undefined - : walletId => - runAction(async () => { - if (environment.userSettings.attributes.web3_wallet?.immutable) { - throw new Web3WalletActionError('providerUnavailable'); - } - const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); - if (!wallet) { - throw new Web3WalletActionError('providerUnavailable'); - } - await wallet.destroy(); - }), + : async walletId => { + if (environment.userSettings.attributes.web3_wallet?.immutable) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); + if (!wallet) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + await wallet.destroy(); + }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts index cdb5a5da66d..865ead4f68b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -16,19 +16,3 @@ export type UserProfileWeb3WalletsModel = }; export type ReadyWeb3WalletsModel = Extract; - -export class Web3WalletActionError extends Error { - constructor( - readonly code: - | 'providerUnavailable' - | 'extensionUnavailable' - | 'creationFailed' - | 'messageUnavailable' - | 'signatureUnavailable', - message: string = code, - options?: ErrorOptions, - ) { - super(message, options); - this.name = 'Web3WalletActionError'; - } -} 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 1b0b09abce2..de26c51ce6b 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 @@ -9,11 +9,6 @@ export const userProfileWeb3WalletsMessages = { 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', diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 9efc9041a00..4ab5ff2eed7 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -63,6 +63,10 @@ export const errorMessages: { readonly generic: string } & Readonly Date: Thu, 8 Oct 2026 13:38:46 -0600 Subject: [PATCH 81/86] refactor(mosaic): colocate Web3 wallet views --- packages/mosaic/eslint-suppressions.json | 4 +- .../user-profile-profile-panel.view.test.tsx | 2 +- ...-enterprise-accounts-host.feature.test.tsx | 2 +- .../user-profile-profile-panel.view.tsx | 2 +- .../user-profile-web3-wallet-row.view.tsx | 18 ++++---- ...profile-web3-wallets-section.controller.ts | 3 +- ...user-profile-web3-wallets-section.model.ts | 7 ++- .../user-profile-web3-wallets-section.tsx | 2 +- ...user-profile-web3-wallets-section.types.ts | 29 +++++++++++- ...user-profile-web3-wallets-section.view.tsx | 44 ++++--------------- .../user-profile-web3-wallets.messages.ts | 0 .../user-profile-web3-wallets.styles.ts | 2 +- packages/mosaic/src/localization/registry.ts | 2 +- .../fixtures/user-profile-web3-wallets.ts | 2 +- .../src/stories/fixtures/user-profile.tsx | 2 +- .../stories/user-profile-profile-panel.mdx | 2 +- .../user-profile-profile-panel.stories.tsx | 2 +- ...r-profile-web3-wallets-section.stories.tsx | 5 ++- 18 files changed, 67 insertions(+), 63 deletions(-) rename packages/mosaic/src/features/user-profile/{ => user-profile-web3-wallets-section}/user-profile-web3-wallet-row.view.tsx (88%) rename packages/mosaic/src/features/user-profile/{ => user-profile-web3-wallets-section}/user-profile-web3-wallets-section.view.tsx (72%) rename packages/mosaic/src/features/user-profile/{ => user-profile-web3-wallets-section}/user-profile-web3-wallets.messages.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-web3-wallets-section}/user-profile-web3-wallets.styles.ts (89%) diff --git a/packages/mosaic/eslint-suppressions.json b/packages/mosaic/eslint-suppressions.json index 378daf64886..419190c3cab 100644 --- a/packages/mosaic/eslint-suppressions.json +++ b/packages/mosaic/eslint-suppressions.json @@ -59,7 +59,7 @@ "count": 1 } }, - "src/features/user-profile/user-profile-web3-wallet-row.view.tsx": { + "src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx": { "sonarjs/cognitive-complexity": { "count": 1 } @@ -147,4 +147,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index af0c55cf8c4..053d75fe70f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -9,7 +9,7 @@ import { MosaicProvider } from '../../../mosaic-provider'; import { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view'; import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; function DeleteAccount() { const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx index 026d459c6e6..0050d4e0a42 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx @@ -4,7 +4,7 @@ import { expect, it } from 'vitest'; import { serveFapi } from '../../../../__tests__/feature/fake-fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; import { UserProfileProfilePanelView } from '../../user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '../../user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '../../user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section'; import { enterpriseAccountSeed } from './enterprise-accounts.fixtures'; 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 e7ff6f17698..abfe9a4bdda 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 @@ -9,7 +9,7 @@ import type { } from './user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from './user-profile-account-section/user-profile-account-section.view'; import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; -import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; +import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section/user-profile-web3-wallets-section.types'; export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet }; export type { UserProfileNameAttribute } from './user-profile-account-section/user-profile-account-section.types'; 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-wallets-section/user-profile-web3-wallet-row.view.tsx similarity index 88% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx index 66adbffbe91..f77d287bbc7 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-wallets-section/user-profile-web3-wallet-row.view.tsx @@ -1,16 +1,16 @@ import * as stylex from '@stylexjs/stylex'; import type { Ref } from 'react'; -import type { ActionMenuAction } from '../../components/action-menu'; -import { ActionMenu } from '../../components/action-menu'; -import { Badge } from '../../components/badge'; -import { SubmitButton } from '../../components/button'; -import { Icon, IconFrame } from '../../components/icon'; -import { Section } from '../../components/section'; -import { fill, useMessages } from '../../localization'; -import { truncationStyles } from '../../styles/typography.styles'; +import type { ActionMenuAction } from '../../../components/action-menu'; +import { ActionMenu } from '../../../components/action-menu'; +import { Badge } from '../../../components/badge'; +import { SubmitButton } from '../../../components/button'; +import { Icon, IconFrame } from '../../../components/icon'; +import { Section } from '../../../components/section'; +import { fill, useMessages } from '../../../localization'; +import { truncationStyles } from '../../../styles/typography.styles'; import { styles } from './user-profile-web3-wallets.styles'; -import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.types'; export function UserProfileWeb3WalletRowView({ wallet, 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 338df2b2764..68cff88fb74 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 @@ -2,8 +2,7 @@ import { useState } from 'react'; import { usePendingAction } from '../../../hooks/use-pending-action'; import { useMessages } from '../../../localization'; -import type { UserProfileWeb3Provider } from '../user-profile-web3-wallets-section.view'; -import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; +import type { ReadyWeb3WalletsModel, UserProfileWeb3Provider } from './user-profile-web3-wallets-section.types'; export function useUserProfileWeb3WalletsController({ wallets, 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 index 1dec3950ea9..471c2b967b2 100644 --- 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 @@ -7,8 +7,11 @@ import { WEB3_PROVIDERS } from '@clerk/shared/web3'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; -import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; -import type { UserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; +import type { + UserProfileWeb3Provider, + UserProfileWeb3Wallet, + UserProfileWeb3WalletsModel, +} from './user-profile-web3-wallets-section.types'; export type Web3WalletEntry = Pick & { verification: Pick; 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 a6294740ebc..14caefc0b56 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,10 +1,10 @@ import type { ReactNode } from 'react'; -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 { useUserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.model'; import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; +import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; export interface UserProfileWeb3WalletsSectionProps { fallback?: ReactNode; diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts index 865ead4f68b..cc553c1ea4f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -1,6 +1,33 @@ import type { Web3Strategy } from '@clerk/shared/types'; -import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; +export interface UserProfileWeb3Provider { + id: Web3Strategy; + walletPicker?: 'solana'; + provider: string; + iconUrl?: string; + connectError?: string; +} + +export interface UserProfileWeb3Wallet { + id: string; + address: string; + provider?: string; + iconUrl?: string; + isPrimary?: boolean; + isVerified: boolean; + canRemove?: boolean; + primaryError?: string; +} + +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; +} export type UserProfileWeb3WalletsModel = | { status: 'loading' } 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/user-profile-web3-wallets-section.view.tsx similarity index 72% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx index cf7905b5721..644961421bd 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/user-profile-web3-wallets-section.view.tsx @@ -1,41 +1,15 @@ -import type { Web3Strategy } from '@clerk/shared/types'; import { useMemo, useRef } from 'react'; -import { Confirmation } from '../../blocks/confirmation'; -import { Section } from '../../components/section'; -import { useListRemovalFocus } from '../../hooks/use-list-removal-focus'; -import { fill, useMessages } from '../../localization'; -import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible'; +import { Confirmation } from '../../../blocks/confirmation'; +import { Section } from '../../../components/section'; +import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import { fill, useMessages } from '../../../localization'; +import { truncateWithEndVisible } from '../../../utils/truncate-text-with-end-visible'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; - -export interface UserProfileWeb3Provider { - id: Web3Strategy; - walletPicker?: 'solana'; - provider: string; - iconUrl?: string; - connectError?: string; -} - -export interface UserProfileWeb3Wallet { - id: string; - address: string; - provider?: string; - iconUrl?: string; - isPrimary?: boolean; - isVerified: boolean; - canRemove?: boolean; - primaryError?: string; -} - -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; -} +import type { + UserProfileWeb3Wallet, + UserProfileWeb3WalletsSectionViewProps, +} from './user-profile-web3-wallets-section.types'; export function UserProfileWeb3WalletsSectionView({ wallets, 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-section/user-profile-web3-wallets.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.styles.ts similarity index 89% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets.styles.ts rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.styles.ts index b9812997644..d17a25cf20f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space } from '../../tokens.stylex'; +import { colorVars, radiusVars, space } from '../../../tokens.stylex'; export const styles = stylex.create({ connectRow: { justifyContent: 'center' }, diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 2a40f6b2d6c..62e5ef06cfd 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -26,7 +26,7 @@ import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/ import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages'; import { userProfilePasskeysMessages } from '../features/user-profile/user-profile-passkeys-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; -import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; +import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages'; import { errorMessages } from './errors.messages'; export const mosaicMessages = { diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index bf5fe3e718e..01e9d9bf5b0 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -1,7 +1,7 @@ import type { UserProfileWeb3Provider, UserProfileWeb3Wallet, -} from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +} from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index 19fefc34559..81a830b082b 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -12,7 +12,7 @@ import type { UserProfileEmail, UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import { useRef, useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 5094a1b339e..bfbc5bc6eac 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -19,7 +19,7 @@ import * as UserProfileProfilePanelStories from './user-profile-profile-panel.st import { useRef } from 'react'; import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import { UserProfileConnectedAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section'; -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; const titleRef = useRef(null); diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index e6b67d19e40..5ec9250cdd9 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -4,7 +4,7 @@ import type { UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import { useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index 34ed28e8f18..40bbf1ad9f1 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -1,4 +1,4 @@ -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -12,7 +12,8 @@ export const meta: StoryMeta = { title: 'UserProfileWeb3WalletsSection', label: 'Web3 wallets', navigation: { category: 'Sections' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx', + source: + 'packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx', }; export function Default() { From 1fd023bcaa0fb719e3156e5b59c51b30bb76a874 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 13:42:05 -0600 Subject: [PATCH 82/86] refactor(mosaic): share identification creation policy --- packages/mosaic/AGENTS.md | 1 + ...e-connected-accounts-section.model.test.ts | 27 +------------------ ...rofile-connected-accounts-section.model.ts | 21 +-------------- ...user-profile-web3-wallets-section.model.ts | 2 +- .../allows-identification-creation.test.ts | 26 ++++++++++++++++++ .../utils/allows-identification-creation.ts | 23 ++++++++++++++++ 6 files changed, 53 insertions(+), 47 deletions(-) create mode 100644 packages/mosaic/src/utils/__tests__/allows-identification-creation.test.ts create mode 100644 packages/mosaic/src/utils/allows-identification-creation.ts diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 16abbc46736..492272449ce 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -43,6 +43,7 @@ Most problems a feature hits already have a shared answer. Find the row for your | A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) | | A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) | | Current time for display labels | `useNow` + `MosaicProvider` ([#10098](https://github.com/clerk/javascript/pull/10098), once available) | +| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | | An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `styles/feedback.styles.ts` | | A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) | | Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts index 82525dd5225..543c585adb5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts @@ -2,7 +2,6 @@ import type { OAuthProvider } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; import { - allowsIdentificationCreation, createProviderCatalog, projectConnectedAccounts, recoveryFor, @@ -45,14 +44,10 @@ function account({ }; } -function userWith( - accounts: ReturnType[], - enterpriseAccounts: Parameters[0]['enterpriseAccounts'] = [], -) { +function userWith(accounts: ReturnType[]) { return { verifiedExternalAccounts: accounts.filter(a => a.verification?.status === 'verified'), unverifiedExternalAccounts: accounts.filter(a => a.verification?.status !== 'verified'), - enterpriseAccounts, }; } @@ -320,26 +315,6 @@ describe('createProviderCatalog', () => { }); }); -describe('allowsIdentificationCreation', () => { - const enterpriseSSO = { enabled: true }; - - it('blocks creation for an active enterprise connection that disables additional identifications', () => { - const blocked = userWith([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]); - expect(allowsIdentificationCreation(blocked, enterpriseSSO)).toBe(false); - }); - - it('allows creation when the enterprise connection is inactive or enterprise SSO is off', () => { - const inactive = userWith( - [], - [{ active: false, enterpriseConnection: { disableAdditionalIdentifications: true } }], - ); - expect(allowsIdentificationCreation(inactive, enterpriseSSO)).toBe(true); - - const active = userWith([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]); - expect(allowsIdentificationCreation(active, { enabled: false })).toBe(true); - }); -}); - describe('recoveryFor retry', () => { it('retries a failed connection with its strategy and missing scopes', () => { expect( diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts index dc88339dc31..2a2af35966e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts @@ -6,9 +6,6 @@ import { useClerk, useUser } from '@clerk/shared/react'; import type { ClerkAPIError, CustomOauthProvider, - EnterpriseAccountConnectionResource, - EnterpriseAccountResource, - EnterpriseSSOSettings, ExternalAccountResource, OAuthProvider, OAuthProviders, @@ -19,6 +16,7 @@ import type { import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; +import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; import type { ConnectedAccountActionResult, ConnectedAccountProviderDisplay, @@ -31,11 +29,6 @@ type AccountData = Pick & { error: Pick | null }) | null; }; -type EnterpriseUser = { - enterpriseAccounts: (Pick & { - enterpriseConnection?: Pick | null; - })[]; -}; type ProjectedUser = { verifiedExternalAccounts: AccountData[]; unverifiedExternalAccounts: AccountData[]; @@ -125,18 +118,6 @@ export function createProviderCatalog( ]; } -export function allowsIdentificationCreation( - user: EnterpriseUser, - enterpriseSSO: Pick, -): boolean { - if (!enterpriseSSO.enabled) { - return true; - } - return !user.enterpriseAccounts.some( - account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications, - ); -} - function findAdditionalScopes(account: AccountData, scopes: AdditionalOAuthScopes | undefined): string[] { const requested = scopes?.[account.provider] ?? []; const approved = account.approvedScopes.split(' '); 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 index 471c2b967b2..a93be83dbcd 100644 --- 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 @@ -6,7 +6,7 @@ import type { VerificationResource, Web3WalletResource } from '@clerk/shared/typ import { WEB3_PROVIDERS } from '@clerk/shared/web3'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; -import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; +import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet, diff --git a/packages/mosaic/src/utils/__tests__/allows-identification-creation.test.ts b/packages/mosaic/src/utils/__tests__/allows-identification-creation.test.ts new file mode 100644 index 00000000000..fb805ba8201 --- /dev/null +++ b/packages/mosaic/src/utils/__tests__/allows-identification-creation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { allowsIdentificationCreation } from '../allows-identification-creation'; + +describe('allowsIdentificationCreation', () => { + const enterpriseSSO = { enabled: true }; + + it('blocks creation for an active enterprise connection that disables additional identifications', () => { + const blocked = { + enterpriseAccounts: [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }], + }; + expect(allowsIdentificationCreation(blocked, enterpriseSSO)).toBe(false); + }); + + it('allows creation when the enterprise connection is inactive or enterprise SSO is off', () => { + const inactive = { + enterpriseAccounts: [{ active: false, enterpriseConnection: { disableAdditionalIdentifications: true } }], + }; + expect(allowsIdentificationCreation(inactive, enterpriseSSO)).toBe(true); + + const active = { + enterpriseAccounts: [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }], + }; + expect(allowsIdentificationCreation(active, { enabled: false })).toBe(true); + }); +}); diff --git a/packages/mosaic/src/utils/allows-identification-creation.ts b/packages/mosaic/src/utils/allows-identification-creation.ts new file mode 100644 index 00000000000..8ac0f8b541c --- /dev/null +++ b/packages/mosaic/src/utils/allows-identification-creation.ts @@ -0,0 +1,23 @@ +import type { + EnterpriseAccountConnectionResource, + EnterpriseAccountResource, + EnterpriseSSOSettings, +} from '@clerk/shared/types'; + +type EnterpriseUser = { + enterpriseAccounts: (Pick & { + enterpriseConnection?: Pick | null; + })[]; +}; + +export function allowsIdentificationCreation( + user: EnterpriseUser, + enterpriseSSO: Pick, +): boolean { + if (!enterpriseSSO.enabled) { + return true; + } + return !user.enterpriseAccounts.some( + account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications, + ); +} From 0f24aa04e94338c7540b53af782343034b8e948a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 13:51:26 -0600 Subject: [PATCH 83/86] chore(mosaic): note Solana picker pending cleanup --- .../user-profile-web3-wallets-section.controller.ts | 1 + 1 file changed, 1 insertion(+) 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 68cff88fb74..667c82ef7e2 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 @@ -21,6 +21,7 @@ export function useUserProfileWeb3WalletsController({ if (!provider) { return; } + // TODO: Open the Solana picker without pending while preserving the same-render action lock. return action.run(provider.id, () => { if (provider.walletPicker === 'solana') { setPicker(provider); From f2364899ca9e24bd477eb55036cb5189006a9618 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 13:55:22 -0600 Subject: [PATCH 84/86] refactor(mosaic): combine Solana picker state --- ...profile-web3-wallets-section.controller.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) 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 667c82ef7e2..6c2f0cad69b 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 @@ -4,6 +4,8 @@ import { usePendingAction } from '../../../hooks/use-pending-action'; import { useMessages } from '../../../localization'; import type { ReadyWeb3WalletsModel, UserProfileWeb3Provider } from './user-profile-web3-wallets-section.types'; +type SolanaPicker = { open: false } | { open: true; provider: UserProfileWeb3Provider; walletName?: string }; + export function useUserProfileWeb3WalletsController({ wallets, availableProviders, @@ -13,8 +15,7 @@ export function useUserProfileWeb3WalletsController({ }: Pick) { const messages = useMessages('userProfileWeb3Wallets'); const action = usePendingAction({ errorFallback: messages.errors.generic }); - const [picker, setPicker] = useState(null); - const [pendingWalletName, setPendingWalletName] = useState(); + const [picker, setPicker] = useState({ open: false }); const onConnect = (id: string) => { const provider = availableProviders.find(candidate => candidate.id === id); @@ -24,7 +25,7 @@ export function useUserProfileWeb3WalletsController({ // TODO: Open the Solana picker without pending while preserving the same-render action lock. return action.run(provider.id, () => { if (provider.walletPicker === 'solana') { - setPicker(provider); + setPicker({ open: true, provider }); return; } return connect(provider.id); @@ -32,17 +33,13 @@ export function useUserProfileWeb3WalletsController({ }; const connectSolana = (walletName: string) => { - if (!picker) { + if (!picker.open) { return; } - return action.run(picker.id, async () => { - setPendingWalletName(walletName); - try { - await connect(picker.id, walletName); - setPicker(null); - } finally { - setPendingWalletName(undefined); - } + return action.run(picker.provider.id, async () => { + setPicker({ ...picker, walletName }); + await connect(picker.provider.id, walletName); + setPicker({ open: false }); }); }; @@ -51,21 +48,21 @@ export function useUserProfileWeb3WalletsController({ action.errorKey === wallet.id ? { ...wallet, primaryError: action.error } : wallet, ), availableProviders: availableProviders.map(provider => - action.errorKey === provider.id && picker?.id !== provider.id + action.errorKey === provider.id && (!picker.open || picker.provider.id !== provider.id) ? { ...provider, connectError: action.error } : provider, ), pendingId: action.pendingKey, - solanaPickerOpen: picker !== null, - solanaPickerError: picker?.id === action.errorKey ? action.error : undefined, - pendingWalletName, + solanaPickerOpen: picker.open, + solanaPickerError: picker.open && picker.provider.id === action.errorKey ? action.error : undefined, + pendingWalletName: picker.open && picker.provider.id === action.pendingKey ? picker.walletName : undefined, onConnect, onSetPrimary: (walletId: string) => action.run(walletId, () => setPrimary(walletId)), onRemove: remove, connectSolana, closeSolanaPicker: () => { if (!action.isPending) { - setPicker(null); + setPicker({ open: false }); } }, }; From 136112ce7ca9a877fe8b8b5460a8b5b0e838f847 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 14:02:08 -0600 Subject: [PATCH 85/86] chore(mosaic): note shared wallet removal lock --- .../user-profile-web3-wallets-section.controller.ts | 1 + 1 file changed, 1 insertion(+) 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 6c2f0cad69b..a35d047a3b1 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 @@ -58,6 +58,7 @@ export function useUserProfileWeb3WalletsController({ pendingWalletName: picker.open && picker.provider.id === action.pendingKey ? picker.walletName : undefined, onConnect, onSetPrimary: (walletId: string) => action.run(walletId, () => setPrimary(walletId)), + // TODO: Share the action lock during removal while preserving errors for the confirmation dialog. onRemove: remove, connectSolana, closeSolanaPicker: () => { From fbc0956455919693d3441d5616fe04e614f569ec Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 14:37:06 -0600 Subject: [PATCH 86/86] chore: use empty Web3 changeset --- .changeset/web3-wallets-wire-up.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md index 3f8f79d2977..a845151cc84 100644 --- a/.changeset/web3-wallets-wire-up.md +++ b/.changeset/web3-wallets-wire-up.md @@ -1,6 +1,2 @@ --- -"@clerk/mosaic": patch --- - -Show wallet discovery errors consistently with other dialogs and ask users to refresh the page when Solana wallets cannot load. -Localize Web3 wallet connection errors through the shared error catalog, including custom translations for unavailable providers and missing wallet extensions.