Skip to content

Commit 5832e60

Browse files
feat(ui): add web3 wallets to user profile
1 parent 3781cbf commit 5832e60

6 files changed

Lines changed: 254 additions & 4 deletions

File tree

.changeset/mosaic-user-profile-profile-panel.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@clerk/ui': patch
33
---
44

5-
Add an outlined Card elevation for bordered, non-elevated surfaces and make Item groups layout-only.
5+
Add an outlined Card elevation for bordered, non-elevated surfaces, make Item groups layout-only, and expose Web3 wallets in the Mosaic user profile panel.

packages/swingset/src/stories/user-profile-profile-panel.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as UserProfileProfilePanelStories from './user-profile-profile-panel.st
33
# UserProfileProfilePanel
44

55
The Profile panel from UserProfile, composed without the surrounding navigation shell. The
6-
presentational view renders account details, connected accounts, and the danger zone from props
6+
presentational view renders account details, connected accounts, Web3 wallets, and the danger zone from props
77
while its callbacks provide the seam for connected Clerk resources.
88

99
## Example
@@ -40,6 +40,12 @@ import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-
4040
identifier: account.emailAddress,
4141
connected: true,
4242
}))}
43+
web3Wallets={user.web3Wallets.map(wallet => ({
44+
id: wallet.id,
45+
address: wallet.web3Wallet,
46+
isPrimary: wallet.id === user.primaryWeb3WalletId,
47+
isVerified: wallet.verification.status === 'verified',
48+
}))}
4349
onNameChange={setName}
4450
onUsernameChange={setUsername}
4551
onVerifyEmail={verifyEmail}
@@ -49,5 +55,8 @@ import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-
4955
onSetPrimaryPhone={setPrimaryPhone}
5056
onRemovePhone={removePhone}
5157
onRemoveConnectedAccount={removeConnectedAccount}
58+
onAddWeb3Wallet={addWeb3Wallet}
59+
onSetPrimaryWeb3Wallet={setPrimaryWeb3Wallet}
60+
onRemoveWeb3Wallet={removeWeb3Wallet}
5261
/>
5362
```

packages/swingset/src/stories/user-profile-profile-panel.stories.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ export function Default(_args: Record<string, unknown>) {
3535
},
3636
{ id: 'apple', provider: 'Apple', iconUrl: providerIconUrl('apple'), connected: false },
3737
]}
38+
web3Wallets={[
39+
{
40+
id: 'metamask',
41+
address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
42+
provider: 'MetaMask',
43+
iconUrl: providerIconUrl('metamask'),
44+
isPrimary: true,
45+
isVerified: true,
46+
},
47+
]}
3848
imageUrl={profileImageUrl}
3949
name={name}
4050
phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]}
@@ -47,6 +57,9 @@ export function Default(_args: Record<string, unknown>) {
4757
onRemoveConnectedAccount={() => undefined}
4858
onRemoveEmail={() => undefined}
4959
onRemovePhone={() => undefined}
60+
onAddWeb3Wallet={() => undefined}
61+
onRemoveWeb3Wallet={() => undefined}
62+
onSetPrimaryWeb3Wallet={() => undefined}
5063
onSetPrimaryEmail={() => undefined}
5164
onSetPrimaryPhone={() => undefined}
5265
onVerifyEmail={() => undefined}

packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,65 @@ describe('UserProfileProfilePanelView', () => {
102102
expect(onDeleteAccount).toHaveBeenCalledOnce();
103103
});
104104

105+
it('renders Web3 wallets and forwards wallet actions', async () => {
106+
const onAddWeb3Wallet = vi.fn();
107+
const onSetPrimaryWeb3Wallet = vi.fn();
108+
const onRemoveWeb3Wallet = vi.fn();
109+
const user = userEvent.setup();
110+
renderView({
111+
web3Wallets: [
112+
{
113+
id: 'primary',
114+
address: '0x1234567890abcdef1234567890abcdef12345678',
115+
provider: 'MetaMask',
116+
isPrimary: true,
117+
isVerified: true,
118+
},
119+
{
120+
id: 'secondary',
121+
address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
122+
provider: 'Coinbase Wallet',
123+
isVerified: true,
124+
},
125+
],
126+
onAddWeb3Wallet,
127+
onSetPrimaryWeb3Wallet,
128+
onRemoveWeb3Wallet,
129+
});
130+
131+
expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument();
132+
expect(screen.getByText('MetaMask')).toBeInTheDocument();
133+
expect(screen.getByText('0x1234...5678')).toBeInTheDocument();
134+
expect(screen.getByText('Primary')).toBeInTheDocument();
135+
136+
await user.click(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByRole('button', { name: 'Add' }));
137+
await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' }));
138+
await user.click(screen.getByRole('menuitem', { name: 'Set as primary' }));
139+
await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' }));
140+
const removeWallet = screen.getByRole('menuitem', { name: 'Remove wallet' });
141+
expect(removeWallet).toHaveAttribute('data-color', 'negative');
142+
await user.click(removeWallet);
143+
144+
expect(onAddWeb3Wallet).toHaveBeenCalledOnce();
145+
expect(onSetPrimaryWeb3Wallet).toHaveBeenCalledWith('secondary');
146+
expect(onRemoveWeb3Wallet).toHaveBeenCalledWith('secondary');
147+
});
148+
149+
it('shows unverified Web3 wallets without a set-primary action', async () => {
150+
const user = userEvent.setup();
151+
renderView({
152+
web3Wallets: [{ id: 'unverified', address: 'short', isVerified: false }],
153+
onSetPrimaryWeb3Wallet: vi.fn(),
154+
onRemoveWeb3Wallet: vi.fn(),
155+
});
156+
157+
expect(screen.getByText('short')).toBeInTheDocument();
158+
expect(screen.getByText('Unverified')).toBeInTheDocument();
159+
await user.click(screen.getByRole('button', { name: 'Manage short' }));
160+
expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument();
161+
expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeInTheDocument();
162+
});
163+
105164
it('renders safely before profile data is available', () => {
106165
render(
107166
<MosaicProvider>

packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const styles = stylex.create({
4848
display: 'flex',
4949
minWidth: 0,
5050
},
51-
connectedAccountsList: {
51+
resourceList: {
5252
padding: 0,
5353
},
5454
dangerContent: {
@@ -87,4 +87,10 @@ export const styles = stylex.create({
8787
display: 'flex',
8888
flexDirection: 'column',
8989
},
90+
sectionHeader: {
91+
gap: space['3'],
92+
alignItems: 'center',
93+
display: 'flex',
94+
justifyContent: 'space-between',
95+
},
9096
});

packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,24 @@ export interface UserProfileConnectedAccount {
4242
canRemove?: boolean;
4343
}
4444

45+
export interface UserProfileWeb3Wallet {
46+
id: string;
47+
address: string;
48+
provider?: string;
49+
iconUrl?: string;
50+
isPrimary?: boolean;
51+
isVerified?: boolean;
52+
canRemove?: boolean;
53+
}
54+
4555
export interface UserProfileProfilePanelViewProps {
4656
imageUrl?: string;
4757
name: string;
4858
username: string;
4959
emails: UserProfileEmail[];
5060
phones: UserProfilePhone[];
5161
connectedAccounts?: UserProfileConnectedAccount[];
62+
web3Wallets?: UserProfileWeb3Wallet[];
5263
onEditProfilePicture?: () => void;
5364
onNameChange?: (value: string) => void;
5465
onUsernameChange?: (value: string) => void;
@@ -65,6 +76,10 @@ export interface UserProfileProfilePanelViewProps {
6576
onConnectAccount?: (id: string) => void;
6677
onManageConnectedAccount?: (id: string) => void;
6778
onRemoveConnectedAccount?: (id: string) => void;
79+
onAddWeb3Wallet?: () => void;
80+
onManageWeb3Wallet?: (id: string) => void;
81+
onSetPrimaryWeb3Wallet?: (id: string) => void;
82+
onRemoveWeb3Wallet?: (id: string) => void;
6883
onDeleteAccount?: () => void;
6984
}
7085

@@ -290,7 +305,7 @@ function ConnectedAccountsSection({
290305
</Heading>
291306
<Card.Root elevation='outlined'>
292307
<Card.Content style={{ paddingBlock: space['4'], paddingInline: space['4'] }}>
293-
<Item.Group {...stylex.props(styles.connectedAccountsList)}>
308+
<Item.Group {...stylex.props(styles.resourceList)}>
294309
{accounts.map((account, index) => {
295310
const connected = account.connected ?? Boolean(account.identifier);
296311
const actions: ProfileMenuAction[] = [];
@@ -375,6 +390,140 @@ function ConnectedAccountsSection({
375390
);
376391
}
377392

393+
const shortenWeb3Address = (address: string) => {
394+
if (address.length <= 10) {
395+
return address;
396+
}
397+
398+
return `${address.slice(0, 6)}...${address.slice(-4)}`;
399+
};
400+
401+
function Web3WalletsSection({
402+
wallets,
403+
onAdd,
404+
onManage,
405+
onSetPrimary,
406+
onRemove,
407+
}: {
408+
wallets: UserProfileWeb3Wallet[];
409+
onAdd?: () => void;
410+
onManage?: (id: string) => void;
411+
onSetPrimary?: (id: string) => void;
412+
onRemove?: (id: string) => void;
413+
}) {
414+
return (
415+
<section
416+
aria-labelledby='user-profile-profile-panel-web3-wallets'
417+
{...stylex.props(styles.section)}
418+
>
419+
<div {...stylex.props(styles.sectionHeader)}>
420+
<Heading
421+
id='user-profile-profile-panel-web3-wallets'
422+
render={props => <h4 {...props} />}
423+
size='xs'
424+
>
425+
Web3 wallets
426+
</Heading>
427+
{onAdd ? (
428+
<Button
429+
color='neutral'
430+
size='sm'
431+
variant='outline'
432+
onClick={onAdd}
433+
>
434+
Add
435+
</Button>
436+
) : null}
437+
</div>
438+
<Card.Root elevation='outlined'>
439+
<Card.Content style={{ paddingBlock: space['4'], paddingInline: space['4'] }}>
440+
<Item.Group {...stylex.props(styles.resourceList)}>
441+
{wallets.map((wallet, index) => {
442+
const actions: ProfileMenuAction[] = [];
443+
const hasExplicitActions = Boolean(onSetPrimary || onRemove);
444+
445+
if (!wallet.isPrimary && wallet.isVerified !== false && onSetPrimary) {
446+
actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(wallet.id) });
447+
}
448+
449+
if (onRemove && wallet.canRemove !== false) {
450+
actions.push({ label: 'Remove wallet', color: 'negative', onClick: () => onRemove(wallet.id) });
451+
}
452+
453+
if (!hasExplicitActions && onManage) {
454+
actions.push({ label: 'Manage', onClick: () => onManage(wallet.id) });
455+
}
456+
457+
const address = shortenWeb3Address(wallet.address);
458+
459+
return (
460+
<Fragment key={wallet.id}>
461+
{index > 0 ? (
462+
<Item.Separator
463+
style={{
464+
backgroundColor: colorVars['--cl-color-border'],
465+
marginBlock: space['4'],
466+
marginInline: space['4'],
467+
width: 'auto',
468+
}}
469+
/>
470+
) : null}
471+
<Item.Root
472+
size='md'
473+
style={{ height: 'auto', paddingBlock: 0, paddingInline: 0 }}
474+
>
475+
{wallet.iconUrl ? (
476+
<Item.Media style={{ width: space['6'] }}>
477+
<img
478+
alt=''
479+
src={wallet.iconUrl}
480+
{...stylex.props(styles.providerIcon)}
481+
/>
482+
</Item.Media>
483+
) : null}
484+
<Item.Content style={{ gap: space['0.5'] }}>
485+
<div {...stylex.props(styles.contactValue)}>
486+
<Item.Title>{wallet.provider ?? address}</Item.Title>
487+
{wallet.isPrimary ? (
488+
<Badge
489+
color='neutral'
490+
style={{ backgroundColor: 'rgba(23, 23, 23, 0.06)', color: 'inherit' }}
491+
>
492+
Primary
493+
</Badge>
494+
) : null}
495+
{wallet.isVerified === false ? <Badge color='warning'>Unverified</Badge> : null}
496+
</div>
497+
{wallet.provider ? (
498+
<Item.Description
499+
style={{
500+
fontSize: typeScaleVars['--cl-text-sm-size'],
501+
lineHeight: typeScaleVars['--cl-text-sm-leading'],
502+
}}
503+
>
504+
{address}
505+
</Item.Description>
506+
) : null}
507+
</Item.Content>
508+
{actions.length > 0 ? (
509+
<Item.Actions>
510+
<ProfileActionMenu
511+
actions={actions}
512+
label={`Manage ${wallet.provider ?? address}`}
513+
/>
514+
</Item.Actions>
515+
) : null}
516+
</Item.Root>
517+
</Fragment>
518+
);
519+
})}
520+
</Item.Group>
521+
</Card.Content>
522+
</Card.Root>
523+
</section>
524+
);
525+
}
526+
378527
function DangerZoneSection({ onDelete }: { onDelete: () => void }) {
379528
return (
380529
<section
@@ -536,6 +685,7 @@ export function UserProfileProfilePanelView({
536685
emails = [],
537686
phones = [],
538687
connectedAccounts = [],
688+
web3Wallets = [],
539689
onEditProfilePicture,
540690
onNameChange,
541691
onUsernameChange,
@@ -552,6 +702,10 @@ export function UserProfileProfilePanelView({
552702
onConnectAccount,
553703
onManageConnectedAccount,
554704
onRemoveConnectedAccount,
705+
onAddWeb3Wallet,
706+
onManageWeb3Wallet,
707+
onSetPrimaryWeb3Wallet,
708+
onRemoveWeb3Wallet,
555709
onDeleteAccount,
556710
}: UserProfileProfilePanelViewProps): ReactElement {
557711
return (
@@ -590,6 +744,15 @@ export function UserProfileProfilePanelView({
590744
onRemove={onRemoveConnectedAccount}
591745
/>
592746
) : null}
747+
{web3Wallets.length > 0 || onAddWeb3Wallet ? (
748+
<Web3WalletsSection
749+
wallets={web3Wallets}
750+
onAdd={onAddWeb3Wallet}
751+
onManage={onManageWeb3Wallet}
752+
onRemove={onRemoveWeb3Wallet}
753+
onSetPrimary={onSetPrimaryWeb3Wallet}
754+
/>
755+
) : null}
593756
{onDeleteAccount ? <DangerZoneSection onDelete={onDeleteAccount} /> : null}
594757
</div>
595758
);

0 commit comments

Comments
 (0)