Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-wallet-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/mosaic": patch
---

Show a clear cancellation message for rejected wallet requests in UserProfile, and explain when a request is still pending in the wallet. Ignore late wallet responses after closing the profile so reopening starts a fresh connection flow.
2 changes: 1 addition & 1 deletion packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ A flow lives in `src/features/<feature>/`. The **model** (`*.model.ts(x)`) is th

Most problems a feature hits already have a shared answer. Find the line for your problem and use it. Do not write a local version, even a small one. A PR that adds, renames or changes the signature of a shared helper in `src/hooks/`, `src/utils/`, `src/blocks/`, `src/components/form/` or `src/machine/` updates this list, and any doc that names it, in the same PR.

- An async action from a button or row: pending, an error, one lock → `usePendingAction` (`src/hooks/`)
- An async action from a button or row: pending, an error, one lock, unmount cancellation → `usePendingAction` (`src/hooks/`)
- A form: values, validation, submit, field errors, success UI → `useForm` (`src/components/form/`), with `validateAsync` for server checks and `onSuccess` for mounted success UI
- A `<form>` element, without the browser's validation popup → `Form` (`src/primitives/form/`)
- A model save whose Clerk errors should land on form fields → `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError`
Expand Down
1 change: 1 addition & 0 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ return {

- `run` resolves `true` on success, so the caller can close a dialog or move focus. A cancelled reverification resolves `false`, shows no error and releases the lock.
- To hold pending through an OAuth redirect, await a delay inside the action (`user-profile-connected-accounts-section.controller.ts`).
- The action receives an `AbortSignal` that aborts on unmount. Completions after unmount cannot update the hook's state. Aborting the signal does not stop an underlying provider request or undo a server mutation. Check the signal after external waits and before side effects. `reset()` only clears errors.
- It is not for a form (`useForm`), a confirm step (`useConfirmController`), an action whose result drives other state (a machine), or a check that reruns as the user types (`useDebouncedAsync`, or a field's `validateAsync`).

For a direct action in `ActionMenu`, opt in with `status: 'idle'` and set it to `'pending'` in the click event while the request runs and no dialog is open yet; the menu closes on the next render unless an action is pending. While pending, the menu reserves room for a spinner, disables its actions and blocks dismissal. It closes when the status returns to `'idle'`. Leave `status` undefined for ordinary actions that close through the menu's own click.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { deferred } from '../../../../__tests__/async';
import { serveFapi } from '../../../../__tests__/feature/fake-fapi';
import { openMenu } from '../../../../__tests__/feature/menu';
import { renderWithClerk } from '../../../../__tests__/feature/render';
import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section';
import { signedInWallets } from './user-profile-web3-wallets.fixtures';

const request = vi.fn<(params: { method: string }) => Promise<string | string[]>>();
const provider = { request };
const announceOKX = () =>
window.dispatchEvent(
new CustomEvent('eip6963:announceProvider', {
detail: { info: { name: 'OKX Wallet', uuid: 'recovery-okx', icon: '', rdns: 'com.okx.wallet' }, provider },
}),
);

afterEach(() => window.removeEventListener('eip6963:requestProvider', announceOKX));

describe.each([
['Base', 'web3_base_signature', '@base-org/account'],
['Coinbase Wallet', 'web3_coinbase_wallet_signature', '@coinbase/wallet-sdk'],
['OKX Wallet', 'web3_okx_wallet_signature', null],
] as const)('%s recovery', (name, strategy, moduleName) => {
async function setup() {
vi.stubGlobal('ethereum', {
request: () => {
throw new Error('The selected wallet must not use the fallback Ethereum provider.');
},
});
const fapi = serveFapi(signedInWallets([strategy]));
const view = await renderWithClerk(<UserProfileWeb3WalletsSection />);
Object.defineProperty(view.clerk, '__internal_moduleManager', {
value: {
import: (module: string) => {
expect(module).toBe(moduleName);
return Promise.resolve({
createBaseAccountSDK: () => ({ getProvider: () => provider }),
createCoinbaseWalletSDK: () => ({ getProvider: () => provider }),
});
},
},
});
if (moduleName === null) {
window.addEventListener('eip6963:requestProvider', announceOKX);
announceOKX();
}
return { fapi, view, user: userEvent.setup() };
}

it.each(['eth_requestAccounts', 'personal_sign'] as const)(
'recovers from cancellation during %s and verifies on retry',
async method => {
request.mockImplementation(({ method: requested }) => {
if (requested === method) {
return Promise.reject(Object.assign(new Error('User rejected the request.'), { code: 4001 }));
}
return Promise.resolve(['0x1234567890abcdef']);
});
const { fapi, user } = await setup();
await user.click(await screen.findByRole('button', { name: `Connect ${name}` }));
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent('Wallet connection was cancelled. Please try again.'),
);
request.mockImplementation(({ method: requested }) =>
Promise.resolve(requested === 'personal_sign' ? 'signature' : ['0x1234567890abcdef']),
);
if (method === 'personal_sign') {
await openMenu(user, screen.getByRole('button', { name: `Manage ${name}` }));
await user.click(screen.getByRole('menuitem', { name: 'Verify' }));
} else {
expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0);
await user.click(screen.getByRole('button', { name: `Connect ${name}` }));
}
await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'));
expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
},
);

it('explains a pending provider request and releases its busy state', async () => {
request.mockRejectedValue({ code: -32002 });
const { user } = await setup();
await user.click(await screen.findByRole('button', { name: `Connect ${name}` }));
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent(
'A request is already pending in your wallet. Open your wallet to complete or reject it, then try again.',
),
);
expect(screen.getByRole('button', { name: `Connect ${name}` })).toBeEnabled();
});

it.each([
['eth_requestAccounts', 'resolve'],
['eth_requestAccounts', 'reject'],
['personal_sign', 'resolve'],
['personal_sign', 'reject'],
] as const)('ignores abandoned %s %s after reopening and completes the new attempt', async (method, outcome) => {
const old = deferred<string | string[]>();
const next = deferred<string | string[]>();
request.mockImplementation(async ({ method: requested }) =>
requested === method ? old.promise : ['0x1234567890abcdef'],
);
const { fapi, view, user } = await setup();
await user.click(await screen.findByRole('button', { name: `Connect ${name}` }));
await waitFor(() => expect(request).toHaveBeenCalledWith(expect.objectContaining({ method })));
view.rerender(<div />);
view.rerender(<UserProfileWeb3WalletsSection />);
request.mockImplementation(async ({ method: requested }) => {
if (requested === method) {
return next.promise;
}
return requested === 'personal_sign' ? 'signature' : ['0x1234567890abcdef'];
});
if (method === 'personal_sign') {
await openMenu(user, await screen.findByRole('button', { name: `Manage ${name}` }));
await user.click(screen.getByRole('menuitem', { name: 'Verify' }));
} else {
await user.click(await screen.findByRole('button', { name: `Connect ${name}` }));
}
await waitFor(() => expect(request.mock.calls.filter(([params]) => params.method === method)).toHaveLength(2));
await act(() => {
if (outcome === 'resolve') {
old.resolve(method === 'personal_sign' ? 'old-signature' : ['0xold']);
} else {
old.reject({ code: 4001 });
}
});
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(method === 'personal_sign' ? 1 : 0);
expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).not.toBe('verified');
expect(
screen.getByRole('button', { name: `${method === 'personal_sign' ? 'Manage' : 'Connect'} ${name}` }),
).toHaveAttribute('aria-disabled', 'true');
await act(() => next.resolve(method === 'personal_sign' ? 'signature' : ['0x1234567890abcdef']));
await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'));
expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';

import { deferred } from '../../../../__tests__/async';
import { serveFapi } from '../../../../__tests__/feature/fake-fapi';
import { renderWithClerk } from '../../../../__tests__/feature/render';
import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section';
import { signedInWallets } from './user-profile-web3-wallets.fixtures';

it('handles cancellation in MetaMask without offering a Clerk cancel button and allows a fresh connection', async () => {
const accounts = deferred<string[]>();
const request = vi.fn(({ method }: { method: string }) =>
method === 'personal_sign' ? Promise.resolve('signature') : accounts.promise,
);
vi.stubGlobal('ethereum', { request });
const fapi = serveFapi(signedInWallets(['web3_metamask_signature', 'web3_solana_signature']));
await renderWithClerk(<UserProfileWeb3WalletsSection />);
const user = userEvent.setup();

await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' }));
await waitFor(() => expect(request).toHaveBeenCalledWith({ method: 'eth_requestAccounts' }));
expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('button', { name: 'Connect Solana' })).toBeDisabled();
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
await act(() => accounts.reject({ code: 4001, message: 'User rejected the request.' }));

expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Connect Solana' })).toBeEnabled();
expect(screen.getByRole('alert')).toHaveTextContent('Wallet connection was cancelled. Please try again.');
expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0);
expect(request).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'personal_sign' }));

