From b01516e99a241184f4be691d73a0388a08b988fb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 10 Aug 2026 11:24:38 -0600 Subject: [PATCH 1/2] feat(ui): add Web3 wallets section --- .../user-profile-web3-wallets-section.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 9 ++ .../user-profile-web3-wallets-section.mdx | 14 ++ ...r-profile-web3-wallets-section.stories.tsx | 36 +++++ ...user-profile-web3-wallets-section.view.tsx | 128 ++++++++++++++++++ 6 files changed, 190 insertions(+) create mode 100644 .changeset/user-profile-web3-wallets-section.md create mode 100644 packages/swingset/src/stories/user-profile-web3-wallets-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx diff --git a/.changeset/user-profile-web3-wallets-section.md b/.changeset/user-profile-web3-wallets-section.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-web3-wallets-section.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index f0d5300fc66..65395de1a63 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -16,6 +16,7 @@ const docModules: Record> = { 'user-profile-connected-accounts-section': dynamic( () => import('../stories/user-profile-connected-accounts-section.mdx'), ), + 'user-profile-web3wallets-section': dynamic(() => import('../stories/user-profile-web3-wallets-section.mdx')), }, components: { avatar: dynamic(() => import('../stories/avatar.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index b146cf3c56d..0ba56d63898 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -104,6 +104,10 @@ import { Default as UserProfileConnectedAccountsSectionDefault, meta as userProfileConnectedAccountsSectionMeta, } from '../stories/user-profile-connected-accounts-section.stories'; +import { + Default as UserProfileWeb3WalletsSectionDefault, + meta as userProfileWeb3WalletsSectionMeta, +} from '../stories/user-profile-web3-wallets-section.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -220,12 +224,17 @@ const userProfileConnectedAccountsSectionModule: StoryModule = { meta: userProfileConnectedAccountsSectionMeta, Default: UserProfileConnectedAccountsSectionDefault, }; +const userProfileWeb3WalletsSectionModule: StoryModule = { + meta: userProfileWeb3WalletsSectionMeta, + Default: UserProfileWeb3WalletsSectionDefault, +}; export const registry: StoryModule[] = [ // User userButtonModule, userProfileAccountSectionModule, userProfileConnectedAccountsSectionModule, + userProfileWeb3WalletsSectionModule, // Components avatarModule, badgeModule, diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx new file mode 100644 index 00000000000..b38a9c5357f --- /dev/null +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx @@ -0,0 +1,14 @@ +import * as Stories from './user-profile-web3-wallets-section.stories'; + +# UserProfileWeb3WalletsSection + +Web3 wallet identities, verification state, primary status, and wallet actions. + + 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 new file mode 100644 index 00000000000..21964f25d18 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -0,0 +1,36 @@ +import { UserProfileWeb3WalletsSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-web3-wallets-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-web3-wallets-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileWeb3WalletsSection', + source: 'packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx', +}; + +export function Default() { + return ( + undefined} + onManage={() => undefined} + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx new file mode 100644 index 00000000000..82c9b68df22 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx @@ -0,0 +1,128 @@ +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'; + +export interface UserProfileWeb3Wallet { + id: string; + provider: string; + address?: string; + iconUrl?: string; + connected?: boolean; + isPrimary?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} + +export interface UserProfileWeb3WalletsSectionViewProps { + wallets: UserProfileWeb3Wallet[]; + 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, + onConnect, + onManage, + onSetPrimary, + onRemove, +}: UserProfileWeb3WalletsSectionViewProps) { + return ( + + Web3 wallets + + {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} + + + + ); + })} + + + ); +} From 6071fae70b8d50e778d9dbfee2423c4c1ab925ed Mon Sep 17 00:00:00 2001 From: Austin Calvelage Date: Mon, 17 Aug 2026 16:50:40 -0600 Subject: [PATCH 2/2] feat(ui): add user profile delete section (#9385) --- .changeset/user-profile-delete-section.md | 2 + .changeset/user-profile-panel-shell.md | 2 + .../swingset/src/components/DocsViewer.tsx | 2 + packages/swingset/src/lib/registry.ts | 18 ++ .../stories/user-profile-delete-section.mdx | 14 + .../user-profile-delete-section.stories.tsx | 15 + .../stories/user-profile-profile-panel.mdx | 71 +++++ .../user-profile-profile-panel.stories.tsx | 72 +++++ .../user-profile-web3-wallets-section.mdx | 3 +- .../user-profile-profile-panel.view.test.tsx | 287 ++++++++++++++++++ .../user-profile-delete-section.view.tsx | 36 +++ .../user-profile-profile-panel.styles.ts | 8 +- .../user-profile-profile-panel.view.tsx | 114 +++++++ 13 files changed, 641 insertions(+), 3 deletions(-) create mode 100644 .changeset/user-profile-delete-section.md create mode 100644 .changeset/user-profile-panel-shell.md create mode 100644 packages/swingset/src/stories/user-profile-delete-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-delete-section.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-profile-panel.mdx create mode 100644 packages/swingset/src/stories/user-profile-profile-panel.stories.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx diff --git a/.changeset/user-profile-delete-section.md b/.changeset/user-profile-delete-section.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-delete-section.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/user-profile-panel-shell.md b/.changeset/user-profile-panel-shell.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-panel-shell.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 65395de1a63..ff0b0120dae 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -12,11 +12,13 @@ import { ViewSource } from './ViewSource'; const docModules: Record> = { user: { 'user-button': dynamic(() => import('../stories/user-button.mdx')), + 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), 'user-profile-connected-accounts-section': dynamic( () => import('../stories/user-profile-connected-accounts-section.mdx'), ), 'user-profile-web3wallets-section': dynamic(() => import('../stories/user-profile-web3-wallets-section.mdx')), + 'user-profile-delete-section': dynamic(() => import('../stories/user-profile-delete-section.mdx')), }, components: { avatar: dynamic(() => import('../stories/avatar.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 0ba56d63898..db75fcefb1b 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -104,6 +104,14 @@ import { Default as UserProfileConnectedAccountsSectionDefault, meta as userProfileConnectedAccountsSectionMeta, } from '../stories/user-profile-connected-accounts-section.stories'; +import { + Default as UserProfileDeleteSectionDefault, + meta as userProfileDeleteSectionMeta, +} from '../stories/user-profile-delete-section.stories'; +import { + Default as UserProfileProfilePanelDefault, + meta as userProfileProfilePanelMeta, +} from '../stories/user-profile-profile-panel.stories'; import { Default as UserProfileWeb3WalletsSectionDefault, meta as userProfileWeb3WalletsSectionMeta, @@ -220,6 +228,10 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, }; +const userProfileProfilePanelModule: StoryModule = { + meta: userProfileProfilePanelMeta, + Default: UserProfileProfilePanelDefault, +}; const userProfileConnectedAccountsSectionModule: StoryModule = { meta: userProfileConnectedAccountsSectionMeta, Default: UserProfileConnectedAccountsSectionDefault, @@ -228,13 +240,19 @@ const userProfileWeb3WalletsSectionModule: StoryModule = { meta: userProfileWeb3WalletsSectionMeta, Default: UserProfileWeb3WalletsSectionDefault, }; +const userProfileDeleteSectionModule: StoryModule = { + meta: userProfileDeleteSectionMeta, + Default: UserProfileDeleteSectionDefault, +}; export const registry: StoryModule[] = [ // User userButtonModule, + userProfileProfilePanelModule, userProfileAccountSectionModule, userProfileConnectedAccountsSectionModule, userProfileWeb3WalletsSectionModule, + userProfileDeleteSectionModule, // Components avatarModule, badgeModule, diff --git a/packages/swingset/src/stories/user-profile-delete-section.mdx b/packages/swingset/src/stories/user-profile-delete-section.mdx new file mode 100644 index 00000000000..e6ca07a140b --- /dev/null +++ b/packages/swingset/src/stories/user-profile-delete-section.mdx @@ -0,0 +1,14 @@ +import * as Stories from './user-profile-delete-section.stories'; + +# UserProfileDeleteSection + +The terminal destructive action for deleting the current user account. + + diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx new file mode 100644 index 00000000000..9c316bf4ed8 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -0,0 +1,15 @@ +import { UserProfileDeleteSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-delete-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-delete-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileDeleteSection', + source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx', +}; + +export function Default() { + return undefined} />; +} diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx new file mode 100644 index 00000000000..62fefa81847 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -0,0 +1,71 @@ +import * as UserProfileProfilePanelStories from './user-profile-profile-panel.stories'; + +# UserProfileProfilePanel + +The Profile panel from UserProfile, composed without the surrounding navigation shell. The +presentational view uses `Section` for its account details, connected accounts, Web3 wallets, and danger zone +while preserving the existing resource props and callback seams. + +## Example + + + +## Usage + +```tsx +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; + + ({ + id: email.id, + value: email.emailAddress, + isDefault: email.id === user.primaryEmailAddressId, + isVerified: email.verification.status === 'verified', + }))} + phones={user.phoneNumbers.map(phone => ({ + id: phone.id, + value: phone.phoneNumber, + isDefault: phone.id === user.primaryPhoneNumberId, + isVerified: phone.verification.status === 'verified', + }))} + connectedAccounts={user.externalAccounts.map(account => ({ + id: account.id, + provider: account.provider, + identifier: account.emailAddress, + connected: true, + }))} + web3Wallets={supportedWeb3Wallets.map(provider => { + const wallet = user.web3Wallets.find(wallet => wallet.provider === provider.id); + + return { + id: provider.id, + provider: provider.name, + iconUrl: provider.iconUrl, + address: wallet?.web3Wallet, + connected: Boolean(wallet), + isPrimary: wallet?.id === user.primaryWeb3WalletId, + isVerified: wallet ? wallet.verification.status === 'verified' : undefined, + }; + })} + onVerifyEmail={verifyEmail} + onSetPrimaryEmail={setPrimaryEmail} + onRemoveEmail={removeEmail} + onVerifyPhone={verifyPhone} + onSetPrimaryPhone={setPrimaryPhone} + onRemovePhone={removePhone} + onRemoveConnectedAccount={removeConnectedAccount} + onConnectWeb3Wallet={connectWeb3Wallet} + onSetPrimaryWeb3Wallet={setPrimaryWeb3Wallet} + onRemoveWeb3Wallet={removeWeb3Wallet} +/> +``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx new file mode 100644 index 00000000000..b69cbefa42c --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -0,0 +1,72 @@ +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; +const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; + +export { default as __source } from './user-profile-profile-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileProfilePanel', + source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx', +}; + +export function Default(_args: Record) { + return ( + undefined} + onAddPhone={() => undefined} + onConnectAccount={() => undefined} + onDeleteAccount={() => undefined} + onEditProfilePicture={() => undefined} + onRemoveConnectedAccount={() => undefined} + onRemoveEmail={() => undefined} + onRemovePhone={() => undefined} + onConnectWeb3Wallet={() => undefined} + onRemoveWeb3Wallet={() => undefined} + onSetPrimaryWeb3Wallet={() => undefined} + onSetPrimaryEmail={() => undefined} + onSetPrimaryPhone={() => undefined} + onVerifyEmail={() => undefined} + onVerifyPhone={() => undefined} + onNameChange={() => undefined} + onUsernameChange={() => undefined} + /> + ); +} 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 b38a9c5357f..c6ab12be20f 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx @@ -2,7 +2,8 @@ import * as Stories from './user-profile-web3-wallets-section.stories'; # UserProfileWeb3WalletsSection -Web3 wallet identities, verification state, primary status, and wallet actions. +Supported Web3 wallet providers with per-provider connection actions, connected identities, verification state, +primary status, and wallet actions. = {}) { + return render( + + + , + ); +} + +describe('UserProfileProfilePanelView', () => { + it('composes the profile content without profile navigation', () => { + renderView({ onEditProfilePicture: vi.fn(), onNameChange: vi.fn(), onUsernameChange: vi.fn() }); + + expect(screen.queryByRole('heading', { name: 'Account' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Account' })).toContainElement( + document.querySelector('.cl-section-group'), + ); + expect(screen.getByText('Name')).toHaveClass('cl-section-label'); + expect(screen.getByText('Username')).toHaveClass('cl-section-label'); + expect(screen.getByText('Preston Booth')).toHaveClass('cl-section-description'); + expect(screen.getByText('prestonxyz')).toHaveClass('cl-section-description'); + expect(screen.getByRole('button', { name: 'Edit name' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Edit username' })).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); + expect(within(screen.getByRole('region', { name: 'Email' })).getByText('Primary')).toBeInTheDocument(); + expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); + expect(screen.getByText('Profile picture')).toHaveClass('cl-section-label'); + expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toHaveClass('cl-section-description'); + expect(screen.getByText('Email')).toHaveClass('cl-section-label'); + expect(screen.getByText('Phone')).toHaveClass('cl-section-label'); + expect(screen.getByText('item1@clerk.dev').closest('.cl-section-description')).not.toBeNull(); + expect(screen.getByRole('button', { name: 'Edit profile picture' })).toBeInTheDocument(); + const profilePicture = screen.getByText('Profile picture').closest('.cl-section-item'); + expect(profilePicture?.querySelector('.cl-section-media')).toHaveAttribute('data-size', 'lg'); + expect(profilePicture?.querySelector('.cl-avatar')).toHaveAttribute('data-size', 'fit'); + expect(screen.queryByRole('tab')).toBeNull(); + expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); + }); + + it('edits the profile picture when the avatar is clicked', async () => { + const onEditProfilePicture = vi.fn(); + const user = userEvent.setup(); + renderView({ onEditProfilePicture }); + + await user.click(screen.getByRole('button', { name: 'Edit profile picture' })); + + expect(onEditProfilePicture).toHaveBeenCalledOnce(); + }); + + it('renders connected accounts and the danger zone when provided', async () => { + const onConnectAccount = vi.fn(); + const onManageConnectedAccount = vi.fn(); + const onDeleteAccount = vi.fn(); + const user = userEvent.setup(); + renderView({ + connectedAccounts: [ + { id: 'google', provider: 'Google', identifier: 'test@google.com' }, + { id: 'apple', provider: 'Apple', connected: false }, + ], + onConnectAccount, + onManageConnectedAccount, + onDeleteAccount, + }); + + expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); + expect(screen.getByText('Permanently delete this profile and all its data. This cannot be undone.')).toHaveClass( + 'cl-section-description', + ); + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + expect(onManageConnectedAccount).not.toHaveBeenCalled(); + await user.click(screen.getByRole('menuitem', { name: 'Manage' })); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + + expect(onManageConnectedAccount).toHaveBeenCalledWith('google'); + expect(onConnectAccount).toHaveBeenCalledWith('apple'); + expect(onDeleteAccount).toHaveBeenCalledOnce(); + }); + + 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(); + renderView({ + web3Wallets: [ + { + id: 'primary', + address: '0x1234567890abcdef1234567890abcdef12345678', + provider: 'MetaMask', + isPrimary: true, + isVerified: true, + }, + { + id: 'secondary', + address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + provider: 'Coinbase Wallet', + isVerified: true, + }, + { + id: 'disconnected', + provider: 'Coinbase Wallet', + connected: false, + }, + ], + onConnectWeb3Wallet, + onSetPrimaryWeb3Wallet, + onRemoveWeb3Wallet, + }); + + expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument(); + expect(screen.getByText('MetaMask')).toBeInTheDocument(); + 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(); + }); + + it('renders safely before profile data is available', () => { + render( + + + , + ); + + expect(screen.getByRole('region', { name: 'Account' })).toBeInTheDocument(); + }); + + it('forwards profile and contact actions', async () => { + const onNameChange = vi.fn(); + const onAddEmail = vi.fn(); + const onManageEmail = vi.fn(); + renderView({ onNameChange, onAddEmail, onManageEmail }); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Edit name' })); + await user.click(within(screen.getByRole('region', { name: 'Email' })).getByRole('button', { name: 'Add email' })); + await user.click(screen.getByRole('button', { name: 'Manage item2@clerk.dev' })); + expect(onManageEmail).not.toHaveBeenCalled(); + await user.click(screen.getByRole('menuitem', { name: 'Manage' })); + + expect(onNameChange).toHaveBeenCalledWith('Preston Booth'); + expect(onAddEmail).toHaveBeenCalledOnce(); + expect(onManageEmail).toHaveBeenCalledWith('email_2'); + }); + + it('matches the existing conditional contact and connected-account actions', async () => { + const onVerifyEmail = vi.fn(); + const onSetPrimaryEmail = vi.fn(); + const onRemoveEmail = vi.fn(); + const onVerifyPhone = vi.fn(); + const onSetPrimaryPhone = vi.fn(); + const onRemovePhone = vi.fn(); + const onRemoveConnectedAccount = vi.fn(); + const user = userEvent.setup(); + + renderView({ + emails: [ + { id: 'email_primary', value: 'primary@clerk.dev', isDefault: true, isVerified: false }, + { id: 'email_secondary', value: 'secondary@clerk.dev', isVerified: true }, + { id: 'email_unverified', value: 'unverified@clerk.dev', isVerified: false }, + ], + phones: [ + { id: 'phone_unverified', value: '+1 801-555-0100', isVerified: false }, + { id: 'phone_secondary', value: '+1 801-555-0101', isVerified: true }, + ], + connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz' }], + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, + onRemoveConnectedAccount, + }); + + await user.click(screen.getByRole('button', { name: 'Manage primary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Complete verification' })); + expect(onVerifyEmail).toHaveBeenCalledWith('email_primary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + const removeEmail = screen.getByRole('menuitem', { name: 'Remove email' }); + expect(removeEmail).toHaveAttribute('data-color', 'negative'); + await user.click(removeEmail); + expect(onRemoveEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage unverified@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify' })); + expect(onVerifyEmail).toHaveBeenCalledWith('email_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify phone number' })); + expect(onVerifyPhone).toHaveBeenCalledWith('phone_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(onRemovePhone).toHaveBeenCalledWith('phone_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0101' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage GitHub' })); + const removeConnectedAccount = screen.getByRole('menuitem', { name: 'Remove' }); + expect(removeConnectedAccount).toHaveAttribute('data-color', 'negative'); + await user.click(removeConnectedAccount); + expect(onRemoveConnectedAccount).toHaveBeenCalledWith('github'); + }); + + it('hides action triggers when immutable items have no available actions', () => { + renderView({ + emails: [ + { + id: 'email_immutable', + value: 'immutable@clerk.dev', + isDefault: true, + isVerified: true, + canRemove: false, + }, + ], + phones: [], + connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz', canRemove: false }], + onVerifyEmail: vi.fn(), + onSetPrimaryEmail: vi.fn(), + onRemoveEmail: vi.fn(), + onRemoveConnectedAccount: vi.fn(), + }); + + expect(screen.queryByRole('button', { name: 'Manage immutable@clerk.dev' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage GitHub' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx new file mode 100644 index 00000000000..ba2e5cb5b6b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx @@ -0,0 +1,36 @@ +import { Button } from '../components/button'; +import { Section } from '../components/section'; + +export interface UserProfileDeleteSectionViewProps { + onDelete: () => void; +} + +export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { + return ( + + Danger zone + + + + + Delete account + + Permanently delete this profile and all its data. This cannot be undone. + + + + + + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 1d22d895791..1f97dce5168 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -22,9 +22,13 @@ export const styles = stylex.create({ width: space['5'], }, root: { - gap: space['6'], + gap: space['4'], + display: 'flex', + flexDirection: 'column', + }, + sections: { + gap: space['8'], display: 'flex', flexDirection: 'column', - width: '100%', }, }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx new file mode 100644 index 00000000000..79139febcdf --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -0,0 +1,114 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import type { + UserProfileAccountSectionViewProps, + UserProfileEmail, + UserProfilePhone, +} from './user-profile-account-section.view'; +import { UserProfileAccountSectionView } from './user-profile-account-section.view'; +import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view'; +import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; +import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; +import { styles } from './user-profile-profile-panel.styles'; +import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; + +export type { UserProfileEmail, UserProfilePhone, UserProfileConnectedAccount, UserProfileWeb3Wallet }; + +export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { + connectedAccounts?: UserProfileConnectedAccount[]; + web3Wallets?: UserProfileWeb3Wallet[]; + 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; + onDeleteAccount?: () => void; +} + +export function UserProfileProfilePanelView({ + imageUrl, + name = '', + username = '', + emails = [], + phones = [], + connectedAccounts = [], + web3Wallets = [], + onEditProfilePicture, + onNameChange, + onUsernameChange, + onAddEmail, + onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onAddPhone, + onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, + onConnectAccount, + onManageConnectedAccount, + onRemoveConnectedAccount, + onConnectWeb3Wallet, + onManageWeb3Wallet, + onSetPrimaryWeb3Wallet, + onRemoveWeb3Wallet, + onDeleteAccount, +}: UserProfileProfilePanelViewProps): ReactElement { + return ( +
+

} + size='2xl' + > + Profile + +
+ + {connectedAccounts.length > 0 ? ( + + ) : null} + {web3Wallets.length > 0 ? ( + + ) : null} + {onDeleteAccount ? : null} +
+

+ ); +}