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

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

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

Web3 wallets

+

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

+
+ {!isLoaded ?

Loading…

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

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

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

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

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

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

+ ) : null} + {wallets.map(wallet => ( + { + if (selectedWalletName.current === wallet.name) { + selectedWallet.current = element; + } + }} + type='button' + variant='outline' + color='neutral' + fullWidth + isPending={pending} + disabled={pending} + onClick={event => { + selectedWalletName.current = wallet.name; + selectedWallet.current = event.currentTarget; + onConnect(wallet.name); + }} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + ))} +
+ + + {m.solanaDialog.cancel} + + } + /> + +
+ + {verification ? ( + <> + + + + + + ) : null} + + + )} +
diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx index 9566865b9aa..2c74b8568ba 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; @@ -72,3 +73,41 @@ describe('Web3 wallet controller', () => { expect(result.current.solanaPickerOpen).toBe(false); }); }); + +it.each(['connect', 'primary', 'solana'] as const)( + 'cancels %s verification without an error and allows retry', + async action => { + const cancelled = new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' }); + const request = vi.fn().mockRejectedValueOnce(cancelled).mockResolvedValueOnce(undefined); + const { result } = renderHook(() => + useUserProfileWeb3WalletsController({ + wallets, + availableProviders: [...availableProviders, { id: 'web3_solana_signature', provider: 'Solana' }], + connect: request, + setPrimary: request, + }), + ); + const run = () => + action === 'primary' + ? result.current.onSetPrimary('wallet_1') + : action === 'solana' + ? result.current.connectSolana('Phantom') + : result.current.onConnect('web3_metamask_signature'); + if (action === 'solana') { + act(() => void result.current.onConnect('web3_solana_signature')); + } + await act(async () => { + await run(); + }); + expect(result.current.pendingId).toBeUndefined(); + expect(result.current.wallets[0].primaryError).toBeUndefined(); + expect(result.current.availableProviders.every(provider => provider.connectError === undefined)).toBe(true); + if (action === 'solana') { + expect(result.current.solanaPickerOpen).toBe(true); + } + await act(async () => { + await run(); + }); + expect(request).toHaveBeenCalledTimes(2); + }, +); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts index 9e0ba769130..e53c4a9b034 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts @@ -1,3 +1,4 @@ +import { isReverificationCancelledError } from '@clerk/shared/error'; import { useRef, useState } from 'react'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; @@ -34,6 +35,9 @@ export function useUserProfileWeb3WalletsController({ await action(); return true; } catch (error) { + if (isReverificationCancelledError(error)) { + return false; + } const message = error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'; setErrors(current => ({ ...current, [id]: message })); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx index 47328911f63..ab0be4241ee 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -1,11 +1,12 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; import { useClerk, useUser } from '@clerk/shared/react'; import { WEB3_PROVIDERS } from '@clerk/shared/web3'; -import type { ReactNode } from 'react'; +import { type ReactNode, useRef } from 'react'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; -import { Reverification, useReverificationFlow } from '../../reverification'; +import { currentInteractionOrigin } from '../../../primitives/utils/interaction-origin'; +import { ReverificationDialog, useReverificationFlow } from '../../reverification'; import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; @@ -17,18 +18,26 @@ export interface UserProfileWeb3WalletsSectionProps { fallbackFocus?: () => HTMLElement | null; } -function errorMessage(error: unknown): string { +function actionError(error: unknown): unknown { + if (isReverificationCancelledError(error)) { + return error; + } if (isClerkAPIResponseError(error)) { const first = error.errors[0]; - return first?.longMessage || first?.message || 'Something went wrong. Please try again.'; + return new Error(first?.longMessage || first?.message || 'Something went wrong. Please try again.'); } - return error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'; + return new Error(error instanceof Error && error.message ? error.message : 'Something went wrong. Please try again.'); } export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserProfileWeb3WalletsSectionProps) { const clerk = useClerk(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); + const reverificationFocus = useRef(null); + const captureReverificationFocus = () => { + const active = document.activeElement; + reverificationFocus.current = currentInteractionOrigin() ?? (active instanceof HTMLElement ? active : null); + }; const [createWallet, createReverification] = useReverificationFlow((address: string) => user?.createWeb3Wallet({ web3Wallet: address }), ); @@ -36,6 +45,10 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP user?.update({ primaryWeb3WalletId: walletId }), ); + const [destroyWallet, removeReverification] = useReverificationFlow((walletId: string) => + user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(), + ); + const projection = user && environment ? projectWeb3Wallets({ @@ -53,6 +66,7 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP : ({ status: 'hidden' } as const); const connect = async (strategy: string, walletName?: string) => { + captureReverificationFocus(); const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); const manager = clerk.__internal_moduleManager; if (!provider || !manager || !user) { @@ -85,26 +99,27 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP } await prepared.attemptVerification({ signature }); } catch (error) { - throw new Error(errorMessage(error)); + throw actionError(error); } }; const setPrimary = async (walletId: string) => { + captureReverificationFocus(); if (!user?.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { return; } try { await updatePrimary(walletId); } catch (error) { - throw new Error(errorMessage(error)); + throw actionError(error); } }; const remove = async (walletId: string) => { try { - await user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(); + await destroyWallet(walletId); } catch (error) { - throw new Error(errorMessage(error)); + throw actionError(error); } }; @@ -136,9 +151,11 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP void controller.onSetPrimary(id); }} onRemove={remove} + removeReverification={removeReverification} /> provider.id === 'web3_solana_signature')?.connectError} onOpenChange={open => { @@ -150,8 +167,16 @@ export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserP void controller.connectSolana(walletName); }} /> - - + {!controller.solanaPickerOpen ? ( + + ) : null} + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts index 4c2bbc2339d..ec91f51cf49 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts @@ -8,6 +8,7 @@ export const userProfileWeb3WalletsMessages = { primary: 'Primary', unverified: 'Unverified', solanaDialog: { + back: 'Back', title: 'Select a Solana wallet', description: 'Choose an installed wallet to connect to your account.', noneAvailable: 'No Solana wallets are available.', From 45b02b7b7659edf3fab288e95c91d1bb63fef63d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:23:34 -0600 Subject: [PATCH 3/5] 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 e52235a48ad7587f65f2e3b1c8601b2a942e269b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:24:39 -0600 Subject: [PATCH 4/5] 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 d36fd36fba4349f179d1c9c09c7274146911c204 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 28 Sep 2026 16:55:14 -0600 Subject: [PATCH 5/5] 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; }