request.mockImplementation(({ method }) =>
Promise.resolve(method === 'personal_sign' ? 'signature' : ['0xabcdef1234567890']),
);
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(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1);
expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.web3_wallet).toBe('0xabcdef1234567890');
});

it('explains a rejected MetaMask connection and releases the pending state', async () => {
const request = vi.fn().mockRejectedValue({ code: 4001, message: 'User rejected the request.' });
vi.stubGlobal('ethereum', { request });
serveFapi(signedInWallets(['web3_metamask_signature']));
await renderWithClerk(<UserProfileWeb3WalletsSection />);
await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled());
expect(screen.getByRole('alert')).toHaveTextContent('Wallet connection was cancelled. Please try again.');
});

it('explains that an existing MetaMask request needs attention in the wallet', async () => {
vi.stubGlobal('ethereum', { request: vi.fn().mockRejectedValue({ code: -32002 }) });
serveFapi(signedInWallets(['web3_metamask_signature']));
await renderWithClerk(<UserProfileWeb3WalletsSection />);
await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' }));
expect(
await screen.findByText(
'A request is already pending in your wallet. Open your wallet to complete or reject it, then try again.',
),
).toBeVisible();
});

it.each(['resolve', 'reject'] as const)(
'ignores a late provider %s after closing and reopening the section',
async outcome => {
const old = deferred<string[]>();
const next = deferred<string[]>();
const request = vi.fn(({ method }: { method: string }) =>
method === 'personal_sign' ? Promise.resolve('signature') : old.promise,
);
vi.stubGlobal('ethereum', { request });
const fapi = serveFapi(signedInWallets(['web3_metamask_signature']));
const view = await renderWithClerk(<UserProfileWeb3WalletsSection />);
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' }));
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
view.rerender(<div />);
view.rerender(<UserProfileWeb3WalletsSection />);
request.mockImplementation(({ method }) =>
method === 'personal_sign' ? Promise.resolve('signature') : next.promise,
);
await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' }));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
await act(() => {
if (outcome === 'resolve') {
old.resolve(['0xold']);
} else {
old.reject({ code: -32002 });
}
});
expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveAttribute('aria-busy', 'true');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0);
await act(() => next.resolve(['0xnew']));
await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'));
expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1);
expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.web3_wallet).toBe('0xnew');
},
);

