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/AGENTS.md b/packages/mosaic/AGENTS.md index 16abbc46736..492272449ce 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -43,6 +43,7 @@ Most problems a feature hits already have a shared answer. Find the row for your | A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) | | A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) | | Current time for display labels | `useNow` + `MosaicProvider` ([#10098](https://github.com/clerk/javascript/pull/10098), once available) | +| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | | An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `styles/feedback.styles.ts` | | A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) | | Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | diff --git a/packages/mosaic/eslint-suppressions.json b/packages/mosaic/eslint-suppressions.json index 378daf64886..419190c3cab 100644 --- a/packages/mosaic/eslint-suppressions.json +++ b/packages/mosaic/eslint-suppressions.json @@ -59,7 +59,7 @@ "count": 1 } }, - "src/features/user-profile/user-profile-web3-wallet-row.view.tsx": { + "src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx": { "sonarjs/cognitive-complexity": { "count": 1 } @@ -147,4 +147,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 52c5fa7fd5f..205f578221e 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -64,7 +64,8 @@ }, "dependencies": { "@clerk/shared": "workspace:^", - "@floating-ui/react": "catalog:repo" + "@floating-ui/react": "catalog:repo", + "@wallet-standard/core": "catalog:module-manager" }, "devDependencies": { "@clerk/clerk-js": "workspace:^", diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx b/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx new file mode 100644 index 00000000000..ea046f5df0b --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { fapiUrl, serveFapi } from './fake-fapi'; +import { fapiClient, fapiSession, fapiUser, fapiWeb3Wallet } from './fapi'; + +describe('fake FAPI user updates', () => { + it('updates profile fields without clearing omitted identification fields', async () => { + const user = fapiUser({ + id: 'user_1', + first_name: 'Austin', + last_name: 'Original', + username: 'austin', + primary_web3_wallet_id: 'wallet_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234' })], + }); + const fapi = serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + + const response = await fetch(`${fapiUrl('/v1/me')}?_method=PATCH`, { + method: 'POST', + body: new URLSearchParams({ first_name: 'Updated', last_name: 'Name', username: 'updated' }), + }); + + expect(response.ok).toBe(true); + expect(fapi.client.sessions[0]?.user).toMatchObject({ + first_name: 'Updated', + last_name: 'Name', + username: 'updated', + primary_web3_wallet_id: 'wallet_1', + web3_wallets: user.web3_wallets, + }); + }); +}); diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index edc1fa49b40..f730da6e6be 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -10,6 +10,7 @@ import type { SessionJSON, UserJSON, UserOrganizationInvitationJSON, + Web3WalletJSON, } from '@clerk/shared/types'; import { http, HttpResponse } from 'msw'; import { setupWorker } from 'msw/browser'; @@ -34,6 +35,7 @@ import { fapiPage, fapiToken, fapiVerification, + fapiWeb3Wallet, } from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; @@ -140,6 +142,20 @@ function updateOrganization(state: FakeFapiState, organization: OrganizationJSON }; } +function findWeb3Wallet(state: FakeFapiState, id: unknown): Web3WalletJSON | undefined { + return activeUser(state)?.web3_wallets.find(wallet => wallet.id === id); +} + +function updateWeb3Wallet(state: FakeFapiState, wallet: Web3WalletJSON): void { + const user = activeUser(state); + if (user) { + updateUser(state, { + ...user, + web3_wallets: user.web3_wallets.map(current => (current.id === wallet.id ? wallet : current)), + }); + } +} + export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const { verification, enterpriseLinking, passkeys, ...rest } = seed; const state: FakeFapiState = { @@ -240,6 +256,107 @@ 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: fapiVerification('', { expire_at: 0 }), + }); + 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: fapiVerification(body.get('strategy') ?? '', { + message: 'Sign this wallet challenge', + expire_at: 0, + }), + }); + 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: fapiVerification(wallet.verification?.strategy ?? '', { + ...wallet.verification, + status: 'verified', + verified_at_client: '', + attempts: 1, + }), + }); + updateWeb3Wallet(state, verified); + const user = activeUser(state); + if (user && !user.primary_web3_wallet_id) { + updateUser(state, { ...user, primary_web3_wallet_id: verified.id }); + } + return envelope(verified, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id'), ({ params, request }) => { + 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(); + } + const remainingWallets = user.web3_wallets.filter(current => current.id !== wallet.id); + updateUser(state, { + ...user, + web3_wallets: remainingWallets, + primary_web3_wallet_id: + user.primary_web3_wallet_id === wallet.id + ? (remainingWallets.find(current => current.verification?.status === 'verified')?.id ?? null) + : user.primary_web3_wallet_id, + }); + return envelope({ object: 'web3_wallet', id: wallet.id, deleted: true }, state.client); + }), + http.post(fapiUrl('/v1/me'), async ({ request }) => { + 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 }; + const fields = [ + 'first_name', + 'last_name', + 'username', + 'primary_email_address_id', + 'primary_phone_number_id', + 'primary_web3_wallet_id', + ] as const; + for (const field of fields) { + if (body.has(field)) { + updated[field] = body.get(field); + } + } + updateUser(state, updated); + return envelope(updated, state.client); + }), http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { const session = findSession(state, params.id); return session diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 0c0744ea63d..9c562790ce5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -26,11 +26,20 @@ import type { UserOrganizationInvitationJSON, UserSettingsJSON, VerificationJSON, + Web3Strategy, + Web3WalletJSON, } from '@clerk/shared/types'; type Settings = Omit; -export type FapiUserSettings = Omit, 'social'> & { social: Partial }; +type FapiWeb3Attribute = Omit & { + first_factors: Array; +}; + +export type FapiUserSettings = Omit, 'social' | 'attributes'> & { + social: Partial; + attributes: Omit & { web3_wallet: FapiWeb3Attribute }; +}; export type FapiEnvironment = Omit & { user_settings: FapiUserSettings; @@ -354,6 +363,21 @@ export function fapiEnterpriseAccount( }; } +export function fapiWeb3Wallet( + overrides: Partial & Pick, +): Web3WalletJSON { + return { + object: 'web3_wallet', + verification: fapiVerification('web3_metamask_signature', { + status: 'verified', + verified_at_client: '', + attempts: 1, + expire_at: 0, + }), + ...overrides, + }; +} + export function fapiSession(overrides: Partial & Pick): SessionJSON { const { user } = overrides; const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); diff --git a/packages/mosaic/src/components/action-menu/action-menu.test.tsx b/packages/mosaic/src/components/action-menu/action-menu.test.tsx index f30098d6b3a..8786eee5e68 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.test.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.test.tsx @@ -36,4 +36,35 @@ describe('Mosaic ActionMenu', () => { expect(onClick).toHaveBeenCalledOnce(); }); + it('disables both the trigger and existing menu items while busy', async () => { + const onClick = vi.fn(); + const actions = [{ label: 'Remove', onClick }]; + const view = render( + + + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage wallet' })); + expect(await screen.findByRole('menuitem', { name: 'Remove' })).toBeInTheDocument(); + + view.rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Manage wallet' })).toBeDisabled(); + const remove = screen.getByRole('menuitem', { name: 'Remove' }); + expect(remove).toHaveAttribute('aria-disabled', 'true'); + await user.click(remove); + expect(onClick).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mosaic/src/components/action-menu/action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx index 480c4655625..2cd36e9fa0b 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -15,9 +15,10 @@ export interface ActionMenuProps { label: string; actions: ActionMenuAction[]; triggerRef?: Ref; + disabled?: boolean; } -export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { +export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuProps) { if (actions.length === 0) { return null; } @@ -27,6 +28,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { {actions.map(action => ( @@ -35,6 +37,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { color={action.color} label={action.label} onClick={action.onClick} + disabled={disabled} > {action.icon ? ( diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index af0c55cf8c4..053d75fe70f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -9,7 +9,7 @@ import { MosaicProvider } from '../../../mosaic-provider'; import { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view'; import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; function DeleteAccount() { const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx deleted file mode 100644 index cbc866af4ee..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; - -const wallet = { id: 'wallet_1', provider: 'MetaMask', address: 'test', isVerified: true }; - -function renderWallets(onRemove: (id: string) => void | Promise) { - return render( - - - , - ); -} - -async function openRemoval(user: ReturnType) { - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); -} - -describe('Web3 wallet removal', () => { - it('returns keyboard focus to the menu when canceled', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - renderWallets(onRemove); - const trigger = screen.getByRole('button', { name: 'Manage MetaMask' }); - trigger.focus(); - await user.keyboard('{Enter}'); - await screen.findByRole('menuitem', { name: 'Remove wallet' }); - await user.keyboard('{ArrowDown}{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await user.keyboard('{Escape}'); - await waitFor(() => expect(trigger).toHaveFocus()); - expect(onRemove).not.toHaveBeenCalled(); - }); - - it('focuses the remaining wallet and then Connect when the caller removes rows', async () => { - const user = userEvent.setup(); - function Example() { - const [wallets, setWallets] = useState([wallet, { ...wallet, id: 'second', provider: 'Coinbase Wallet' }]); - return ( - - {}} - onRemove={id => setWallets(current => current.filter(item => item.id !== id))} - /> - - ); - } - render(); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })).toHaveFocus()); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Connect Other' })).toHaveFocus()); - }); - - it('keeps confirmation open while pending and allows retrying a rejected removal', async () => { - const user = userEvent.setup(); - const removal = createDeferredPromise(); - const onRemove = vi - .fn() - .mockImplementationOnce(async () => { - await removal.promise; - }) - .mockResolvedValue(undefined); - renderWallets(onRemove); - await openRemoval(user); - const dialog = screen.getByRole('alertdialog'); - const remove = within(dialog).getByRole('button', { name: 'Remove', exact: true }); - await user.click(remove); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); - expect(dialog).toBeInTheDocument(); - expect(remove).toHaveAttribute('aria-busy', 'true'); - await user.click(remove); - expect(onRemove).toHaveBeenCalledOnce(); - await act(async () => { - removal.reject(clerkApiError('web3_wallet_in_use', 'Unable to remove wallet')); - await removal.promise.catch(() => undefined); - }); - expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove wallet'); - await user.click(remove); - expect(onRemove).toHaveBeenNthCalledWith(2, 'wallet_1'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('uses the newly selected wallet and its warning after canceling another removal', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - render( - - - , - ); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - const dialog = screen.getByRole('alertdialog'); - expect(dialog).toHaveTextContent('second-wallet'); - expect(dialog).not.toHaveTextContent('You will no longer be able to sign in using this web3 wallet.'); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_2'); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx new file mode 100644 index 00000000000..b9bc5b693a4 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx @@ -0,0 +1,877 @@ +import type { UserJSON } from '@clerk/shared/types'; +import { act, fireEvent, render, 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, + fapiEnterpriseAccount, + fapiEnvironment, + fapiSession, + fapiUser, + fapiVerification, + fapiWeb3Wallet, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { Dialog } from '../../../components/dialog'; +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileSolanaWalletView } from '../user-profile-web3-wallets-section/user-profile-solana-wallet.view'; +import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; + +describe('Web3 wallets', () => { + 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('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('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(); + expect(await screen.findByText('Primary')).toBeInTheDocument(); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe( + fapi.client.sessions[0]?.user.web3_wallets[0]?.id, + ); + }); + + it('reuses the created Ethereum wallet when Connect is retried after signature rejection', async () => { + const address = '0xabcdef1234567890'; + let accountRequests = 0; + let signatureRequests = 0; + vi.stubGlobal('ethereum', { + request: vi.fn(({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') { + accountRequests += 1; + return Promise.resolve([accountRequests === 1 ? address : address.toUpperCase().replace('0X', '0x')]); + } + if (method === 'personal_sign') { + signatureRequests += 1; + return signatureRequests === 1 + ? Promise.reject(new Error('Signature rejected')) + : Promise.resolve('signature'); + } + throw new Error(`Unexpected wallet method: ${method}`); + }), + }); + const fapi = await renderWeb3(); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.release(); + expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + const pendingId = fapi.client.sessions[0]?.user.web3_wallets[0]?.id; + expect(pendingId).toBeDefined(); + expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified')); + expect(creation.requests).toHaveLength(1); + expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual([pendingId]); + expect(screen.getByText('Primary')).toBeInTheDocument(); + }); + + it('hides immutable wallet mutations but still permits setting a verified wallet as primary', async () => { + const environment = web3Environment(); + serveFapi({ + environment: fapiEnvironment({ + user_settings: { + attributes: { + ...environment.user_settings.attributes, + web3_wallet: { + ...environment.user_settings.attributes.web3_wallet, + immutable: true, + first_factors: ['web3_metamask_signature', 'web3_coinbase_wallet_signature'], + }, + }, + }, + }), + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ + id: 'user_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }), + }), + ]), + }); + await renderWithClerk(); + + expect(screen.queryByRole('button', { name: 'Connect Coinbase Wallet' })).toBeNull(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Manage MetaMask' })); + expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Remove wallet' })).toBeNull(); + }); + + it('hides an immutable Web3 section without saved wallets', async () => { + const environment = web3Environment(); + serveFapi({ + environment: fapiEnvironment({ + user_settings: { + attributes: { + ...environment.user_settings.attributes, + web3_wallet: { ...environment.user_settings.attributes.web3_wallet, immutable: true }, + }, + }, + }), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk(); + + expect(screen.queryByRole('group', { name: 'Web3 wallets' })).toBeNull(); + }); + + it('shows a localized fallback when the wallet provider rejects without a message', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error(''))) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + + + , + ); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connexion impossible.')); + }); + + it('resolves a missing wallet identifier from the shared error catalog when localization changes', async () => { + const request = vi.fn(() => Promise.resolve([])); + vi.stubGlobal('ethereum', { request }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + const section = ; + const { rerender } = await renderWithClerk( + + {section} + , + ); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Installez un portefeuille.'); + + rerender( + + {section} + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Portefeuille introuvable.'); + expect(request).toHaveBeenCalledOnce(); + + rerender({section}); + expect(screen.getByRole('alert')).toHaveTextContent( + 'A Web3 Wallet extension cannot be found. Please install one to continue.', + ); + expect(request).toHaveBeenCalledOnce(); + }); + + it('uses the canonical API-code translation for a wallet connection failure', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.resolve(['0x1234567890abcdef'])) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + + + , + ); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.fail('verification_invalid_strategy', 'Server copy'); + + expect(await screen.findByRole('alert')).toHaveTextContent('Ce portefeuille est indisponible.'); + expect(screen.queryByText('Server copy')).toBeNull(); + }); + + it.each(['switch', 'sign out'] as const)( + 'aborts wallet creation after %s during the provider prompt', + async change => { + const accountRequest = Promise.withResolvers(); + const request = vi.fn(() => accountRequest.promise); + vi.stubGlobal('ethereum', { request }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const original = clerk.user; + if (!original) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(original, 'createWeb3Wallet'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(request).toHaveBeenCalledOnce()); + await act(() => (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut())); + expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined); + if (change === 'switch') { + expect.soft(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + } + + await act(async () => { + accountRequest.resolve(['0x1234567890abcdef']); + await accountRequest.promise; + }); + + expect(create).not.toHaveBeenCalled(); + if (change === 'switch') { + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + expect(screen.queryByRole('alert')).toBeNull(); + } + }, + ); + + it('rechecks creation policy after the wallet provider prompt', async () => { + const accountRequest = Promise.withResolvers(); + vi.stubGlobal('ethereum', { request: vi.fn(() => accountRequest.promise) }); + const environment = web3Environment(); + const fapi = serveFapi({ + environment: fapiEnvironment({ + user_settings: { + ...environment.user_settings, + enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + }, + }), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ + id: 'sess_2', + user: fapiUser({ id: 'user_1', enterprise_accounts: [fapiEnterpriseAccount({ id: 'sso_1' })] }), + }), + ]), + }); + const { clerk } = await renderWithClerk(); + const initialUser = clerk.user; + if (!initialUser) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(initialUser, 'createWeb3Wallet'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await act(() => clerk.setActive({ session: 'sess_2' })); + await act(async () => { + accountRequest.resolve(['0x1234567890abcdef']); + await accountRequest.promise; + }); + + expect(create).not.toHaveBeenCalled(); + expect(fapi.client.sessions[1]?.user.web3_wallets).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).toBeNull(); + }); + + it('clears the previous user’s provider error and open wallet picker on session change', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error('First user wallet failure'))) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); + + await act(() => clerk.setActive({ session: 'sess_2' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + }); + + it('holds one provider pending and allows a manual retry after an API error', async () => { + vi.stubGlobal('ethereum', { + request: vi.fn(({ method }: { method: string }) => + Promise.resolve(method === 'personal_sign' ? 'signature' : ['0x1234567890abcdef']), + ), + }); + const fapi = await renderWeb3(); + const hold = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + const metamaskButton = await screen.findByRole('button', { name: 'Connect MetaMask' }); + act(() => { + fireEvent.click(metamaskButton); + fireEvent.click(screen.getByRole('button', { name: 'Connect Solana' })); + }); + expect(screen.queryByRole('dialog')).toBeNull(); + 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(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('clears a provider failure when a wallet action starts', async () => { + vi.stubGlobal('ethereum', { + request: vi.fn(() => Promise.resolve(['0x1234567890abcdef'])), + }); + await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ + id: 'solana_wallet', + web3_wallet: 'SolanaAddress123', + verification: fapiVerification('web3_solana_signature', { status: 'verified' }), + }), + ], + }); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.fail('wallet_creation_failed'); + expect(await screen.findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + + const primary = holdRequests('post', '/v1/me'); + await user.click(screen.getByRole('button', { name: 'Manage Solana' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await waitFor(() => expect(primary.requests).toHaveLength(1)); + try { + expect(screen.queryByRole('alert')).toBeNull(); + } finally { + primary.release(); + } + await waitFor(() => expect(screen.getByText('Primary')).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)); + expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeDisabled(); + 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' })], + primary_web3_wallet_id: 'wallet_1', + }); + 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)); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBeNull(); + 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' }), + ], + primary_web3_wallet_id: 'wallet_2', + }); + const user = userEvent.setup(); + const first = screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]; + first.focus(); + await user.keyboard('{Enter}'); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + await user.keyboard('{Escape}'); + await waitFor(() => expect(first).toHaveFocus()); + expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(2); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + expect(screen.getByRole('alertdialog')).toHaveTextContent('0xabcd...7890'); + await user.click(screen.getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual(['wallet_1']); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_1'); + expect(screen.getByText('Primary')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); + }); + + it('distinguishes localized discovery loading and failure from no installed wallets', async () => { + const props = { onConnect: vi.fn() }; + const localization = { + messages: { + userProfileWeb3Wallets: { + solanaDialog: { + loading: 'Recherche des portefeuilles…', + loadError: 'Chargement impossible.', + }, + }, + }, + }; + const { rerender } = render( + + + + + + + , + ); + + expect(await screen.findByRole('status')).toHaveTextContent('Recherche des portefeuilles…'); + expect(screen.queryByText('No Solana wallets are available.')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Find a Solana wallet' })).not.toBeInTheDocument(); + + rerender( + + + + + + + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Chargement impossible.'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByText('No Solana wallets are available.')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Find a Solana wallet' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Retry|Try again/ })).not.toBeInTheDocument(); + expect(props.onConnect).not.toHaveBeenCalled(); + }); + + it('renders caller-supplied Solana wallets and forwards the chosen name', async () => { + const props = { onConnect: vi.fn() }; + const { rerender } = render( + + + + + + + , + ); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Supplied Solana' })); + expect(props.onConnect).toHaveBeenCalledExactlyOnceWith('Supplied Solana'); + + rerender( + + + + + + + , + ); + expect(screen.queryByRole('button', { name: 'Supplied Solana' })).toBeNull(); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + }); + + it('shows only eligible Solana wallets and refreshes when wallets register or unregister', async () => { + const wallet = { + version: '1.0.0' as const, + name: 'Eligible Solana', + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [], + features: { + 'standard:connect': { version: '1.0.0' as const, connect: () => Promise.resolve({ accounts: [] }) }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage: () => Promise.resolve([]) }, + }, + }; + const registries: WindowAppReadyEventAPI[] = []; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => { + registries.push(api); + unregister.push( + api.register( + { + ...wallet, + name: 'Cannot connect', + features: { 'solana:signMessage': wallet.features['solana:signMessage'] }, + }, + { ...wallet, name: 'Cannot sign', features: { 'standard:connect': wallet.features['standard:connect'] } }, + { ...wallet, name: 'Wrong chain', chains: ['ethereum:mainnet'] }, + ), + ); + }; + const onAppReady = (event: Event & { detail?: WindowAppReadyEventAPI }) => { + if (event.detail) { + register(event.detail); + } + }; + window.addEventListener('wallet-standard:app-ready', onAppReady); + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); + let unregisterEligible: Array<() => void> = []; + try { + await renderWeb3(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(screen.queryByRole('button', { name: 'Cannot connect' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Cannot sign' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Wrong chain' })).toBeNull(); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + + act(() => { + unregisterEligible = registries.map(api => api.register(wallet)); + }); + expect(await screen.findByRole('button', { name: 'Eligible Solana' })).toBeEnabled(); + act(() => unregisterEligible.forEach(remove => remove())); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Eligible Solana' })).toBeNull()); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + unregisterEligible.forEach(remove => remove()); + } + }); + + it('connects the chosen Solana wallet and verifies it through Clerk', async () => { + const account = { + address: 'SolanaAddress123', + 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' })); + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Second Solana' })); + }); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Second Solana' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Second Solana' })).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Test Solana' })).toBeDisabled(); + await user.keyboard('{Escape}'); + expect(screen.getByRole('dialog')).toBeVisible(); + creation.fail('wallet_creation_failed'); + expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + expect(screen.getAllByRole('alert', { hidden: true })).toHaveLength(1); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); + await waitFor(() => + 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()); + } + }); + + it.each(['Cancel', 'Close', 'Escape'] as const)( + 'clears the previous Solana failure when reopened after %s', + async dismissal => { + const connect = vi.fn(() => Promise.reject(new Error('Wallet rejected connection'))); + 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: [], + features: { + 'standard:connect': { version: '1.0.0' as const, connect }, + 'solana:signMessage': { + version: '1.0.0' as const, + signMessage: () => Promise.resolve([{ signature: new Uint8Array([4, 5, 6]) }]), + }, + }, + }; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => unregister.push(api.register(wallet)); + 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 { + await renderWeb3(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await user.click(await screen.findByRole('button', { name: 'Test Solana' })); + expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent( + 'Something went wrong. Please try again.', + ); + + if (dismissal === 'Escape') { + await user.keyboard('{Escape}'); + } else { + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: dismissal })); + } + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + + expect(within(screen.getByRole('dialog')).queryByText('Something went wrong. Please try again.')).toBeNull(); + expect(screen.getByRole('button', { name: 'Test Solana' })).toBeEnabled(); + await user.click(screen.getByRole('button', { name: 'Test Solana' })); + expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent( + 'Something went wrong. Please try again.', + ); + expect(connect).toHaveBeenCalledTimes(2); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + } + }, + ); + + // TODO: Add session reverification for wallet connection, primary updates, and removal; + // surface API errors until then. + // 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. +}); 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-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts index 82525dd5225..543c585adb5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.model.test.ts @@ -2,7 +2,6 @@ import type { OAuthProvider } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; import { - allowsIdentificationCreation, createProviderCatalog, projectConnectedAccounts, recoveryFor, @@ -45,14 +44,10 @@ function account({ }; } -function userWith( - accounts: ReturnType[], - enterpriseAccounts: Parameters[0]['enterpriseAccounts'] = [], -) { +function userWith(accounts: ReturnType[]) { return { verifiedExternalAccounts: accounts.filter(a => a.verification?.status === 'verified'), unverifiedExternalAccounts: accounts.filter(a => a.verification?.status !== 'verified'), - enterpriseAccounts, }; } @@ -320,26 +315,6 @@ describe('createProviderCatalog', () => { }); }); -describe('allowsIdentificationCreation', () => { - const enterpriseSSO = { enabled: true }; - - it('blocks creation for an active enterprise connection that disables additional identifications', () => { - const blocked = userWith([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]); - expect(allowsIdentificationCreation(blocked, enterpriseSSO)).toBe(false); - }); - - it('allows creation when the enterprise connection is inactive or enterprise SSO is off', () => { - const inactive = userWith( - [], - [{ active: false, enterpriseConnection: { disableAdditionalIdentifications: true } }], - ); - expect(allowsIdentificationCreation(inactive, enterpriseSSO)).toBe(true); - - const active = userWith([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]); - expect(allowsIdentificationCreation(active, { enabled: false })).toBe(true); - }); -}); - describe('recoveryFor retry', () => { it('retries a failed connection with its strategy and missing scopes', () => { expect( diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts index dc88339dc31..2a2af35966e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts @@ -6,9 +6,6 @@ import { useClerk, useUser } from '@clerk/shared/react'; import type { ClerkAPIError, CustomOauthProvider, - EnterpriseAccountConnectionResource, - EnterpriseAccountResource, - EnterpriseSSOSettings, ExternalAccountResource, OAuthProvider, OAuthProviders, @@ -19,6 +16,7 @@ import type { import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; +import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; import type { ConnectedAccountActionResult, ConnectedAccountProviderDisplay, @@ -31,11 +29,6 @@ type AccountData = Pick & { error: Pick | null }) | null; }; -type EnterpriseUser = { - enterpriseAccounts: (Pick & { - enterpriseConnection?: Pick | null; - })[]; -}; type ProjectedUser = { verifiedExternalAccounts: AccountData[]; unverifiedExternalAccounts: AccountData[]; @@ -125,18 +118,6 @@ export function createProviderCatalog( ]; } -export function allowsIdentificationCreation( - user: EnterpriseUser, - enterpriseSSO: Pick, -): boolean { - if (!enterpriseSSO.enabled) { - return true; - } - return !user.enterpriseAccounts.some( - account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications, - ); -} - function findAdditionalScopes(account: AccountData, scopes: AdditionalOAuthScopes | undefined): string[] { const requested = scopes?.[account.provider] ?? []; const approved = account.approvedScopes.split(' '); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx index 026d459c6e6..0050d4e0a42 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx @@ -4,7 +4,7 @@ import { expect, it } from 'vitest'; import { serveFapi } from '../../../../__tests__/feature/fake-fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; import { UserProfileProfilePanelView } from '../../user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '../../user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '../../user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section'; import { enterpriseAccountSeed } from './enterprise-accounts.fixtures'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index e7ff6f17698..abfe9a4bdda 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -9,7 +9,7 @@ import type { } from './user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from './user-profile-account-section/user-profile-account-section.view'; import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; -import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; +import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section/user-profile-web3-wallets-section.types'; export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet }; export type { UserProfileNameAttribute } from './user-profile-account-section/user-profile-account-section.types'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.dialog.test.tsx new file mode 100644 index 00000000000..76461c9c944 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.dialog.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../../mosaic-provider'; +import { UserProfileSolanaWalletDialog } from '../user-profile-solana-wallet.dialog'; + +afterEach(() => { + vi.doUnmock('@wallet-standard/core'); +}); + +describe('Solana wallet discovery failures', () => { + it('asks for a page refresh after a failed import and lets the user cancel', async () => { + vi.doMock('@wallet-standard/core', () => { + throw new Error('Failed to fetch wallet registry'); + }); + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + + , + ); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'Wallets could not be loaded. Please refresh the page and try again.', + ), + ); + expect(screen.queryByText('No Solana wallets are available.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Retry|Try again/ })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.lifecycle.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.lifecycle.test.tsx new file mode 100644 index 00000000000..a286d6a8f2e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.lifecycle.test.tsx @@ -0,0 +1,90 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Wallet } from '@wallet-standard/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../../mosaic-provider'; +import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section'; +import type { ReadyWeb3WalletsModel } from '../user-profile-web3-wallets-section.types'; + +const registry = vi.hoisted(() => { + const wallets: Wallet[] = []; + const listeners = { register: new Set<() => void>(), unregister: new Set<() => void>() }; + const getWallets = vi.fn(() => ({ + get: () => wallets, + on: (event: 'register' | 'unregister', listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + })); + return { wallets, listeners, getWallets, loadModule: vi.fn() }; +}); + +vi.mock('@wallet-standard/core', () => { + registry.loadModule(); + return { getWallets: registry.getWallets }; +}); +vi.mock('../user-profile-web3-wallets-section.model', () => ({ + useUserProfileWeb3WalletsModel: () => + ({ + status: 'ready', + userId: 'user_1', + wallets: [], + availableProviders: [{ id: 'web3_solana_signature', provider: 'Solana', walletPicker: 'solana' }], + connect: async () => {}, + setPrimary: async () => {}, + }) satisfies ReadyWeb3WalletsModel, +})); + +function wallet(name: string): Wallet { + return { + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features: { 'standard:connect': {}, 'solana:signMessage': {} }, + }; +} + +describe('Solana picker discovery lifetime', () => { + it('discovers wallets only while the picker content is mounted and refreshes after reopening', async () => { + registry.wallets.push(wallet('Phantom')); + const user = userEvent.setup(); + const { unmount } = render( + + + , + ); + + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(registry.loadModule).not.toHaveBeenCalled(); + expect(registry.getWallets).not.toHaveBeenCalled(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(await screen.findByRole('button', { name: 'Phantom' })).toBeInTheDocument(); + expect(registry.loadModule).toHaveBeenCalledOnce(); + expect(registry.getWallets).toHaveBeenCalledOnce(); + expect(registry.listeners.register.size).toBe(1); + expect(registry.listeners.unregister.size).toBe(1); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + + registry.wallets.splice(0, registry.wallets.length, wallet('Backpack')); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(await screen.findByRole('button', { name: 'Backpack' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Phantom' })).not.toBeInTheDocument(); + expect(registry.getWallets).toHaveBeenCalledTimes(2); + + unmount(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model-errors.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model-errors.test.tsx new file mode 100644 index 00000000000..97a093331bf --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model-errors.test.tsx @@ -0,0 +1,47 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../../../__tests__/async'; +import { useUserProfileSolanaWalletsModel } from '../user-profile-solana-wallet.model'; + +afterEach(() => { + vi.doUnmock('@wallet-standard/core'); +}); + +describe('Solana wallet discovery failures', () => { + it('handles a registry import that rejects after unmount', async () => { + const load = deferred(); + const unhandled = vi.fn(); + window.addEventListener('unhandledrejection', unhandled); + const loadRegistry = vi.fn(() => load.promise); + vi.doMock('@wallet-standard/core', loadRegistry); + + try { + const { result, unmount } = renderHook(useUserProfileSolanaWalletsModel); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => expect(loadRegistry).toHaveBeenCalledOnce()); + unmount(); + await act(async () => { + load.reject(new Error('Registry import failed after unmount')); + await vi.dynamicImportSettled(); + }); + expect(result.current).toEqual({ status: 'loading' }); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('unhandledrejection', unhandled); + } + }); + + it('reports a failed registry import instead of an empty wallet list', async () => { + vi.doMock('@wallet-standard/core', () => { + throw new Error('Failed to fetch the wallet registry chunk'); + }); + + const { result } = renderHook(useUserProfileSolanaWalletsModel); + expect(result.current).toEqual({ status: 'loading' }); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(result.current).toMatchObject({ status: 'error' }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model.test.tsx new file mode 100644 index 00000000000..23851a0884e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-solana-wallet.model.test.tsx @@ -0,0 +1,124 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { Wallet } from '@wallet-standard/core'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileSolanaWalletsModel } from '../user-profile-solana-wallet.model'; + +const registry = vi.hoisted(() => { + const wallets: Wallet[] = []; + const listeners = { register: new Set<() => void>(), unregister: new Set<() => void>() }; + const getWallets = vi.fn(() => ({ + get: () => wallets, + on: (event: 'register' | 'unregister', listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + })); + return { wallets, listeners, getWallets }; +}); + +vi.mock('@wallet-standard/core', () => ({ getWallets: registry.getWallets })); + +function wallet( + name: string, + features: Wallet['features'] = { 'standard:connect': {}, 'solana:signMessage': {} }, + chains: Wallet['chains'] = ['solana:mainnet'], +): Wallet { + return { + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains, + accounts: [], + features, + }; +} + +describe('useUserProfileSolanaWalletsModel', () => { + beforeEach(() => { + registry.wallets.splice(0); + registry.listeners.register.clear(); + registry.listeners.unregister.clear(); + registry.getWallets.mockClear(); + }); + + it('renders a loading server snapshot without initializing the browser registry', () => { + registry.wallets.push(wallet('Phantom')); + function WalletNames() { + return createElement('div', null, useUserProfileSolanaWalletsModel().status); + } + + expect(renderToString(createElement(WalletNames))).toBe('
loading
'); + expect(registry.getWallets).not.toHaveBeenCalled(); + }); + + it('starts loading and projects only eligible wallet names and icons after loading the registry', async () => { + registry.wallets.push(wallet('Phantom'), wallet('Signer only', { 'solana:signMessage': {} })); + const { result } = renderHook(useUserProfileSolanaWalletsModel); + + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Phantom', icon: 'data:image/svg+xml;base64,' }], + }), + ); + }); + + it.each([ + { chains: ['solana:mainnet'], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: true }, + { chains: ['solana:mainnet'], features: { 'solana:signMessage': {} }, eligible: false }, + { chains: ['solana:mainnet'], features: { 'standard:connect': {} }, eligible: false }, + { chains: ['eip155:1'], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: false }, + { chains: [], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: false }, + ] as const)('requires a Solana chain and both sign-in capabilities for $chains and $features', async entry => { + registry.wallets.push(wallet('Candidate', entry.features, entry.chains)); + const { result } = renderHook(useUserProfileSolanaWalletsModel); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(registry.getWallets).toHaveBeenCalled(); + expect(result.current).toEqual({ + status: 'ready', + wallets: entry.eligible ? [{ name: 'Candidate', icon: 'data:image/svg+xml;base64,' }] : [], + }); + }); + + it('refreshes on registration and unregistration and removes both subscriptions on unmount', async () => { + const { result, unmount } = renderHook(useUserProfileSolanaWalletsModel); + await waitFor(() => expect(registry.listeners.register.size).toBe(1)); + + act(() => { + registry.wallets.push(wallet('Backpack')); + registry.listeners.register.forEach(listener => listener()); + }); + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Backpack', icon: 'data:image/svg+xml;base64,' }], + }); + + act(() => { + registry.wallets.splice(0); + registry.listeners.unregister.forEach(listener => listener()); + }); + expect(result.current).toEqual({ status: 'ready', wallets: [] }); + + unmount(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); + + it('does not initialize or subscribe when unmounted before the module loads', async () => { + const { unmount } = renderHook(useUserProfileSolanaWalletsModel); + unmount(); + await act(async () => { + await vi.dynamicImportSettled(); + }); + + expect(registry.getWallets).not.toHaveBeenCalled(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.model.test.ts new file mode 100644 index 00000000000..201fd3c48c7 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.model.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizedWeb3Wallet, projectWeb3Wallets } from '../user-profile-web3-wallets-section.model'; + +const metamask = { + id: 'wallet_metamask', + web3Wallet: '0x1234567890abcdef', + verification: { strategy: 'web3_metamask_signature', status: 'verified', expireAt: null }, +} as const; + +describe('Web3 wallet projection', () => { + it('matches the backend wallet sanitizer without folding Solana address case', () => { + expect(normalizedWeb3Wallet(' 0XAbCd ')).toBe('0xabcd'); + expect(normalizedWeb3Wallet(' SolanaAbCd ')).toBe('SolanaAbCd'); + expect(normalizedWeb3Wallet(' Solanaabcd ')).toBe('Solanaabcd'); + }); + + it('WEB3-01 shows configured providers that do not have a verified wallet', () => { + expect( + projectWeb3Wallets({ + 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, verification: { ...metamask.verification, 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', web3Wallet: '0xadmin', verification: { strategy: 'admin', status: 'verified', expireAt: null } }, + { + id: 'unknown', + web3Wallet: '0xunknown', + verification: { strategy: 'unknown', status: 'verified', expireAt: null }, + }, + ], + 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 without mutating the input', () => { + const wallets = Object.freeze([ + { + ...metamask, + id: 'unverified_later', + verification: { ...metamask.verification, status: 'unverified' as const, expireAt: new Date(200) }, + }, + { ...metamask, id: 'verified_z' }, + { ...metamask, id: 'verified_a' }, + { + ...metamask, + id: 'primary', + verification: { ...metamask.verification, status: 'unverified' as const }, + }, + { + ...metamask, + id: 'unverified_earlier', + verification: { ...metamask.verification, status: 'unverified' as const, expireAt: new Date(100) }, + }, + ]); + const result = projectWeb3Wallets({ + wallets, + 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_earlier', + 'unverified_later', + ]); + expect(result.availableProviders).toEqual([]); + } + expect(wallets.map(wallet => wallet.id)).toEqual([ + 'unverified_later', + 'verified_z', + 'verified_a', + 'primary', + 'unverified_earlier', + ]); + }); + + it('WEB3-04 keeps wallets without a verification status after expiring unverified wallets', () => { + const result = projectWeb3Wallets({ + wallets: [ + { ...metamask, id: 'missing', verification: { ...metamask.verification, status: null } }, + { + ...metamask, + id: 'unverified_without_expiry', + verification: { ...metamask.verification, status: 'unverified' }, + }, + { + ...metamask, + id: 'unverified_with_expiry', + verification: { ...metamask.verification, status: 'unverified', expireAt: new Date('2026-01-02') }, + }, + ], + primaryId: null, + enabledStrategies: [], + 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-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..e4f723c7fa6 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx @@ -0,0 +1,50 @@ +import type { ComponentProps } from 'react'; + +import { Dialog } from '../../../components/dialog'; +import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; +import { UserProfileSolanaWalletView } from './user-profile-solana-wallet.view'; + +type SolanaWalletContentProps = Omit, 'discovery'>; + +export function UserProfileSolanaWalletDialog({ + open, + pendingWalletName, + error, + onOpenChange, + onConnect, +}: SolanaWalletContentProps & { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const pending = pendingWalletName !== undefined; + + return ( + { + if (!nextOpen && pending) { + return; + } + onOpenChange(nextOpen); + }} + > + + + + + ); +} + +function SolanaWalletContent(props: SolanaWalletContentProps) { + const discovery = useUserProfileSolanaWalletsModel(); + return ( + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts new file mode 100644 index 00000000000..85d2e80e2c0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; + +export type InstalledSolanaWallet = { name: string; icon: string }; + +export type SolanaWalletDiscovery = + | { status: 'loading' } + | { status: 'ready'; wallets: readonly InstalledSolanaWallet[] } + | { status: 'error' }; + +function isSolanaSignInWallet(wallet: { + chains: readonly string[]; + features: Readonly>; +}): boolean { + return ( + wallet.chains.some(chain => chain.startsWith('solana:')) && + 'standard:connect' in wallet.features && + 'solana:signMessage' in wallet.features + ); +} + +export function useUserProfileSolanaWalletsModel(): SolanaWalletDiscovery { + const [discovery, setDiscovery] = useState({ status: 'loading' }); + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | undefined; + + void import('@wallet-standard/core') + .then(({ getWallets }) => { + if (disposed) { + return; + } + + const registry = getWallets(); + const update = () => { + setDiscovery({ + status: 'ready', + wallets: registry + .get() + .filter(isSolanaSignInWallet) + .map(({ name, icon }) => ({ name, icon })), + }); + }; + const offRegister = registry.on('register', update); + const offUnregister = registry.on('unregister', update); + unsubscribe = () => { + offRegister(); + offUnregister(); + }; + update(); + }) + .catch(() => { + if (disposed) { + return; + } + setDiscovery({ status: 'error' }); + }); + + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); + + return discovery; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx new file mode 100644 index 00000000000..0a19e2499b8 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx @@ -0,0 +1,92 @@ +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Dialog } from '../../../components/dialog'; +import { useMessages } from '../../../localization'; +import type { SolanaWalletDiscovery } from './user-profile-solana-wallet.model'; + +export function UserProfileSolanaWalletView({ + discovery, + pendingWalletName, + error, + onConnect, +}: { + discovery: SolanaWalletDiscovery; + pendingWalletName?: string; + error?: string; + onConnect: (walletName: string) => void; +}) { + const m = useMessages('userProfileWeb3Wallets'); + const pending = pendingWalletName !== undefined; + + return ( + + + {m.solanaDialog.title} + {m.solanaDialog.description} + + + {discovery.status === 'error' ? m.solanaDialog.loadError : error} + + + {discovery.status === 'loading' ?

{m.solanaDialog.loading}

: null} + {discovery.status === 'ready' && discovery.wallets.length === 0 ? ( +

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

+ ) : null} + {discovery.status === 'ready' + ? discovery.wallets.map(wallet => ( + onConnect(wallet.name)} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + )) + : null} +
+ + + {m.solanaDialog.cancel} + + } + /> + +
+ ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx similarity index 82% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx index 933a4dd5c02..f77d287bbc7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx @@ -1,26 +1,30 @@ import * as stylex from '@stylexjs/stylex'; import type { Ref } from 'react'; -import type { ActionMenuAction } from '../../components/action-menu'; -import { ActionMenu } from '../../components/action-menu'; -import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; -import { Icon, IconFrame } from '../../components/icon'; -import { Section } from '../../components/section'; -import { fill, useMessages } from '../../localization'; -import { truncationStyles } from '../../styles/typography.styles'; +import type { ActionMenuAction } from '../../../components/action-menu'; +import { ActionMenu } from '../../../components/action-menu'; +import { Badge } from '../../../components/badge'; +import { SubmitButton } from '../../../components/button'; +import { Icon, IconFrame } from '../../../components/icon'; +import { Section } from '../../../components/section'; +import { fill, useMessages } from '../../../localization'; +import { truncationStyles } from '../../../styles/typography.styles'; import { styles } from './user-profile-web3-wallets.styles'; -import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.types'; export function UserProfileWeb3WalletRowView({ wallet, 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; @@ -85,10 +89,12 @@ export function UserProfileWeb3WalletRowView({ {onConnect ? ( - + ) : actions.length > 0 ? ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/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..a35d047a3b1 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts @@ -0,0 +1,70 @@ +import { useState } from 'react'; + +import { usePendingAction } from '../../../hooks/use-pending-action'; +import { useMessages } from '../../../localization'; +import type { ReadyWeb3WalletsModel, UserProfileWeb3Provider } from './user-profile-web3-wallets-section.types'; + +type SolanaPicker = { open: false } | { open: true; provider: UserProfileWeb3Provider; walletName?: string }; + +export function useUserProfileWeb3WalletsController({ + wallets, + availableProviders, + connect, + setPrimary, + remove, +}: Pick) { + const messages = useMessages('userProfileWeb3Wallets'); + const action = usePendingAction({ errorFallback: messages.errors.generic }); + const [picker, setPicker] = useState({ open: false }); + + const onConnect = (id: string) => { + const provider = availableProviders.find(candidate => candidate.id === id); + if (!provider) { + return; + } + // TODO: Open the Solana picker without pending while preserving the same-render action lock. + return action.run(provider.id, () => { + if (provider.walletPicker === 'solana') { + setPicker({ open: true, provider }); + return; + } + return connect(provider.id); + }); + }; + + const connectSolana = (walletName: string) => { + if (!picker.open) { + return; + } + return action.run(picker.provider.id, async () => { + setPicker({ ...picker, walletName }); + await connect(picker.provider.id, walletName); + setPicker({ open: false }); + }); + }; + + return { + wallets: wallets.map(wallet => + action.errorKey === wallet.id ? { ...wallet, primaryError: action.error } : wallet, + ), + availableProviders: availableProviders.map(provider => + action.errorKey === provider.id && (!picker.open || picker.provider.id !== provider.id) + ? { ...provider, connectError: action.error } + : provider, + ), + pendingId: action.pendingKey, + solanaPickerOpen: picker.open, + solanaPickerError: picker.open && picker.provider.id === action.errorKey ? action.error : undefined, + pendingWalletName: picker.open && picker.provider.id === action.pendingKey ? picker.walletName : undefined, + onConnect, + onSetPrimary: (walletId: string) => action.run(walletId, () => setPrimary(walletId)), + // TODO: Share the action lock during removal while preserving errors for the confirmation dialog. + onRemove: remove, + connectSolana, + closeSolanaPicker: () => { + if (!action.isPending) { + setPicker({ open: false }); + } + }, + }; +} 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..a93be83dbcd --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts @@ -0,0 +1,209 @@ +import { iconImageUrl } from '@clerk/shared/constants'; +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; +import { useClerk, useUser } from '@clerk/shared/react'; +import type { VerificationResource, Web3WalletResource } from '@clerk/shared/types'; +import { WEB3_PROVIDERS } from '@clerk/shared/web3'; + +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; +import type { + UserProfileWeb3Provider, + UserProfileWeb3Wallet, + UserProfileWeb3WalletsModel, +} from './user-profile-web3-wallets-section.types'; + +export type Web3WalletEntry = Pick & { + verification: Pick; +}; + +export type Web3WalletsProjection = + | { status: 'hidden' } + | { status: 'ready'; wallets: UserProfileWeb3Wallet[]; availableProviders: UserProfileWeb3Provider[] }; + +export function normalizedWeb3Wallet(identifier: string): string { + const trimmed = identifier.trim(); + return /^0x/i.test(trimmed) ? trimmed.toLowerCase() : trimmed; +} + +export function projectWeb3Wallets({ + wallets, + primaryId, + 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.verification.strategy === 'admin' || + WEB3_PROVIDERS.some(provider => provider.strategy === wallet.verification.strategy), + ); + const primary = knownWallets.filter(wallet => wallet.id === primaryId); + const remaining = knownWallets.filter(wallet => wallet.id !== primaryId); + const verified = remaining.filter(wallet => wallet.verification.status === 'verified'); + const unverified = remaining.filter( + wallet => wallet.verification.status !== null && wallet.verification.status !== 'verified', + ); + const withoutStatus = remaining.filter(wallet => wallet.verification.status === null); + + verified.sort((first, second) => first.id.localeCompare(second.id)); + unverified.sort((first, second) => { + const firstExpiry = first.verification.expireAt; + const secondExpiry = second.verification.expireAt; + return firstExpiry && secondExpiry ? firstExpiry.getTime() - secondExpiry.getTime() : 0; + }); + + const sorted = [...primary, ...verified, ...unverified, ...withoutStatus]; + + const connectedStrategies = new Set( + wallets.filter(wallet => wallet.verification.status === 'verified').map(wallet => wallet.verification.strategy), + ); + + return { + status: 'ready', + wallets: sorted.map(wallet => { + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === wallet.verification.strategy); + return { + id: wallet.id, + address: wallet.web3Wallet, + provider: provider?.name, + iconUrl: provider ? iconImageUrl(provider.provider) : undefined, + isPrimary: wallet.id === primaryId, + isVerified: wallet.verification.status === 'verified', + }; + }), + availableProviders: allowCreation + ? providers + .filter(provider => !connectedStrategies.has(provider.strategy)) + .map(provider => ({ + id: provider.strategy, + walletPicker: provider.provider === 'solana' ? 'solana' : undefined, + provider: provider.name, + iconUrl: iconImageUrl(provider.provider), + })) + : [], + }; +} + +export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const environment = useMosaicEnvironment(); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + if (!user || !environment.userSettings.attributes.web3_wallet?.enabled) { + return { status: 'hidden' }; + } + const web3Attribute = environment.userSettings.attributes.web3_wallet; + const projection = projectWeb3Wallets({ + wallets: user.web3Wallets, + primaryId: user.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: + !web3Attribute.immutable && allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), + }); + if (projection.status === 'hidden') { + return projection; + } + const userId = user.id; + const requireCurrentUser = () => { + const current = clerk.user; + if (!current || current.id !== userId) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + return current; + }; + return { + ...projection, + userId, + connect: async (strategy, walletName) => { + requireCurrentUser(); + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); + const available = projection.availableProviders.some(candidate => candidate.id === strategy); + const manager = clerk.__internal_moduleManager; + if (!provider || !available || !manager) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + const web3 = createWeb3(manager); + const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); + if (!identifier) { + throw new ClerkRuntimeError('A Web3 Wallet extension cannot be found.', { code: 'web3_missing_identifier' }); + } + const current = requireCurrentUser(); + const currentProjection = projectWeb3Wallets({ + wallets: current.web3Wallets, + primaryId: current.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: + Boolean(environment.userSettings.attributes.web3_wallet?.enabled) && + !environment.userSettings.attributes.web3_wallet?.immutable && + allowsIdentificationCreation(current, environment.userSettings.enterpriseSSO), + }); + if ( + currentProjection.status !== 'ready' || + !currentProjection.availableProviders.some(candidate => candidate.id === strategy) + ) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + const normalizedIdentifier = normalizedWeb3Wallet(identifier); + const existing = current.web3Wallets.find( + wallet => + wallet.verification.status !== 'verified' && normalizedWeb3Wallet(wallet.web3Wallet) === normalizedIdentifier, + ); + const wallet = existing ?? (await current.createWeb3Wallet({ web3Wallet: identifier })); + requireCurrentUser(); + if (!wallet) { + throw new ClerkRuntimeError('The wallet could not be created.', { code: 'web3_wallet_creation_failed' }); + } + const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); + requireCurrentUser(); + const nonce = prepared.verification.message; + if (!nonce) { + throw new ClerkRuntimeError('The wallet verification message is unavailable.', { + code: 'web3_verification_message_unavailable', + }); + } + const signature = await web3.generateWeb3Signature({ + identifier, + nonce, + provider: provider.provider, + walletName, + }); + requireCurrentUser(); + if (!signature) { + throw new ClerkRuntimeError('The wallet signature is unavailable.', { code: 'web3_signature_unavailable' }); + } + await prepared.attemptVerification({ signature }); + }, + setPrimary: async walletId => { + const current = requireCurrentUser(); + if (!current.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + await current.update({ primaryWeb3WalletId: walletId }); + }, + remove: web3Attribute.immutable + ? undefined + : async walletId => { + if (environment.userSettings.attributes.web3_wallet?.immutable) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); + if (!wallet) { + throw new ClerkRuntimeError('This wallet provider is unavailable.', { code: 'web3_provider_unavailable' }); + } + await wallet.destroy(); + }, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.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..14caefc0b56 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; + +import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; +import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; +import { useUserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.model'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; +import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; + +export interface UserProfileWeb3WalletsSectionProps { + fallback?: ReactNode; + fallbackFocus?: () => HTMLElement | null; +} + +export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserProfileWeb3WalletsSectionProps) { + const model = useUserProfileWeb3WalletsModel(); + if (model.status === 'loading') { + return fallback ?? null; + } + if (model.status === 'hidden') { + return null; + } + return ( + + ); +} + +function Web3Wallets({ + model, + fallbackFocus, +}: { + model: ReadyWeb3WalletsModel; + fallbackFocus?: () => HTMLElement | null; +}) { + const controller = useUserProfileWeb3WalletsController(model); + return ( + <> + { + void controller.onConnect(id); + }} + onSetPrimary={id => { + void controller.onSetPrimary(id); + }} + onRemove={controller.onRemove} + /> + { + if (!open) { + controller.closeSolanaPicker(); + } + }} + onConnect={walletName => { + void controller.connectSolana(walletName); + }} + /> + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts new file mode 100644 index 00000000000..cc553c1ea4f --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -0,0 +1,45 @@ +import type { Web3Strategy } from '@clerk/shared/types'; + +export interface UserProfileWeb3Provider { + id: Web3Strategy; + walletPicker?: 'solana'; + provider: string; + iconUrl?: string; + connectError?: string; +} + +export interface UserProfileWeb3Wallet { + id: string; + address: string; + provider?: string; + iconUrl?: string; + isPrimary?: boolean; + isVerified: boolean; + canRemove?: boolean; + primaryError?: string; +} + +export interface UserProfileWeb3WalletsSectionViewProps { + fallbackFocus?: () => HTMLElement | null; + wallets: UserProfileWeb3Wallet[]; + availableProviders?: UserProfileWeb3Provider[]; + pendingId?: string; + onConnect?: (id: string) => void; + onSetPrimary?: (id: string) => void; + onRemove?: (id: string) => void | Promise; +} + +export type UserProfileWeb3WalletsModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + userId: string; + wallets: UserProfileWeb3Wallet[]; + availableProviders: UserProfileWeb3Provider[]; + connect: (strategy: Web3Strategy, walletName?: string) => Promise; + setPrimary: (walletId: string) => Promise; + remove?: (walletId: string) => Promise; + }; + +export type ReadyWeb3WalletsModel = Extract; diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx similarity index 72% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx index 6ed2cb678ee..644961421bd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx @@ -1,43 +1,21 @@ import { useMemo, useRef } from 'react'; -import { Confirmation } from '../../blocks/confirmation'; -import { Section } from '../../components/section'; -import { useListRemovalFocus } from '../../hooks/use-list-removal-focus'; -import { fill, useMessages } from '../../localization'; -import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible'; +import { Confirmation } from '../../../blocks/confirmation'; +import { Section } from '../../../components/section'; +import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import { fill, useMessages } from '../../../localization'; +import { truncateWithEndVisible } from '../../../utils/truncate-text-with-end-visible'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; - -export interface UserProfileWeb3Provider { - id: string; - provider: string; - iconUrl?: string; - connectError?: string; -} - -export interface UserProfileWeb3Wallet { - id: string; - address: string; - provider?: string; - iconUrl?: string; - isPrimary?: boolean; - isVerified: boolean; - canRemove?: boolean; - primaryError?: string; -} - -export interface UserProfileWeb3WalletsSectionViewProps { - fallbackFocus?: () => HTMLElement | null; - wallets: UserProfileWeb3Wallet[]; - availableProviders?: UserProfileWeb3Provider[]; - onConnect?: (id: string) => void; - onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void | Promise; -} +import type { + UserProfileWeb3Wallet, + UserProfileWeb3WalletsSectionViewProps, +} from './user-profile-web3-wallets-section.types'; export function UserProfileWeb3WalletsSectionView({ wallets, fallbackFocus, availableProviders = [], + pendingId, onConnect, onSetPrimary, onRemove, @@ -55,6 +33,7 @@ export function UserProfileWeb3WalletsSectionView({ }); const removeWallet = useMemo(() => Confirmation.createHandle(), []); const hasRows = wallets.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); + const isBusy = pendingId !== undefined; return ( <> @@ -75,6 +54,7 @@ export function UserProfileWeb3WalletsSectionView({ key={wallet.id} wallet={wallet} triggerRef={removalFocus.registerTrigger(wallet.id)} + isDisabled={isBusy} onSetPrimary={onSetPrimary} onRemove={onRemove ? wallet => removeWallet.open(wallet) : undefined} /> @@ -84,6 +64,8 @@ export function UserProfileWeb3WalletsSectionView({ )) diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages.ts similarity index 56% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages.ts index abdfb66e137..de26c51ce6b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages.ts @@ -7,6 +7,18 @@ export const userProfileWeb3WalletsMessages = { setPrimary: 'Set as primary', primary: 'Primary', unverified: 'Unverified', + errors: { + generic: 'Something went wrong. Please try again.', + }, + solanaDialog: { + title: 'Select a Solana wallet', + description: 'Choose an installed wallet to connect to your account.', + loading: 'Loading wallets…', + loadError: 'Wallets could not be loaded. Please refresh the page and try again.', + 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/src/features/user-profile/user-profile-web3-wallets.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.styles.ts similarity index 89% rename from packages/mosaic/src/features/user-profile/user-profile-web3-wallets.styles.ts rename to packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.styles.ts index b9812997644..d17a25cf20f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space } from '../../tokens.stylex'; +import { colorVars, radiusVars, space } from '../../../tokens.stylex'; export const styles = stylex.create({ connectRow: { justifyContent: 'center' }, diff --git a/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx b/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx index e4febb92444..d7f1e423571 100644 --- a/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx +++ b/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx @@ -76,6 +76,52 @@ describe('usePendingAction', () => { expect(result.current.isPending).toBe(false); }); + it('holds the pending action until a redirect grace period completes', async () => { + vi.useFakeTimers(); + try { + const { result } = renderHook(() => usePendingAction({ errorFallback: 'Try again.' })); + act(() => { + void result.current.run('oauth', () => new Promise(resolve => setTimeout(resolve, 2000))); + }); + await act(() => vi.advanceTimersByTimeAsync(1999)); + expect(result.current.pendingKey).toBe('oauth'); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(result.current.pendingKey).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + ['success', () => Promise.resolve()], + ['failure', () => Promise.reject(blocked())], + ])('blocks actions from the same render and releases them after %s', async (_label, settle) => { + const pending = deferred(); + const { result } = renderHook(() => usePendingAction()); + const action = result.current; + const second = vi.fn(() => Promise.resolve()); + let run: Promise | undefined; + let blockedRun: Promise | undefined; + + act(() => { + run = action.run('a', () => pending.promise.then(settle)); + blockedRun = action.run('b', second); + }); + expect(second).not.toHaveBeenCalled(); + expect(await blockedRun).toBe(false); + expect(result.current.isPending).toBe(true); + + await act(async () => { + pending.resolve(); + await run; + }); + expect(result.current.isPending).toBe(false); + await act(async () => { + expect(await action.run('b', second)).toBe(true); + }); + expect(second).toHaveBeenCalledOnce(); + }); + it('reports which key failed', async () => { const { result } = renderHook(() => usePendingAction()); diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 9efc9041a00..4ab5ff2eed7 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -63,6 +63,10 @@ export const errorMessages: { readonly generic: string } & Readonly { + const enterpriseSSO = { enabled: true }; + + it('blocks creation for an active enterprise connection that disables additional identifications', () => { + const blocked = { + enterpriseAccounts: [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }], + }; + expect(allowsIdentificationCreation(blocked, enterpriseSSO)).toBe(false); + }); + + it('allows creation when the enterprise connection is inactive or enterprise SSO is off', () => { + const inactive = { + enterpriseAccounts: [{ active: false, enterpriseConnection: { disableAdditionalIdentifications: true } }], + }; + expect(allowsIdentificationCreation(inactive, enterpriseSSO)).toBe(true); + + const active = { + enterpriseAccounts: [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }], + }; + expect(allowsIdentificationCreation(active, { enabled: false })).toBe(true); + }); +}); diff --git a/packages/mosaic/src/utils/allows-identification-creation.ts b/packages/mosaic/src/utils/allows-identification-creation.ts new file mode 100644 index 00000000000..8ac0f8b541c --- /dev/null +++ b/packages/mosaic/src/utils/allows-identification-creation.ts @@ -0,0 +1,23 @@ +import type { + EnterpriseAccountConnectionResource, + EnterpriseAccountResource, + EnterpriseSSOSettings, +} from '@clerk/shared/types'; + +type EnterpriseUser = { + enterpriseAccounts: (Pick & { + enterpriseConnection?: Pick | null; + })[]; +}; + +export function allowsIdentificationCreation( + user: EnterpriseUser, + enterpriseSSO: Pick, +): boolean { + if (!enterpriseSSO.enabled) { + return true; + } + return !user.enterpriseAccounts.some( + account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications, + ); +} 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/web3-wallets/page.tsx b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx new file mode 100644 index 00000000000..d268f2082fc --- /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/mosaic-provider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function Web3WalletsLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Web3 wallets

+

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

+
+ {!isLoaded ?

Loading…

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

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

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index a330e15c410..f62a4cb9aae 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -19,6 +19,7 @@ const liveRoutes = new Map([ ['/organization-profile/organization-profile-general-panel', '/live/organization-general'], ['/organization-profile/organization-profile-danger-section', '/live/organization-danger'], ['/user-profile/user-profile-passkeys-section', '/live/passkeys'], + ['/user-profile/user-profile-web3wallets-section', '/live/web3-wallets'], ['/reverification/reverification', '/live/reverification'], ]); diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index a02fd0c09dc..01e9d9bf5b0 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -1,7 +1,7 @@ import type { UserProfileWeb3Provider, UserProfileWeb3Wallet, -} from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +} from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -12,13 +12,17 @@ interface DemoWallet extends UserProfileWeb3Wallet { } const providers: UserProfileWeb3Provider[] = [ - { id: 'metamask', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, - { id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg' }, + { id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, + { + id: 'web3_coinbase_wallet_signature', + provider: 'Coinbase Wallet', + iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', + }, ]; export const primaryWallet: DemoWallet = { id: 'wallet_1', - providerId: 'metamask', + providerId: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg', address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', @@ -28,7 +32,7 @@ export const primaryWallet: DemoWallet = { export const secondaryWallet: DemoWallet = { id: 'wallet_2', - providerId: 'coinbase-wallet', + providerId: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', address: '0x1234567890abcdef1234567890abcdef12345678', diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index 19fefc34559..81a830b082b 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -12,7 +12,7 @@ import type { UserProfileEmail, UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import { useRef, useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 5094a1b339e..bfbc5bc6eac 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -19,7 +19,7 @@ import * as UserProfileProfilePanelStories from './user-profile-profile-panel.st import { useRef } from 'react'; import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import { UserProfileConnectedAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section'; -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; const titleRef = useRef(null); diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index e6b67d19e40..5ec9250cdd9 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -4,7 +4,7 @@ import type { UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import { useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index 894cdd5963a..40bbf1ad9f1 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -1,4 +1,4 @@ -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -9,11 +9,11 @@ export { default as __source } from './user-profile-web3-wallets-section.stories export const meta: StoryMeta = { group: 'User Profile', status: 'wip', - substatus: 'needs wire-up', title: 'UserProfileWeb3WalletsSection', label: 'Web3 wallets', navigation: { category: 'Sections' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx', + source: + 'packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx', }; export function Default() { @@ -34,7 +34,7 @@ export function ConnectOnly() { export function UnverifiedWallet() { const fixture = useWeb3WalletsFixture({ initialWallets: [{ ...primaryWallet, isPrimary: false, isVerified: false }], - availableProviders: [{ id: 'metamask', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], + availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], }); return ( ({ - wallets: [] as Wallet[], - listeners: { register: new Set<() => void>(), unregister: new Set<() => void>() }, -})); - -vi.mock('@wallet-standard/core', () => ({ - getWallets: () => ({ - get: () => registry.wallets, - on: (event: 'register' | 'unregister', listener: () => void) => { - registry.listeners[event].add(listener); - return () => registry.listeners[event].delete(listener); - }, - }), -})); - -const makeWallet = (name: string, chains: string[], features: string[]): Wallet => - ({ - name, - icon: 'data:image/svg+xml;base64,', - version: '1.0.0', - chains, - accounts: [], - features: Object.fromEntries(features.map(feature => [feature, {}])), - }) as unknown as Wallet; +let installedWallets: Wallet[] = []; +const unregister: Array<() => void> = []; + +function register(api: WindowAppReadyEventAPI) { + unregister.push(api.register(...installedWallets)); +} + +function onAppReady(event: Event & { detail?: WindowAppReadyEventAPI }) { + if (event.detail) { + register(event.detail); + } +} + +function installWallets(wallets: Wallet[]) { + installedWallets = wallets; + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); +} + +const makeWallet = (name: string, chains: Wallet['chains'], features: string[]): Wallet => ({ + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains, + accounts: [], + features: Object.fromEntries(features.map(feature => [feature, {}])), +}); const SIGN_IN_FEATURES = ['standard:connect', 'solana:signMessage']; @@ -41,18 +43,22 @@ const Buttons = withCardStateProvider(Web3SolanaWalletButtons); describe('Web3SolanaWalletButtons', () => { beforeEach(() => { - registry.wallets = []; - registry.listeners.register.clear(); - registry.listeners.unregister.clear(); + installedWallets = []; + window.addEventListener('wallet-standard:app-ready', onAppReady); + }); + + afterEach(() => { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.splice(0).forEach(off => off()); }); it('lists only Solana wallets that can connect and sign messages', async () => { - registry.wallets = [ + installWallets([ makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES), makeWallet('Solana Viewer', ['solana:mainnet'], ['standard:connect']), makeWallet('Solana Signer', ['solana:mainnet'], ['solana:signMessage']), makeWallet('MetaMask', ['eip155:1'], SIGN_IN_FEATURES), - ]; + ]); const { wrapper } = await createFixtures(); render(, { wrapper }); @@ -71,15 +77,20 @@ describe('Web3SolanaWalletButtons', () => { expect(await screen.findByText(/No Solana Web3 wallets detected/)).toBeInTheDocument(); act(() => { - registry.wallets = [makeWallet('Backpack', ['solana:mainnet'], SIGN_IN_FEATURES)]; - registry.listeners.register.forEach(listener => listener()); + installWallets([makeWallet('Backpack', ['solana:mainnet'], SIGN_IN_FEATURES)]); }); expect(await screen.findByText('Continue with Backpack')).toBeInTheDocument(); + + act(() => { + installedWallets = []; + unregister.splice(0).forEach(off => off()); + }); + expect(await screen.findByText(/No Solana Web3 wallets detected/)).toBeInTheDocument(); }); it('passes the chosen wallet name to the auth callback', async () => { - registry.wallets = [makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES)]; + installWallets([makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES)]); const web3AuthCallback = vi.fn().mockResolvedValue(undefined); const { wrapper } = await createFixtures(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f8e9f20ee5..474f60c5838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,9 @@ importers: '@floating-ui/react': specifier: catalog:repo version: 0.27.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wallet-standard/core': + specifier: catalog:module-manager + version: 1.1.1 devDependencies: '@clerk/clerk-js': specifier: workspace:^