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/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/web3-wallets-wire-up.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index eb089c4f40e..7b91b795d97 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -64,7 +64,8 @@ }, "dependencies": { "@clerk/shared": "workspace:^", - "@floating-ui/react": "catalog:repo" + "@floating-ui/react": "catalog:repo", + "@wallet-standard/core": "catalog:module-manager" }, "devDependencies": { "@clerk/clerk-js": "workspace:^", diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 039e8a04bc8..3b52c224807 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -7,6 +7,7 @@ import type { SessionJSON, UserJSON, UserOrganizationInvitationJSON, + Web3WalletJSON, } from '@clerk/shared/types'; import { http, HttpResponse, type JsonBodyType } from 'msw'; import { setupWorker } from 'msw/browser'; @@ -21,6 +22,7 @@ import { fapiPage, fapiToken, fapiVerification, + fapiWeb3Wallet, } from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; @@ -87,6 +89,20 @@ function updateUser(state: FakeFapiState, user: UserJSON): void { }; } +function findWeb3Wallet(state: FakeFapiState, id: unknown): Web3WalletJSON | undefined { + return activeUser(state)?.web3_wallets.find(wallet => wallet.id === id); +} + +function updateWeb3Wallet(state: FakeFapiState, wallet: Web3WalletJSON): void { + const user = activeUser(state); + if (user) { + updateUser(state, { + ...user, + web3_wallets: user.web3_wallets.map(current => (current.id === wallet.id ? wallet : current)), + }); + } +} + function missing() { return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); } @@ -164,6 +180,103 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { updateUser(state, { ...user, external_accounts: user.external_accounts.filter(item => item.id !== account.id) }); return envelope({ ...account, object: 'external_account' }, state.client); }), + http.post(fapiUrl('/v1/me/web3_wallets'), async ({ request }) => { + const user = activeUser(state); + if (!user) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const address = body.get('web3_wallet'); + if (!address) { + return missing(); + } + const wallet = fapiWeb3Wallet({ + id: `wallet_${user.web3_wallets.length + 1}`, + web3_wallet: address, + verification: { + id: 'verification_1', + object: 'verification', + status: 'unverified', + verified_at_client: '', + strategy: '', + attempts: 0, + expire_at: 0, + error: { code: '', message: '' }, + }, + }); + updateUser(state, { ...user, web3_wallets: [...user.web3_wallets, wallet] }); + return envelope(wallet, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id/prepare_verification'), async ({ params, request }) => { + const wallet = findWeb3Wallet(state, params.id); + if (!wallet) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const prepared = fapiWeb3Wallet({ + ...wallet, + verification: { + id: 'verification_1', + object: 'verification', + status: 'unverified', + verified_at_client: '', + strategy: body.get('strategy') ?? '', + message: 'Sign this wallet challenge', + attempts: 0, + expire_at: 0, + error: { code: '', message: '' }, + }, + }); + updateWeb3Wallet(state, prepared); + return envelope(prepared, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id/attempt_verification'), ({ params }) => { + const wallet = findWeb3Wallet(state, params.id); + if (!wallet) { + return missing(); + } + const verified = fapiWeb3Wallet({ + ...wallet, + verification: { + id: 'verification_1', + object: 'verification', + ...wallet.verification, + status: 'verified', + verified_at_client: '', + strategy: wallet.verification?.strategy ?? '', + attempts: 1, + expire_at: 0, + error: { code: '', message: '' }, + }, + }); + updateWeb3Wallet(state, verified); + return envelope(verified, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id'), ({ params, request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { + return undefined; + } + const user = activeUser(state); + const wallet = findWeb3Wallet(state, params.id); + if (!user || !wallet) { + return missing(); + } + updateUser(state, { ...user, web3_wallets: user.web3_wallets.filter(current => current.id !== wallet.id) }); + return envelope({ object: 'web3_wallet', id: wallet.id, deleted: true }, state.client); + }), + http.post(fapiUrl('/v1/me'), async ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + const user = activeUser(state); + if (!user) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const updated = { ...user, primary_web3_wallet_id: body.get('primary_web3_wallet_id') }; + updateUser(state, updated); + return envelope(updated, state.client); + }), http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { const session = findSession(state, params.id); return session diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index f52d1b12408..fca1f533de5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -20,11 +20,20 @@ import type { UserOrganizationInvitationJSON, UserSettingsJSON, VerificationJSON, + Web3Strategy, + Web3WalletJSON, } from '@clerk/shared/types'; type Settings = Omit; -export type FapiUserSettings = Omit, 'social'> & { social: Partial }; +type FapiWeb3Attribute = Omit & { + first_factors: Array; +}; + +export type FapiUserSettings = Omit, 'social' | 'attributes'> & { + social: Partial; + attributes: Omit & { web3_wallet: FapiWeb3Attribute }; +}; export type FapiEnvironment = Omit & { user_settings: FapiUserSettings; @@ -304,6 +313,25 @@ export function fapiEnterpriseAccount( }; } +export function fapiWeb3Wallet( + overrides: Partial & Pick, +): Web3WalletJSON { + return { + object: 'web3_wallet', + verification: { + id: 'verification_1', + object: 'verification', + status: 'verified', + verified_at_client: '', + strategy: 'web3_metamask_signature', + attempts: 1, + expire_at: 0, + error: { code: '', message: '' }, + }, + ...overrides, + }; +} + export function fapiUser(overrides: Partial & Pick): UserJSON { return { object: 'user', diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx deleted file mode 100644 index a30a938216c..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; - -const wallet = { id: 'wallet_1', provider: 'MetaMask', address: 'test', isVerified: true }; - -function renderWallets(onRemove: (id: string) => void | Promise) { - return render( - - - , - ); -} - -async function openRemoval(user: ReturnType) { - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); -} - -describe('Web3 wallet removal', () => { - it('returns keyboard focus to the menu when canceled', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - renderWallets(onRemove); - const trigger = screen.getByRole('button', { name: 'Manage MetaMask' }); - trigger.focus(); - await user.keyboard('{Enter}'); - await screen.findByRole('menuitem', { name: 'Remove wallet' }); - await user.keyboard('{ArrowDown}{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await user.keyboard('{Escape}'); - await waitFor(() => expect(trigger).toHaveFocus()); - expect(onRemove).not.toHaveBeenCalled(); - }); - - it('focuses the remaining wallet and then Connect when the caller removes rows', async () => { - const user = userEvent.setup(); - function Example() { - const [wallets, setWallets] = useState([wallet, { ...wallet, id: 'second', provider: 'Coinbase Wallet' }]); - return ( - - {}} - onRemove={id => setWallets(current => current.filter(item => item.id !== id))} - /> - - ); - } - render(); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })).toHaveFocus()); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Connect Other' })).toHaveFocus()); - }); - - it('keeps confirmation open while pending and allows retrying a rejected removal', async () => { - const user = userEvent.setup(); - const removal = createDeferredPromise(); - const onRemove = vi - .fn() - .mockImplementationOnce(async () => { - await removal.promise; - }) - .mockResolvedValue(undefined); - renderWallets(onRemove); - await openRemoval(user); - const dialog = screen.getByRole('alertdialog'); - const remove = within(dialog).getByRole('button', { name: 'Remove', exact: true }); - await user.click(remove); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); - expect(dialog).toBeInTheDocument(); - expect(remove).toHaveAttribute('aria-busy', 'true'); - await user.click(remove); - expect(onRemove).toHaveBeenCalledOnce(); - await act(async () => { - removal.reject(new Error('Unable to remove wallet')); - await removal.promise.catch(() => undefined); - }); - expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove wallet'); - await user.click(remove); - expect(onRemove).toHaveBeenNthCalledWith(2, 'wallet_1'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('uses the newly selected wallet and its warning after canceling another removal', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - render( - - - , - ); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - const dialog = screen.getByRole('alertdialog'); - expect(dialog).toHaveTextContent('second-wallet'); - expect(dialog).not.toHaveTextContent('You will no longer be able to sign in using this web3 wallet.'); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_2'); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx new file mode 100644 index 00000000000..e72719ff483 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx @@ -0,0 +1,346 @@ +import type { UserJSON } from '@clerk/shared/types'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { WindowAppReadyEventAPI } from '@wallet-standard/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnvironment, + fapiSession, + fapiUser, + fapiVerification, + fapiWeb3Wallet, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; + +describe('Web3 wallets', () => { + afterEach(() => vi.unstubAllGlobals()); + + function web3Environment() { + const base = fapiEnvironment(); + return fapiEnvironment({ + user_settings: { + attributes: { + ...base.user_settings.attributes, + web3_wallet: { + ...base.user_settings.attributes.web3_wallet, + enabled: true, + used_for_first_factor: true, + first_factors: ['web3_metamask_signature', 'web3_solana_signature'], + }, + }, + }, + }); + } + + async function renderWeb3(overrides: Partial = {}) { + const user = fapiUser({ id: 'user_1', ...overrides }); + const fapi = serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + }); + await renderWithClerk(); + return fapi; + } + + it('shows a saved wallet from the signed-in Clerk user', async () => { + await renderWeb3({ web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })] }); + + expect(await screen.findByText('Web3 wallets')).toBeInTheDocument(); + expect(screen.getByText('0x1234...cdef')).toBeInTheDocument(); + }); + + it.each(['verified', 'unverified'] as const)( + 'warns about sign-in loss only when removing a verified admin wallet (%s)', + async status => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ + id: 'admin_wallet', + web3_wallet: address, + verification: fapiVerification({ strategy: 'admin', status }), + }), + ], + }); + const user = userEvent.setup(); + expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: `Manage ${address}` })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + const warning = 'You will no longer be able to sign in using this web3 wallet.'; + if (status === 'verified') { + expect(screen.getByRole('alertdialog')).toHaveTextContent(warning); + } else { + expect(screen.getByRole('alertdialog')).not.toHaveTextContent(warning); + } + }, + ); + + it('keeps an unverified wallet available to connect without offering to make it primary', async () => { + await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ + id: 'wallet_1', + web3_wallet: '0x1234567890abcdef', + verification: fapiVerification({ strategy: 'web3_metamask_signature', status: 'unverified' }), + }), + ], + }); + expect(screen.getByText('Unverified')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Manage MetaMask' })); + expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).toBeNull(); + }); + + it('hides existing wallets when Web3 is disabled in the Clerk environment', async () => { + const user = fapiUser({ + id: 'user_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(); + + expect(screen.queryByText('Web3 wallets')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).not.toBeInTheDocument(); + }); + + it('creates and verifies a MetaMask wallet through Clerk and the injected provider', async () => { + const request = vi.fn(({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') { + return Promise.resolve(['0x1234567890abcdef']); + } + if (method === 'personal_sign') { + return Promise.resolve('signature'); + } + throw new Error(`Unexpected wallet method: ${method}`); + }); + vi.stubGlobal('ethereum', { request }); + const fapi = await renderWeb3(); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified')); + expect(request).toHaveBeenCalledWith({ method: 'eth_requestAccounts' }); + expect(request).toHaveBeenCalledWith({ method: 'personal_sign', params: expect.any(Array) }); + expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + }); + + it('holds one provider pending and allows a manual retry after an API error', async () => { + vi.stubGlobal('ethereum', { + request: vi.fn(({ method }: { method: string }) => + Promise.resolve(method === 'personal_sign' ? 'signature' : ['0x1234567890abcdef']), + ), + }); + const fapi = await renderWeb3(); + const hold = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Connect Solana' })).toBeDisabled(); + + hold.fail('wallet_creation_failed'); + expect(await screen.findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'), + ); + expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + }); + + it('sets a verified wallet as primary through Clerk', async () => { + const fapi = await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), + fapiWeb3Wallet({ id: 'wallet_2', web3_wallet: '0xabcdef1234567890' }), + ], + primary_web3_wallet_id: 'wallet_1', + }); + const user = userEvent.setup(); + + await waitFor(() => expect(screen.getAllByRole('button', { name: 'Manage MetaMask' })).toHaveLength(2)); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).toBeNull(); + await user.keyboard('{Escape}'); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_2')); + const selectedRow = screen.getByTitle('0xabcdef1234567890').closest('.cl-section-row'); + expect(selectedRow).not.toBeNull(); + if (selectedRow) { + expect(within(selectedRow).getByText('Primary')).toBeInTheDocument(); + } + }); + + it('shows a primary update failure on the row and allows another attempt', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + const hold = holdRequests('post', '/v1/me'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + hold.fail('primary_update_failed'); + + expect(await screen.findByRole('alert')).toHaveTextContent('primary_update_failed'); + expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await waitFor(() => expect(retryFapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_1')); + expect(screen.getByText('Primary')).toBeInTheDocument(); + }); + + it('confirms removal and focuses Connect after deleting the last wallet', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + expect(screen.getByRole('alertdialog')).toHaveTextContent('0x1234...cdef'); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); + await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveFocus()); + }); + + it('keeps a failed wallet removal in its confirmation for a manual retry', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + const hold = holdRequests('post', '/v1/me/web3_wallets/:id'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + const confirm = within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }); + await user.click(confirm); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + expect(confirm).toHaveAttribute('aria-busy', 'true'); + hold.fail('wallet_removal_failed'); + + expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toHaveTextContent( + 'wallet_removal_failed', + ); + await waitFor(() => expect(confirm).not.toHaveAttribute('aria-busy', 'true')); + const retryFapi = serveFapi(fapi); + await user.click(confirm); + await waitFor(() => expect(retryFapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('cancels removal, then removes the newly selected wallet and focuses the remaining row', async () => { + const fapi = await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), + fapiWeb3Wallet({ id: 'wallet_2', web3_wallet: '0xabcdef1234567890' }), + ], + }); + const user = userEvent.setup(); + const first = screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]; + first.focus(); + await user.keyboard('{Enter}'); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + await user.keyboard('{Escape}'); + await waitFor(() => expect(first).toHaveFocus()); + expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(2); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + expect(screen.getByRole('alertdialog')).toHaveTextContent('0xabcd...7890'); + await user.click(screen.getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual(['wallet_1']); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); + }); + + it('connects the chosen Solana wallet and verifies it through Clerk', async () => { + const account = { + address: 'SolanaAddress123', + publicKey: new Uint8Array([1, 2, 3]), + chains: ['solana:mainnet' as const], + features: ['solana:signMessage' as const], + }; + const connect = vi.fn(() => Promise.resolve({ accounts: [account] })); + const signMessage = vi.fn(() => Promise.resolve([{ signature: new Uint8Array([4, 5, 6]) }])); + const wallet = { + version: '1.0.0' as const, + name: 'Test Solana', + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [account], + features: { + 'standard:connect': { version: '1.0.0' as const, connect }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage }, + }, + }; + const secondAccount = { ...account, address: 'SecondSolanaAddress456' }; + const secondConnect = vi.fn(() => Promise.resolve({ accounts: [secondAccount] })); + const secondSignMessage = vi.fn(() => Promise.resolve([{ signature: new Uint8Array([7, 8, 9]) }])); + const secondWallet = { + ...wallet, + name: 'Second Solana', + accounts: [secondAccount], + features: { + 'standard:connect': { version: '1.0.0' as const, connect: secondConnect }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage: secondSignMessage }, + }, + }; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => unregister.push(api.register(wallet), api.register(secondWallet)); + const onAppReady = (event: Event & { detail?: WindowAppReadyEventAPI }) => { + if (event.detail) { + register(event.detail); + } + }; + window.addEventListener('wallet-standard:app-ready', onAppReady); + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); + try { + const fapi = await renderWeb3(); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Connect Solana' })); + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Second Solana' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Test Solana' })).toBeDisabled(); + await user.keyboard('{Escape}'); + expect(screen.getByRole('dialog')).toBeVisible(); + creation.fail('wallet_creation_failed'); + expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); + await waitFor(() => + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'), + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.web3_wallet).toBe('SecondSolanaAddress456'); + expect(connect).not.toHaveBeenCalled(); + expect(signMessage).not.toHaveBeenCalled(); + expect(secondConnect).toHaveBeenCalledTimes(2); + expect(secondSignMessage).toHaveBeenCalledOnce(); + expect(await screen.findByRole('button', { name: 'Manage Solana' })).toBeInTheDocument(); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx deleted file mode 100644 index 359c4674d02..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; - -describe('Web3 wallets section', () => { - it.each([{ availableProviders: [] }, { availableProviders: [{ id: 'metamask', provider: 'MetaMask' }] }])( - 'hides the entire section without wallets or actionable providers (%j)', - ({ availableProviders }) => { - const { container } = render( - , - ); - expect(container).toBeEmptyDOMElement(); - }, - ); - - it.each([ - { isPrimary: true, isVerified: true, canSetPrimary: false }, - { isPrimary: false, isVerified: true, canSetPrimary: true }, - { isPrimary: false, isVerified: false, canSetPrimary: false }, - ])( - 'offers only applicable legacy wallet actions ($isPrimary, $isVerified)', - async ({ isPrimary, isVerified, canSetPrimary }) => { - const user = userEvent.setup(); - render( - , - ); - expect(screen.getByText('MetaMask')).toBeVisible(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeVisible(); - expect(screen.getAllByRole('menuitem')).toHaveLength(canSetPrimary ? 2 : 1); - if (canSetPrimary) { - expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeVisible(); - } else { - expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); - } - }, - ); - - it.each([true, false])( - 'renders an admin wallet and limits the removal warning to verified wallets (%s)', - async isVerified => { - const user = userEvent.setup(); - const address = '0x1234567890abcdef1234567890abcdef12345678'; - render( - , - ); - expect(screen.getByText('0x1234...5678')).toBeVisible(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: `Manage ${address}` })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - const dialog = screen.getByRole('alertdialog'); - const warning = 'You will no longer be able to sign in using this web3 wallet.'; - if (isVerified) { - expect(dialog).toHaveTextContent(warning); - } else { - expect(dialog).not.toHaveTextContent(warning); - } - }, - ); - - it('shows connection errors while keeping Connect available', () => { - render( - , - ); - expect(screen.getByRole('alert')).toHaveTextContent('Wallet extension not found'); - expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); - expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); - }); - - it('shows primary errors while keeping Set as primary available', async () => { - const user = userEvent.setup(); - render( - , - ); - expect(screen.getByRole('alert')).toHaveTextContent('Unable to set primary'); - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeEnabled(); - }); - - it('shows an unverified wallet alongside its available provider', () => { - render( - , - ); - expect(screen.getByText('Unverified')).toBeVisible(); - expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeVisible(); - }); -}); 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..24ebc321eb5 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx @@ -0,0 +1,132 @@ +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); + const [selectedWalletName, setSelectedWalletName] = useState(); + + useEffect(() => { + const registry = getWallets(); + const update = () => setWallets(installedSolanaWallets()); + const unregister = registry.on('register', update); + const unadvertise = registry.on('unregister', update); + update(); + return () => { + unregister(); + unadvertise(); + }; + }, []); + + 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 => ( + { + setSelectedWalletName(wallet.name); + onConnect(wallet.name); + }} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + ))} +
+ + + {m.solanaDialog.cancel} + + } + /> + +
+
+
+ ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.test.tsx new file mode 100644 index 00000000000..1d0b4c9872e --- /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,33 @@ +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()); + }); +}); 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..928ff8b2c7a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -0,0 +1,150 @@ +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 { 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 actionError(error: unknown): unknown { + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + return new Error(first?.longMessage || first?.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 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 user.createWeb3Wallet({ web3Wallet: 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 actionError(error); + } + }; + + const setPrimary = async (walletId: string) => { + if (!user?.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { + return; + } + try { + await user.update({ primaryWeb3WalletId: walletId }); + } catch (error) { + throw actionError(error); + } + }; + + const remove = async (walletId: string) => { + try { + await user?.web3Wallets.find(wallet => wallet.id === walletId)?.destroy(); + } catch (error) { + throw actionError(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 (!environment.userSettings.attributes.web3_wallet?.enabled) { + return 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/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index fd1a0353c67..4c14168ce9d 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -52,6 +52,7 @@ export default defineConfig({ optimizeDeps: { include: [ '@clerk/clerk-js', + '@wallet-standard/core', '@testing-library/jest-dom/matchers', '@testing-library/react', '@testing-library/user-event', diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 9341c6cfe71..69aa840738c 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -20,6 +20,7 @@ import { const flows = [ { title: 'Connected accounts', href: '/live/connected-accounts' }, + { title: 'Web3 wallets', href: '/live/web3-wallets' }, { title: 'Reverification', href: '/live/reverification' }, ]; diff --git a/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx new file mode 100644 index 00000000000..9aac58176d0 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { UserProfileWeb3WalletsSection } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function Web3WalletsLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Web3 wallets

+

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

+
+ {!isLoaded ?

Loading…

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

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

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df232371ea4..3dfef8094f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -798,6 +798,9 @@ importers: '@floating-ui/react': specifier: catalog:repo version: 0.27.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wallet-standard/core': + specifier: catalog:module-manager + version: 1.1.1 devDependencies: '@clerk/clerk-js': specifier: workspace:^