it('keeps only the new wallet when MetaMask approves an abandoned request after the section closed', async () => {
const abandoned = deferred<string[]>();
const request = vi.fn(({ method }: { method: string }) =>
method === 'eth_requestAccounts' ? abandoned.promise : Promise.resolve('signature'),
);
vi.stubGlobal('ethereum', { request });
const fapi = serveFapi(signedInWallets(['web3_metamask_signature']));
const view = await renderWithClerk(<UserProfileWeb3WalletsSection />);
const user = userEvent.setup();

await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' }));
await waitFor(() => expect(request).toHaveBeenCalledWith({ method: 'eth_requestAccounts' }));
view.rerender(<div />);
await act(() => abandoned.resolve(['0xabandoned']));

request.mockImplementation(({ method }) =>
Promise.resolve(method === 'eth_requestAccounts' ? ['0xnew'] : 'signature'),
);
view.rerender(<UserProfileWeb3WalletsSection />);
await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' }));

await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'));
expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.web3_wallet)).toEqual(['0xnew']);
});
Original file line number Diff line number Diff line change
Expand Up @@ -1064,8 +1064,4 @@ describe('Web3 wallets', () => {

// TODO: Share Solana discovery and filtering only after verifying parity with the legacy UI.
// TODO: Share identification sorting only after verifying parity for legacy email, phone, and wallet sections.
// TODO: Add recovery for abandoned MetaMask connections. In Brave, closing the locked wallet prompt
// left wallet_requestPermissions pending. Refresh cleared Clerk's error, but a new connection returned -32002.
// Clerk cancellation must release the UI and ignore late responses without claiming to cancel the wallet request.
// The injected MetaMask API exposes no supported method to cancel that request, and clerk_go cannot clear it.
});
Loading
Loading