Skip to content

Commit 51aed17

Browse files
fix(mosaic): resume verification from saved wallet rows (#10366)
1 parent 4cf8e33 commit 51aed17

22 files changed

Lines changed: 1208 additions & 165 deletions

‎.changeset/calm-wallet-actions.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': patch
3+
---
4+
5+
Prevent actions in `UserButton` and `OrganizationSwitcher` menus from activating during the closing animation.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/components/action-menu/action-menu.test.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,30 @@ describe('Mosaic ActionMenu', () => {
3737

3838
expect(onClick).toHaveBeenCalledOnce();
3939
});
40+
it('keeps a disabled trigger focusable without allowing pointer or keyboard activation', async () => {
41+
const onClick = vi.fn();
42+
render(
43+
<MosaicProvider>
44+
<ActionMenu
45+
label='Manage wallet'
46+
actions={[{ label: 'Remove', onClick }]}
47+
disabled
48+
focusableWhenDisabled
49+
/>
50+
</MosaicProvider>,
51+
);
52+
const user = userEvent.setup();
53+
const trigger = screen.getByRole('button', { name: 'Manage wallet' });
54+
await user.tab();
55+
expect(trigger).toHaveFocus();
56+
expect(trigger).toHaveAttribute('aria-disabled', 'true');
57+
await user.click(trigger);
58+
expect(screen.queryByRole('menu')).toBeNull();
59+
await user.keyboard('{Enter} {ArrowDown}');
60+
expect(screen.queryByRole('menu')).toBeNull();
61+
expect(onClick).not.toHaveBeenCalled();
62+
});
63+
4064
it('disables both the trigger and existing menu items while busy', async () => {
4165
const onClick = vi.fn();
4266
const actions = [{ label: 'Remove', onClick }];

‎packages/mosaic/src/components/action-menu/action-menu.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ export interface ActionMenuProps {
1616
actions: ActionMenuAction[];
1717
triggerRef?: Ref<HTMLButtonElement>;
1818
disabled?: boolean;
19+
focusableWhenDisabled?: boolean;
1920
}
2021

21-
export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuProps) {
22+
export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhenDisabled }: ActionMenuProps) {
2223
if (actions.length === 0) {
2324
return null;
2425
}
@@ -29,6 +30,7 @@ export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuP
2930
ref={triggerRef}
3031
aria-label={label}
3132
disabled={disabled}
33+
focusableWhenDisabled={focusableWhenDisabled}
3234
/>
3335
<Menu.Popup>
3436
{actions.map(action => (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { deferred } from '../../../../__tests__/async';
6+
import { holdRequests, serveFapi } from '../../../../__tests__/feature/fake-fapi';
7+
import { fapiVerification, fapiWeb3Wallet } from '../../../../__tests__/feature/fapi';
8+
import { openMenu } from '../../../../__tests__/feature/menu';
9+
import { renderWithClerk } from '../../../../__tests__/feature/render';
10+
import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section';
11+
import { registerWallets, signedInWallets } from './user-profile-web3-wallets.fixtures';
12+
13+
function stubEvmModules(clerk: Awaited<ReturnType<typeof renderWithClerk>>['clerk']) {
14+
Object.defineProperty(clerk, '__internal_moduleManager', {
15+
value: {
16+
import: async (module: string) => {
17+
if (module === '@base-org/account') {
18+
return { createBaseAccountSDK: () => ({ getProvider: () => window.ethereum }) };
19+
}
20+
if (module === '@coinbase/wallet-sdk') {
21+
return { createCoinbaseWalletSDK: () => ({ getProvider: () => window.ethereum }) };
22+
}
23+
return undefined;
24+
},
25+
},
26+
});
27+
}
28+
29+
describe('Web3 connection rows', () => {
30+
afterEach(() => vi.unstubAllGlobals());
31+
32+
it.each([
33+
['MetaMask', 'web3_metamask_signature'],
34+
['Base', 'web3_base_signature'],
35+
['Coinbase Wallet', 'web3_coinbase_wallet_signature'],
36+
['OKX Wallet', 'web3_okx_wallet_signature'],
37+
] as const)('verifies a saved %s wallet without creating another resource', async (name, strategy) => {
38+
const address = '0x1234567890abcdef';
39+
vi.stubGlobal('ethereum', {
40+
request: vi.fn(({ method }: { method: string }) =>
41+
Promise.resolve(method === 'eth_requestAccounts' ? [address] : 'signature'),
42+
),
43+
});
44+
const fapi = serveFapi(
45+
signedInWallets([strategy], {
46+
web3_wallets: [
47+
fapiWeb3Wallet({
48+
id: 'saved_wallet',
49+
web3_wallet: address,
50+
verification: fapiVerification(strategy, { status: 'unverified' }),
51+
}),
52+
],
53+
}),
54+
);
55+
const { clerk } = await renderWithClerk(<UserProfileWeb3WalletsSection />);
56+
stubEvmModules(clerk);
57+
const creation = holdRequests('post', '/v1/me/web3_wallets');
58+
59+
expect(screen.queryByRole('button', { name: `Connect ${name}` })).toBeNull();
60+
const user = userEvent.setup();
61+
await openMenu(user, screen.getByRole('button', { name: `Manage ${name}` }));
62+
await user.click(screen.getByRole('menuitem', { name: 'Verify' }));
63+
await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'));
64+
expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual(['saved_wallet']);
65+
expect(creation.requests).toHaveLength(0);
66+
creation.release();
67+
});
68+
69+
it('rejects a different MetaMask account before preparing or creating a saved wallet', async () => {
70+
vi.stubGlobal('ethereum', {
71+
request: vi.fn(({ method }: { method: string }) => {
72+
expect(method).toBe('eth_requestAccounts');
73+
return Promise.resolve(['0xabcdef1234567890']);
74+
}),
75+
});
76+
const fapi = serveFapi(
77+
signedInWallets(['web3_metamask_signature'], {
78+
web3_wallets: [
79+
fapiWeb3Wallet({
80+
id: 'saved_wallet',
81+
web3_wallet: '0x1234567890abcdef',
82+
verification: fapiVerification('web3_metamask_signature', { status: 'unverified' }),
83+
}),
84+
],
85+
}),
86+
);
87+
await renderWithClerk(<UserProfileWeb3WalletsSection />);
88+
const creation = holdRequests('post', '/v1/me/web3_wallets');
89+
const preparation = holdRequests('post', '/v1/me/web3_wallets/:id/prepare_verification');
90+
91+
const user = userEvent.setup();
92+
await openMenu(user, screen.getByRole('button', { name: 'Manage MetaMask' }));
93+
await user.click(screen.getByRole('menuitem', { name: 'Verify' }));
94+
expect(await screen.findByRole('alert')).toHaveTextContent('Select the wallet account that matches this address.');
95+
expect(creation.requests).toHaveLength(0);
96+
expect(preparation.requests).toHaveLength(0);
97+
expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual(['saved_wallet']);
98+
creation.release();
99+
preparation.release();
100+
});
101+
102+
it.each([
103+
['MetaMask', 'web3_metamask_signature'],
104+
['Base', 'web3_base_signature'],
105+
['Coinbase Wallet', 'web3_coinbase_wallet_signature'],
106+
['OKX Wallet', 'web3_okx_wallet_signature'],
107+
] as const)('moves pending %s connection to its saved wallet row while signing', async (name, strategy) => {
108+
const signature = deferred<string>();
109+
vi.stubGlobal('ethereum', {
110+
request: vi.fn(({ method }: { method: string }) =>
111+
method === 'eth_requestAccounts' ? Promise.resolve(['0x1234567890abcdef']) : signature.promise,
112+
),
113+
});
114+
const fapi = serveFapi(signedInWallets([strategy]));
115+
const { clerk } = await renderWithClerk(<UserProfileWeb3WalletsSection />);
116+
stubEvmModules(clerk);
117+
118+
await userEvent.setup().click(await screen.findByRole('button', { name: `Connect ${name}` }));
119+
await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1));
120+
expect(await screen.findByRole('button', { name: `Manage ${name}` })).toBeInTheDocument();
121+
expect(screen.queryByRole('button', { name: `Verify ${name}` })).toBeNull();
122+
expect(screen.queryByRole('button', { name: `Connect ${name}` })).toBeNull();
123+
expect(screen.getByText('Verifying')).toBeInTheDocument();
124+
expect(screen.getByTitle('0x1234567890abcdef')).toBeInTheDocument();
125+
126+
signature.resolve('signature');
127+
await waitFor(() =>
128+
expect(
129+
fapi.client.sessions[0]?.user.web3_wallets.find(wallet => wallet.web3_wallet === '0x1234567890abcdef')
130+
?.verification?.status,
131+
).toBe('verified'),
132+
);
133+
expect(await screen.findByTitle('0x1234567890abcdef')).toBeInTheDocument();
134+
expect(screen.queryByRole('button', { name: `Connect ${name}` })).toBeNull();
135+
});
136+
137+
it('keeps the Solana picker pending without a duplicate wallet row while signing', async () => {
138+
const signature = deferred<[{ signature: Uint8Array }]>();
139+
const account = {
140+
address: 'SolanaAbCd',
141+
publicKey: new Uint8Array([1, 2, 3]),
142+
chains: ['solana:mainnet' as const],
143+
features: ['solana:signMessage' as const],
144+
};
145+
const wallet = {
146+
version: '1.0.0' as const,
147+
name: 'Test Solana',
148+
icon: 'data:image/svg+xml;base64,' as const,
149+
chains: ['solana:mainnet' as const],
150+
accounts: [account],
151+
features: {
152+
'standard:connect': { version: '1.0.0' as const, connect: () => Promise.resolve({ accounts: [account] }) },
153+
'solana:signMessage': { version: '1.0.0' as const, signMessage: () => signature.promise },
154+
},
155+
};
156+
const unregister = registerWallets(wallet);
157+
try {
158+
const fapi = serveFapi(signedInWallets(['web3_solana_signature']));
159+
await renderWithClerk(<UserProfileWeb3WalletsSection />);
160+
await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Solana' }));
161+
await userEvent.setup().click(await screen.findByRole('button', { name: 'Test Solana' }));
162+
163+
await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1));
164+
await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Solana' })).toBeInTheDocument());
165+
expect(screen.queryByRole('button', { name: 'Verify Solana' })).toBeNull();
166+
expect(screen.queryByRole('button', { name: 'Connect Solana' })).toBeNull();
167+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
168+
await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Solana' })).toHaveFocus());
169+
expect(screen.getByText('Verifying')).toBeInTheDocument();
170+
171+
signature.resolve([{ signature: new Uint8Array([4, 5, 6]) }]);
172+
await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'));
173+
expect(await screen.findByTitle('SolanaAbCd')).toBeInTheDocument();
174+
} finally {
175+
unregister();
176+
}
177+
});
178+
179+
it.each([
180+
['SolanaAbCd', 'verified'],
181+
['SolanaOther', 'mismatch'],
182+
['Solanaabcd', 'mismatch'],
183+
['SolanaAbCd', 'retry'],
184+
] as const)('checks a selected Solana account against the saved wallet (%s, %s)', async (address, outcome) => {
185+
let signatures = 0;
186+
const account = {
187+
address,
188+
publicKey: new Uint8Array([1, 2, 3]),
189+
chains: ['solana:mainnet' as const],
190+
features: ['solana:signMessage' as const],
191+
};
192+
const wallet = {
193+
version: '1.0.0' as const,
194+
name: 'Saved Solana',
195+
icon: 'data:image/svg+xml;base64,' as const,
196+
chains: ['solana:mainnet' as const],
197+
accounts: [account],
198+
features: {
199+
'standard:connect': { version: '1.0.0' as const, connect: () => Promise.resolve({ accounts: [account] }) },
200+
'solana:signMessage': {
201+
version: '1.0.0' as const,
202+
signMessage: () => {
203+
signatures += 1;
204+
return outcome === 'retry' && signatures === 1
205+
? Promise.reject(new Error('User rejected the request.'))
206+
: Promise.resolve([{ signature: new Uint8Array([4, 5, 6]) }]);
207+
},
208+
},
209+
},
210+
};
211+
const unregister = registerWallets(wallet);
212+
try {
213+
const fapi = serveFapi(
214+
signedInWallets(['web3_solana_signature'], {
215+
web3_wallets: [
216+
fapiWeb3Wallet({
217+
id: 'saved_solana',
218+
web3_wallet: 'SolanaAbCd',
219+
verification: fapiVerification('web3_solana_signature', { status: 'unverified' }),
220+
}),
221+
],
222+
}),
223+
);
224+
await renderWithClerk(<UserProfileWeb3WalletsSection />);
225+
const creation = holdRequests('post', '/v1/me/web3_wallets');
226+
const preparation = holdRequests('post', '/v1/me/web3_wallets/:id/prepare_verification');
227+
228+
expect(screen.queryByRole('button', { name: 'Connect Solana' })).toBeNull();
229+
const user = userEvent.setup();
230+
await openMenu(user, screen.getByRole('button', { name: 'Manage Solana' }));
231+
await user.click(screen.getByRole('menuitem', { name: 'Verify' }));
232+
await user.click(await screen.findByRole('button', { name: 'Saved Solana' }));
233+
if (outcome !== 'mismatch') {
234+
await waitFor(() => expect(preparation.requests).toHaveLength(1));
235+
preparation.release();
236+
if (outcome === 'retry') {
237+
await waitFor(() =>
238+
expect(screen.getByRole('alert')).toHaveTextContent('You have rejected the signature request.'),
239+
);
240+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
241+
expect(screen.getByRole('button', { name: 'Manage Solana' })).toBeEnabled();
242+
expect(screen.queryByRole('button', { name: 'Connect Solana' })).toBeNull();
243+
expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('unverified');
244+
expect(signatures).toBe(1);
245+
await openMenu(user, screen.getByRole('button', { name: 'Manage Solana' }));
246+
await user.click(screen.getByRole('menuitem', { name: 'Verify' }));
247+
await user.click(await screen.findByRole('button', { name: 'Saved Solana' }));
248+
}
249+
await waitFor(() =>
250+
expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'),
251+
);
252+
expect(signatures).toBe(outcome === 'retry' ? 2 : 1);
253+
} else {
254+
await waitFor(() =>
255+
expect(screen.getByRole('alert')).toHaveTextContent('Select the wallet account that matches this address.'),
256+
);
257+
expect(preparation.requests).toHaveLength(0);
258+
expect(signatures).toBe(0);
259+
expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('unverified');
260+
preparation.release();
261+
}
262+
expect(fapi.client.sessions[0]?.user.web3_wallets.map(candidate => candidate.id)).toEqual(['saved_solana']);
263+
expect(creation.requests).toHaveLength(0);
264+
creation.release();
265+
} finally {
266+
unregister();
267+
}
268+
});
269+
});

0 commit comments

Comments
 (0)