diff --git a/.changeset/web3-wallet-removal-dialog.md b/.changeset/web3-wallet-removal-dialog.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/web3-wallet-removal-dialog.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index bc9fe0a5055..1550fe9a5b2 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -262,8 +262,15 @@ import { meta as userProfileSubscriptionSectionMeta, } from '../stories/user-profile-subscription-section.stories'; import { + ConnectedWallets as UserProfileWeb3WalletsSectionConnectedWallets, + ConnectionError as UserProfileWeb3WalletsSectionConnectionError, + ConnectOnly as UserProfileWeb3WalletsSectionConnectOnly, Default as UserProfileWeb3WalletsSectionDefault, meta as userProfileWeb3WalletsSectionMeta, + PrimaryError as UserProfileWeb3WalletsSectionPrimaryError, + RemovalError as UserProfileWeb3WalletsSectionRemovalError, + RemovalPending as UserProfileWeb3WalletsSectionRemovalPending, + UnverifiedWallet as UserProfileWeb3WalletsSectionUnverifiedWallet, } from '../stories/user-profile-web3-wallets-section.stories'; import { Default as VisuallyHiddenDefault, @@ -536,6 +543,13 @@ const userProfileConnectedAccountsSectionModule: StoryModule = { const userProfileWeb3WalletsSectionModule: StoryModule = { meta: userProfileWeb3WalletsSectionMeta, Default: UserProfileWeb3WalletsSectionDefault, + ConnectedWallets: UserProfileWeb3WalletsSectionConnectedWallets, + ConnectOnly: UserProfileWeb3WalletsSectionConnectOnly, + ConnectionError: UserProfileWeb3WalletsSectionConnectionError, + PrimaryError: UserProfileWeb3WalletsSectionPrimaryError, + RemovalPending: UserProfileWeb3WalletsSectionRemovalPending, + RemovalError: UserProfileWeb3WalletsSectionRemovalError, + UnverifiedWallet: UserProfileWeb3WalletsSectionUnverifiedWallet, }; const userProfileDeleteSectionModule: StoryModule = { meta: userProfileDeleteSectionMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts new file mode 100644 index 00000000000..75b19217a98 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -0,0 +1,115 @@ +import type { + UserProfileWeb3Provider, + UserProfileWeb3Wallet, +} from '@clerk/ui/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { useState } from 'react'; + +interface DemoWallet extends UserProfileWeb3Wallet { + providerId?: string; +} + +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' }, +]; + +export const primaryWallet: DemoWallet = { + id: 'wallet_1', + providerId: 'metamask', + provider: 'MetaMask', + iconUrl: 'https://img.clerk.com/static/metamask.svg', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + isPrimary: true, + isVerified: true, +}; + +export const secondaryWallet: DemoWallet = { + id: 'wallet_2', + providerId: 'coinbase-wallet', + provider: 'Coinbase Wallet', + iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', + address: '0x1234567890abcdef1234567890abcdef12345678', + isVerified: true, +}; + +export function useWeb3WalletsFixture({ + initialWallets = [primaryWallet], + availableProviders = providers, + primaryError = false, + removalState, +}: { + initialWallets?: DemoWallet[]; + availableProviders?: UserProfileWeb3Provider[]; + primaryError?: boolean; + removalState?: 'pending' | 'error'; +} = {}) { + const [wallets, setWallets] = useState(initialWallets); + const [connectionProviders, setConnectionProviders] = useState(availableProviders); + const [primaryFailed, setPrimaryFailed] = useState(false); + + return { + wallets, + availableProviders: connectionProviders.filter( + provider => !wallets.some(wallet => wallet.providerId === provider.id && wallet.isVerified), + ), + onConnect: (id: string) => { + const provider = connectionProviders.find(item => item.id === id); + if (!provider) { + return; + } + setConnectionProviders(current => + current.map(item => (item.id === id ? { ...item, connectError: undefined } : item)), + ); + setWallets(current => { + if (current.some(wallet => wallet.providerId === id && !wallet.isVerified)) { + return current.map(wallet => (wallet.providerId === id ? { ...wallet, isVerified: true } : wallet)); + } + return [ + ...current, + { + provider: provider.provider, + iconUrl: provider.iconUrl, + id: `wallet_${id}`, + providerId: id, + address: '0x1234567890abcdef1234567890abcdef12345678', + isVerified: true, + isPrimary: !current.some(wallet => wallet.isPrimary), + }, + ]; + }); + }, + onSetPrimary: (id: string) => { + if (primaryError && !primaryFailed) { + setPrimaryFailed(true); + setWallets(current => + current.map(wallet => + wallet.id === id + ? { ...wallet, primaryError: 'Unable to set this wallet as primary. Please try again.' } + : wallet, + ), + ); + return; + } + setWallets(current => + current.map(wallet => ({ ...wallet, isPrimary: wallet.id === id, primaryError: undefined })), + ); + }, + onRemove: (id: string) => { + if (removalState === 'pending') { + setWallets(current => current.map(wallet => (wallet.id === id ? { ...wallet, isRemoving: true } : wallet))); + setTimeout(() => { + setWallets(current => current.filter(wallet => wallet.id !== id)); + }, 1500); + return; + } + setWallets(current => { + if (removalState === 'error' && !current.find(wallet => wallet.id === id)?.removalError) { + return current.map(wallet => + wallet.id === id ? { ...wallet, removalError: 'Unable to remove wallet. Please try again.' } : wallet, + ); + } + return current.filter(wallet => wallet.id !== id); + }); + }, + }; +} 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 5991daf46dd..df21ed7cee8 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -68,12 +68,9 @@ export function Default(_args: Record) { isPrimary: true, isVerified: true, }, - { - id: 'coinbase-wallet', - provider: 'Coinbase Wallet', - iconUrl: providerIconUrl('coinbase_wallet'), - connected: false, - }, + ]} + availableWeb3Providers={[ + { id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: providerIconUrl('coinbase_wallet') }, ]} hasImage={Boolean(imageUrl)} imageUrl={imageUrl} diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx index c6ab12be20f..b14b20c3e6a 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx @@ -2,8 +2,7 @@ import * as Stories from './user-profile-web3-wallets-section.stories'; # UserProfileWeb3WalletsSection -Supported Web3 wallet providers with per-provider connection actions, connected identities, verification state, -primary status, and wallet actions. +Wallets and available providers are supplied separately. These examples simulate state through props without connecting to browser extensions. Reload to reset them. Solana wallet selection is a separate flow. + +## Connected Wallets + +Only verified, non-primary wallets offer Set as primary. Removing a wallet makes its provider available to connect again. + + + +## Connect Only + + + +## Unverified Wallet + +An unverified wallet offers Remove. After removal, the provider can be connected again. + + + +## Connection Error + +Retry Connect to clear the error and connect the wallet. + + + +## Primary Error + +Choose Set as primary on Coinbase Wallet to show an error. Retry succeeds. + + + +## Removal Pending + +Choose Remove wallet, then confirm to hold the pending state. Cancel is disabled while removal is pending. + + + +## Removal Error + +The first removal attempt shows an error inside the dialog. Retry succeeds. + + 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 faa09ab806d..cd446a95a9c 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 @@ -2,11 +2,14 @@ import { UserProfileWeb3WalletsSectionView } from '@clerk/ui/mosaic/features/use import type { StoryMeta } from '@/lib/types'; +import { primaryWallet, secondaryWallet, useWeb3WalletsFixture } from './fixtures/user-profile-web3-wallets'; + export { default as __source } from './user-profile-web3-wallets-section.stories?raw'; export const meta: StoryMeta = { group: 'User Profile', status: 'wip', + substatus: 'needs wire-up', title: 'UserProfileWeb3WalletsSection', label: 'Web3 wallets', navigation: { category: 'Sections' }, @@ -14,26 +17,59 @@ export const meta: StoryMeta = { }; export function Default() { + const fixture = useWeb3WalletsFixture(); + return ; +} + +export function ConnectedWallets() { + const fixture = useWeb3WalletsFixture({ initialWallets: [primaryWallet, secondaryWallet] }); + return ; +} + +export function ConnectOnly() { + const fixture = useWeb3WalletsFixture({ initialWallets: [] }); + return ; +} + +export function UnverifiedWallet() { + const fixture = useWeb3WalletsFixture({ + initialWallets: [{ ...primaryWallet, isPrimary: false, isVerified: false }], + availableProviders: [{ id: 'metamask', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], + }); return ( undefined} - onManage={() => undefined} + {...fixture} + availableProviders={fixture.wallets.length === 0 ? fixture.availableProviders : []} /> ); } + +export function ConnectionError() { + const fixture = useWeb3WalletsFixture({ + initialWallets: [], + availableProviders: [ + { + id: 'metamask', + provider: 'MetaMask', + iconUrl: primaryWallet.iconUrl, + connectError: 'Wallet extension not found. Check your wallet and try again.', + }, + ], + }); + return ; +} + +export function PrimaryError() { + const fixture = useWeb3WalletsFixture({ initialWallets: [primaryWallet, secondaryWallet], primaryError: true }); + return ; +} + +export function RemovalPending() { + const fixture = useWeb3WalletsFixture({ removalState: 'pending' }); + return ; +} + +export function RemovalError() { + const fixture = useWeb3WalletsFixture({ removalState: 'error' }); + return ; +} diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 6901098a761..00aac7aaa9f 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -236,7 +236,9 @@ describe('UserProfileProfilePanelView', () => { it('renders provider images in icon frames', () => { const { container } = renderView({ connectedAccounts: [{ id: 'google', provider: 'Google', iconUrl: '/google.svg' }], - web3Wallets: [{ id: 'metamask', provider: 'MetaMask', iconUrl: '/metamask.svg' }], + web3Wallets: [ + { id: 'metamask', provider: 'MetaMask', address: 'test', isVerified: true, iconUrl: '/metamask.svg' }, + ], }); const frames = container.querySelectorAll('.cl-icon-frame'); @@ -247,11 +249,7 @@ describe('UserProfileProfilePanelView', () => { frames.forEach(frame => expect(frame.closest('.cl-section-media')).toHaveAttribute('data-size', 'lg')); }); - it('renders Web3 wallets and forwards wallet actions', async () => { - const onConnectWeb3Wallet = vi.fn(); - const onSetPrimaryWeb3Wallet = vi.fn(); - const onRemoveWeb3Wallet = vi.fn(); - const user = userEvent.setup(); + it('composes linked wallets and available providers', () => { renderView({ web3Wallets: [ { @@ -268,53 +266,20 @@ describe('UserProfileProfilePanelView', () => { provider: 'Coinbase Wallet', isVerified: true, }, - { - id: 'disconnected', - provider: 'Coinbase Wallet', - connected: false, - }, ], - onConnectWeb3Wallet, - onSetPrimaryWeb3Wallet, - onRemoveWeb3Wallet, + availableWeb3Providers: [{ id: 'disconnected', provider: 'Coinbase Wallet' }], + onConnectWeb3Wallet: vi.fn(), + onSetPrimaryWeb3Wallet: vi.fn(), + onRemoveWeb3Wallet: vi.fn(), }); expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument(); expect(screen.getByText('MetaMask')).toBeInTheDocument(); - expect( - screen.getByRole('region', { name: 'Web3 wallets' }).querySelector('.cl-section-media[data-size="lg"] img'), - ).toHaveAttribute('src', 'https://example.com/metamask.svg'); expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); expect(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByText('Primary')).toBeInTheDocument(); - await user.click( - within(screen.getByRole('region', { name: 'Web3 wallets' })).getByRole('button', { name: 'Connect' }), - ); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - const removeWallet = screen.getByRole('menuitem', { name: 'Remove wallet' }); - expect(removeWallet).toHaveAttribute('data-color', 'negative'); - await user.click(removeWallet); - - expect(onConnectWeb3Wallet).toHaveBeenCalledWith('disconnected'); - expect(onSetPrimaryWeb3Wallet).toHaveBeenCalledWith('secondary'); - expect(onRemoveWeb3Wallet).toHaveBeenCalledWith('secondary'); - }); - - it('shows unverified Web3 wallets without a set-primary action', async () => { - const user = userEvent.setup(); - renderView({ - web3Wallets: [{ id: 'unverified', provider: 'WalletConnect', address: 'short', isVerified: false }], - onSetPrimaryWeb3Wallet: vi.fn(), - onRemoveWeb3Wallet: vi.fn(), - }); - - expect(screen.getByText('short')).toBeInTheDocument(); - expect(screen.getByText('Unverified')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage WalletConnect' })); - expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect Coinbase Wallet' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })).toBeVisible(); }); it('renders safely before profile data is available', () => { diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx new file mode 100644 index 00000000000..d7a1493b3c3 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; + +const wallet = { id: 'wallet_1', provider: 'MetaMask', address: 'test', isVerified: true }; + +function renderWallets(onRemove: (id: string) => void) { + 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}{Enter}'); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + await waitFor(() => expect(trigger).toHaveFocus()); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it('removes the wallet and its dialog when the caller updates the list', async () => { + const user = userEvent.setup(); + function Example() { + const [wallets, setWallets] = useState([wallet]); + return ( + + 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.queryByRole('button', { name: 'Manage MetaMask' })).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('keeps confirmation open while pending and allows retrying an error supplied by the caller', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + const { rerender } = renderWallets(onRemove); + await openRemoval(user); + const dialog = screen.getByRole('alertdialog'); + expect(onRemove).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); + rerender( + + + , + ); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled(); + expect(within(dialog).getByRole('progressbar')).toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledTimes(1); + rerender( + + + , + ); + expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove wallet'); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledTimes(2); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx new file mode 100644 index 00000000000..a9aa62fdccb --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx @@ -0,0 +1,131 @@ +import { render, screen, within } 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'); + expect(dialog).toHaveTextContent(address); + const warning = within(dialog).queryByText('You will no longer be able to sign in using this web3 wallet.'); + if (isVerified) { + expect(warning).toBeVisible(); + } else { + expect(warning).not.toBeInTheDocument(); + } + }, + ); + + 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/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx index 774c0681390..508a79e67ba 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx @@ -13,7 +13,7 @@ import type { UserProfileConnectedAccount } from './user-profile-connected-accou import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; import { styles } from './user-profile-profile-panel.styles'; -import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet }; @@ -26,11 +26,11 @@ export type { UserProfileEditNameValue } from './user-profile-account-section/us export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { connectedAccounts?: UserProfileConnectedAccount[]; web3Wallets?: UserProfileWeb3Wallet[]; + availableWeb3Providers?: UserProfileWeb3Provider[]; onConnectAccount?: (id: string) => void; onManageConnectedAccount?: (id: string) => void; onRemoveConnectedAccount?: (id: string) => void; onConnectWeb3Wallet?: (id: string) => void; - onManageWeb3Wallet?: (id: string) => void; onSetPrimaryWeb3Wallet?: (id: string) => void; onRemoveWeb3Wallet?: (id: string) => void; /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ @@ -51,6 +51,7 @@ export function UserProfileProfilePanelView({ phones = [], connectedAccounts = [], web3Wallets = [], + availableWeb3Providers = [], onProfilePictureChange, onProfilePictureReject, onRemoveProfilePicture, @@ -73,7 +74,6 @@ export function UserProfileProfilePanelView({ onManageConnectedAccount, onRemoveConnectedAccount, onConnectWeb3Wallet, - onManageWeb3Wallet, onSetPrimaryWeb3Wallet, onRemoveWeb3Wallet, onDeleteAccount, @@ -121,11 +121,11 @@ export function UserProfileProfilePanelView({ onRemove={onRemoveConnectedAccount} /> ) : null} - {web3Wallets.length > 0 ? ( + {web3Wallets.length > 0 || (availableWeb3Providers.length > 0 && onConnectWeb3Wallet) ? ( diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx new file mode 100644 index 00000000000..f26fcb4ae3b --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx @@ -0,0 +1,67 @@ +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Dialog } from '../../components/dialog'; +import { Heading } from '../../components/heading'; +import { Text } from '../../components/text'; +import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; + +export interface UserProfileRemoveWeb3WalletDialogProps { + address: string; + isVerified: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isPending?: boolean; + errorMessage?: string; +} + +export function UserProfileRemoveWeb3WalletDialog({ + address, + isVerified, + open, + onOpenChange, + onConfirm, + isPending, + errorMessage, +}: UserProfileRemoveWeb3WalletDialogProps) { + return ( + + + }>{m.removeDialog.title} + }> + {m.removeDialog.description.replace('{wallet}', address)} + + {isVerified ? {m.removeDialog.signInWarning} : null} + {errorMessage ? ( + + {errorMessage} + + ) : null} + + } + > + {m.removeDialog.cancel} + + + {m.removeDialog.confirm} + + + + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx new file mode 100644 index 00000000000..ed2f66e9494 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx @@ -0,0 +1,121 @@ +import * as stylex from '@stylexjs/stylex'; +import { useState } from 'react'; + +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Icon, IconFrame } from '../../components/icon'; +import { Section } from '../../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { UserProfileRemoveWeb3WalletDialog } from './user-profile-remove-web3-wallet.dialog'; +import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; +import { styles } from './user-profile-web3-wallets.styles'; +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; + +export function UserProfileWeb3WalletRowView({ + wallet, + onConnect, + onSetPrimary, + onRemove, +}: { + wallet: UserProfileWeb3Wallet | UserProfileWeb3Provider; + onConnect?: (id: string) => void; + onSetPrimary?: (id: string) => void; + onRemove?: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + const iconUrl = wallet.iconUrl?.trim(); + const linkedWallet = 'address' in wallet ? wallet : undefined; + const address = linkedWallet?.address; + const shortAddress = address && (address.length <= 10 ? address : `${address.slice(0, 6)}...${address.slice(-4)}`); + const actions: UserProfileMenuAction[] = []; + + if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary) { + actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) }); + } + if (linkedWallet && onRemove && linkedWallet.canRemove !== false) { + actions.push({ label: m.remove, color: 'negative', onClick: () => setOpen(true) }); + } + + return ( + + + {wallet.provider || iconUrl ? ( + + + {iconUrl ? ( + + ) : ( + + {wallet.provider?.trim().charAt(0).toUpperCase()} + + )} + + + ) : null} + + + {wallet.provider || shortAddress} + {linkedWallet?.isPrimary ? {m.primary} : null} + {linkedWallet && !linkedWallet.isVerified ? {m.unverified} : null} + + {wallet.provider && address ? ( + + {shortAddress} + + ) : null} + + {onConnect ? ( + + + + ) : actions.length > 0 ? ( + + + {linkedWallet && onRemove && linkedWallet.canRemove !== false ? ( + onRemove(wallet.id)} + isPending={linkedWallet.isRemoving} + errorMessage={linkedWallet.removalError} + /> + ) : null} + + + ) : null} + + {'connectError' in wallet && wallet.connectError ? {wallet.connectError} : null} + {linkedWallet?.primaryError ? {linkedWallet.primaryError} : null} + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx index 6a497c9b982..84d2671f18a 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -1,117 +1,67 @@ -import * as stylex from '@stylexjs/stylex'; - -import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; -import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import type { UserProfileMenuAction } from './user-profile-action-menu'; -import { UserProfileActionMenu } from './user-profile-action-menu'; -import { styles } from './user-profile-profile-panel.styles'; -import { UserProfileProviderIcon } from './user-profile-provider-icon'; +import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; +import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; -export interface UserProfileWeb3Wallet { +export interface UserProfileWeb3Provider { id: string; provider: string; - address?: string; iconUrl?: string; - connected?: boolean; + connectError?: string; +} + +export interface UserProfileWeb3Wallet { + id: string; + address: string; + provider?: string; + iconUrl?: string; isPrimary?: boolean; - isVerified?: boolean; + isVerified: boolean; canRemove?: boolean; + isRemoving?: boolean; + removalError?: string; + primaryError?: string; } export interface UserProfileWeb3WalletsSectionViewProps { wallets: UserProfileWeb3Wallet[]; + availableProviders?: UserProfileWeb3Provider[]; onConnect?: (id: string) => void; - onManage?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void; } -const shortenWeb3Address = (address: string) => { - if (address.length <= 10) { - return address; - } - - return `${address.slice(0, 6)}...${address.slice(-4)}`; -}; - export function UserProfileWeb3WalletsSectionView({ wallets, + availableProviders = [], onConnect, - onManage, onSetPrimary, onRemove, }: UserProfileWeb3WalletsSectionViewProps) { + if (wallets.length === 0 && (availableProviders.length === 0 || !onConnect)) { + return null; + } + return ( - Web3 wallets + {m.title} - {wallets.map(wallet => { - const connected = wallet.connected ?? Boolean(wallet.address); - const actions: UserProfileMenuAction[] = []; - const hasExplicitActions = Boolean(onSetPrimary || onRemove); - - if (!wallet.isPrimary && wallet.isVerified !== false && onSetPrimary) { - actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(wallet.id) }); - } - - if (onRemove && wallet.canRemove !== false) { - actions.push({ label: 'Remove wallet', color: 'negative', onClick: () => onRemove(wallet.id) }); - } - - if (!hasExplicitActions && onManage) { - actions.push({ label: 'Manage', onClick: () => onManage(wallet.id) }); - } - - const address = wallet.address ? shortenWeb3Address(wallet.address) : undefined; - const badges = ( - <> - {wallet.isPrimary ? Primary : null} - {wallet.isVerified === false ? Unverified : null} - - ); - - return ( - - - {wallet.iconUrl ? : null} - - - - {wallet.provider} - {badges} - - - {address ? {address} : null} - - - {connected ? ( - - ) : null} - {!connected && onConnect ? ( - - ) : null} - - - - ); - })} + {wallets.map(wallet => ( + + ))} + {onConnect + ? availableProviders.map(provider => ( + + )) + : null} ); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts new file mode 100644 index 00000000000..3ffe00aae49 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts @@ -0,0 +1,18 @@ +export const userProfileWeb3WalletsMessages = { + title: 'Web3 wallets', + connect: 'Connect', + connectLabel: 'Connect {provider}', + manageLabel: 'Manage {wallet}', + remove: 'Remove wallet', + setPrimary: 'Set as primary', + primary: 'Primary', + unverified: 'Unverified', + removeDialog: { + title: 'Remove wallet?', + description: '{wallet} will be removed from this account.', + signInWarning: 'You will no longer be able to sign in using this web3 wallet.', + confirm: 'Remove', + pending: 'Removing wallet', + cancel: 'Cancel', + }, +}; diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts new file mode 100644 index 00000000000..1cc5a4d1947 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts @@ -0,0 +1,21 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + connectRow: { justifyContent: 'center' }, + icon: { display: 'block', objectFit: 'contain', height: space['6'], width: space['6'] }, + fallback: { + borderRadius: radiusVars['--cl-radius-sm'], + alignItems: 'center', + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, + color: colorVars['--cl-color-neutral'], + display: 'inline-flex', + fontSize: '0.625rem', + justifyContent: 'center', + height: space['6'], + width: space['6'], + }, + label: { gap: space['2'], alignItems: 'center', display: 'flex', flexWrap: 'wrap' }, + text: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }, +});