Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d4fd2e0
feat(mosaic): wire up user profile Web3 wallets
austincalvelage Sep 28, 2026
4f3cd18
fix(mosaic): present reverification for wallet actions
austincalvelage Sep 28, 2026
cb9d3cd
test(mosaic): cover selected Solana wallet loading
austincalvelage Sep 28, 2026
e3a2d7b
fix(mosaic): show loading on the selected Solana wallet
austincalvelage Sep 28, 2026
f445ecb
fix(mosaic): hide wallets when Web3 is disabled
austincalvelage Sep 28, 2026
01634ea
fix(mosaic): align Web3 reverification with controller status
austincalvelage Sep 29, 2026
74df3d9
refactor(mosaic): remove Web3 reverification flow
austincalvelage Sep 29, 2026
45de813
test(mosaic): cover wallet behavior through real Clerk
austincalvelage Sep 29, 2026
17aedb6
test(mosaic): replace remaining wallet view tests with feature coverage
austincalvelage Sep 29, 2026
fbc81a3
fix(mosaic): localize Web3 wallet action errors
austincalvelage Sep 29, 2026
3a4cf8f
chore(mosaic): note Web3 error localization follow-up
austincalvelage Sep 30, 2026
5089b19
chore(mosaic): mark session reverification as deferred
austincalvelage Sep 30, 2026
151601c
test(mosaic): align Web3 verification fixtures with main
austincalvelage Oct 1, 2026
c7a8d69
test(mosaic): record shared Solana follow-up
austincalvelage Oct 2, 2026
96f9319
fix(mosaic): align Web3 imports with main
austincalvelage Oct 2, 2026
7113a28
chore(mosaic): keep one empty Web3 changeset
austincalvelage Oct 2, 2026
578c139
test(mosaic): cover unrelated fake FAPI user updates
austincalvelage Oct 2, 2026
b2eb78f
fix(mosaic): address Web3 wallet review feedback
austincalvelage Oct 2, 2026
f18c71a
test(mosaic): cover wallet retries and immutable settings
austincalvelage Oct 3, 2026
9971a09
test(mosaic): mirror primary-wallet updates in fake FAPI
austincalvelage Oct 3, 2026
3f08f59
fix(mosaic): recover wallet connections and respect immutable settings
austincalvelage Oct 3, 2026
81579bf
test(mosaic): replace wallet hook tests with connected coverage
austincalvelage Oct 3, 2026
55c0bc7
test(mosaic): focus Solana selection through user interaction
austincalvelage Oct 3, 2026
c477331
fix(swingset): align wired Web3 stories with live navigation
austincalvelage Oct 3, 2026
4f5b73c
fix(ui): keep profile helpers outside initial shared entries
austincalvelage Oct 3, 2026
7cb8aca
fix(ui): retain deferred wallet registry chunks
austincalvelage Oct 3, 2026
f5eca43
refactor(mosaic): route wallet removal through controller
austincalvelage Oct 6, 2026
fa47957
fix(mosaic): preserve Web3 wallet error details for localization
austincalvelage Oct 6, 2026
4464682
chore(mosaic): format keyed pending actions
austincalvelage Oct 6, 2026
821688e
chore: keep one empty changeset
austincalvelage Oct 6, 2026
02f0bb1
test(mosaic): use Node 20 compatible deferred promise
austincalvelage Oct 6, 2026
050061e
refactor(mosaic): use keyed pending actions for Web3
austincalvelage Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/web3-wallets-wire-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 2 additions & 1 deletion packages/mosaic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
117 changes: 117 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
SessionJSON,
UserJSON,
UserOrganizationInvitationJSON,
Web3WalletJSON,
} from '@clerk/shared/types';
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { setupWorker } from 'msw/browser';
Expand All @@ -31,6 +32,7 @@ import {
fapiPage,
fapiToken,
fapiVerification,
fapiWeb3Wallet,
} from './fapi';

export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k';
Expand Down Expand Up @@ -137,6 +139,20 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): void
};
}

function findWeb3Wallet(state: FakeFapiState, id: unknown): Web3WalletJSON | undefined {
return activeUser(state)?.web3_wallets.find(wallet => wallet.id === id);
}

function updateWeb3Wallet(state: FakeFapiState, wallet: Web3WalletJSON): void {
const user = activeUser(state);
if (user) {
updateUser(state, {
...user,
web3_wallets: user.web3_wallets.map(current => (current.id === wallet.id ? wallet : current)),
});
}
}

function missing() {
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
}
Expand Down Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion packages/mosaic/src/__tests__/feature/fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,20 @@ import type {
UserOrganizationInvitationJSON,
UserSettingsJSON,
VerificationJSON,
Web3Strategy,
Web3WalletJSON,
} from '@clerk/shared/types';

type Settings<T> = Omit<T, 'id' | 'object'>;

export type FapiUserSettings = Omit<Settings<UserSettingsJSON>, 'social'> & { social: Partial<OAuthProviders> };
type FapiWeb3Attribute = Omit<AttributeDataJSON, 'first_factors'> & {
first_factors: Array<AttributeDataJSON['first_factors'][number] | Web3Strategy>;
};

export type FapiUserSettings = Omit<Settings<UserSettingsJSON>, 'social' | 'attributes'> & {
social: Partial<OAuthProviders>;
attributes: Omit<AttributesJSON, 'web3_wallet'> & { web3_wallet: FapiWeb3Attribute };
};

export type FapiEnvironment = Omit<EnvironmentJSON, 'user_settings' | 'organization_settings'> & {
user_settings: FapiUserSettings;
Expand Down Expand Up @@ -353,6 +362,21 @@ export function fapiEnterpriseAccount(
};
}

export function fapiWeb3Wallet(
overrides: Partial<Web3WalletJSON> & Pick<Web3WalletJSON, 'id' | 'web3_wallet'>,
): 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<SessionJSON> & Pick<SessionJSON, 'id' | 'user'>): SessionJSON {
const { user } = overrides;
const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id);
Expand Down
31 changes: 31 additions & 0 deletions packages/mosaic/src/components/action-menu/action-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MosaicProvider>
<ActionMenu
label='Manage wallet'
actions={actions}
/>
</MosaicProvider>,
);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Manage wallet' }));
expect(await screen.findByRole('menuitem', { name: 'Remove' })).toBeInTheDocument();

view.rerender(
<MosaicProvider>
<ActionMenu
label='Manage wallet'
actions={actions}
disabled
/>
</MosaicProvider>,
);

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();
});
});
5 changes: 4 additions & 1 deletion packages/mosaic/src/components/action-menu/action-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ export interface ActionMenuProps {
label: string;
actions: ActionMenuAction[];
triggerRef?: Ref<HTMLButtonElement>;
disabled?: boolean;
}

export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) {
export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuProps) {
if (actions.length === 0) {
return null;
}
Expand All @@ -27,6 +28,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) {
<Menu.Trigger
ref={triggerRef}
aria-label={label}
disabled={disabled}
/>
<Menu.Popup>
{actions.map(action => (
Expand All @@ -35,6 +37,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) {
color={action.color}
label={action.label}
onClick={action.onClick}
disabled={disabled}
>
{action.icon ? (
<Menu.Media>
Expand Down
Loading
Loading