Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
e611a65
feat(mosaic): wire up user profile Web3 wallets
austincalvelage Sep 28, 2026
e1246d1
fix(mosaic): present reverification for wallet actions
austincalvelage Sep 28, 2026
a82fbec
test(mosaic): cover selected Solana wallet loading
austincalvelage Sep 28, 2026
0affb58
fix(mosaic): show loading on the selected Solana wallet
austincalvelage Sep 28, 2026
5aead19
fix(mosaic): hide wallets when Web3 is disabled
austincalvelage Sep 28, 2026
ff3406f
fix(mosaic): align Web3 reverification with controller status
austincalvelage Sep 29, 2026
cd80524
refactor(mosaic): remove Web3 reverification flow
austincalvelage Sep 29, 2026
9111953
test(mosaic): cover wallet behavior through real Clerk
austincalvelage Sep 29, 2026
d5b2003
test(mosaic): replace remaining wallet view tests with feature coverage
austincalvelage Sep 29, 2026
e2c08dc
fix(mosaic): localize Web3 wallet action errors
austincalvelage Sep 29, 2026
3ba219c
chore(mosaic): note Web3 error localization follow-up
austincalvelage Sep 30, 2026
34137cc
chore(mosaic): mark session reverification as deferred
austincalvelage Sep 30, 2026
f3e1520
test(mosaic): align Web3 verification fixtures with main
austincalvelage Oct 1, 2026
52a428b
test(mosaic): record shared Solana follow-up
austincalvelage Oct 2, 2026
b22fef3
fix(mosaic): align Web3 imports with main
austincalvelage Oct 2, 2026
ea60ec4
chore(mosaic): keep one empty Web3 changeset
austincalvelage Oct 2, 2026
2e145bb
test(mosaic): cover unrelated fake FAPI user updates
austincalvelage Oct 2, 2026
257791c
fix(mosaic): address Web3 wallet review feedback
austincalvelage Oct 2, 2026
4fe4ba1
test(mosaic): cover wallet retries and immutable settings
austincalvelage Oct 3, 2026
918b3dc
test(mosaic): mirror primary-wallet updates in fake FAPI
austincalvelage Oct 3, 2026
ab14f1b
fix(mosaic): recover wallet connections and respect immutable settings
austincalvelage Oct 3, 2026
f40eb00
test(mosaic): replace wallet hook tests with connected coverage
austincalvelage Oct 3, 2026
94d393f
test(mosaic): focus Solana selection through user interaction
austincalvelage Oct 3, 2026
6cd2aa3
fix(swingset): align wired Web3 stories with live navigation
austincalvelage Oct 3, 2026
3f2d736
fix(ui): keep profile helpers outside initial shared entries
austincalvelage Oct 3, 2026
c224e68
fix(ui): retain deferred wallet registry chunks
austincalvelage Oct 3, 2026
f236310
refactor(mosaic): route wallet removal through controller
austincalvelage Oct 6, 2026
6bb9b0b
fix(mosaic): preserve Web3 wallet error details for localization
austincalvelage Oct 6, 2026
937a31c
chore(mosaic): format keyed pending actions
austincalvelage Oct 6, 2026
8bf957c
chore: keep one empty changeset
austincalvelage Oct 6, 2026
de0f2a1
test(mosaic): use Node 20 compatible deferred promise
austincalvelage Oct 6, 2026
605f014
refactor(mosaic): use keyed pending actions for Web3
austincalvelage Oct 6, 2026
a2e9f7a
fix(mosaic): clear Solana picker errors on reopen
austincalvelage Oct 7, 2026
340037b
docs(mosaic): clarify Web3 wallet recovery TODOs
austincalvelage Oct 7, 2026
22089f1
refactor(mosaic): keep discovery local
austincalvelage Oct 7, 2026
fed7a20
refactor(mosaic): keep sorting local
austincalvelage Oct 7, 2026
32f6409
fix(mosaic): handle Solana wallet discovery failures
austincalvelage Oct 7, 2026
8e4724f
fix(mosaic): discover Solana wallets when the picker opens
austincalvelage Oct 7, 2026
f43ee56
fix(mosaic): reuse shared fake FAPI missing response
austincalvelage Oct 7, 2026
cdf7fdf
feat(mosaic): wire up user profile Web3 wallets
austincalvelage Sep 28, 2026
86d4424
fix(mosaic): present reverification for wallet actions
austincalvelage Sep 28, 2026
7c4e164
test(mosaic): cover selected Solana wallet loading
austincalvelage Sep 28, 2026
383682e
fix(mosaic): show loading on the selected Solana wallet
austincalvelage Sep 28, 2026
974ead6
fix(mosaic): hide wallets when Web3 is disabled
austincalvelage Sep 28, 2026
0caf9d7
fix(mosaic): align Web3 reverification with controller status
austincalvelage Sep 29, 2026
9b57741
refactor(mosaic): remove Web3 reverification flow
austincalvelage Sep 29, 2026
8039074
test(mosaic): cover wallet behavior through real Clerk
austincalvelage Sep 29, 2026
f1052b0
test(mosaic): replace remaining wallet view tests with feature coverage
austincalvelage Sep 29, 2026
7e68e63
fix(mosaic): localize Web3 wallet action errors
austincalvelage Sep 29, 2026
db6e8ce
chore(mosaic): note Web3 error localization follow-up
austincalvelage Sep 30, 2026
812e1fb
chore(mosaic): mark session reverification as deferred
austincalvelage Sep 30, 2026
c775d78
test(mosaic): align Web3 verification fixtures with main
austincalvelage Oct 1, 2026
8ba3ce3
test(mosaic): record shared Solana follow-up
austincalvelage Oct 2, 2026
ff6eade
fix(mosaic): align Web3 imports with main
austincalvelage Oct 2, 2026
0666184
chore(mosaic): keep one empty Web3 changeset
austincalvelage Oct 2, 2026
9925256
test(mosaic): cover unrelated fake FAPI user updates
austincalvelage Oct 2, 2026
527e308
fix(mosaic): address Web3 wallet review feedback
austincalvelage Oct 2, 2026
51045b6
test(mosaic): cover wallet retries and immutable settings
austincalvelage Oct 3, 2026
b9a714a
test(mosaic): mirror primary-wallet updates in fake FAPI
austincalvelage Oct 3, 2026
ad353ec
fix(mosaic): recover wallet connections and respect immutable settings
austincalvelage Oct 3, 2026
71afc2f
test(mosaic): replace wallet hook tests with connected coverage
austincalvelage Oct 3, 2026
1ae70a1
test(mosaic): focus Solana selection through user interaction
austincalvelage Oct 3, 2026
7071b6a
fix(swingset): align wired Web3 stories with live navigation
austincalvelage Oct 3, 2026
ed5361c
fix(ui): keep profile helpers outside initial shared entries
austincalvelage Oct 3, 2026
6c638f0
fix(ui): retain deferred wallet registry chunks
austincalvelage Oct 3, 2026
8be4de5
refactor(mosaic): route wallet removal through controller
austincalvelage Oct 6, 2026
28c2d8f
fix(mosaic): preserve Web3 wallet error details for localization
austincalvelage Oct 6, 2026
f2016c8
chore(mosaic): format keyed pending actions
austincalvelage Oct 6, 2026
d2a18eb
chore: keep one empty changeset
austincalvelage Oct 6, 2026
c84776c
test(mosaic): use Node 20 compatible deferred promise
austincalvelage Oct 6, 2026
565cafc
refactor(mosaic): use keyed pending actions for Web3
austincalvelage Oct 6, 2026
e74d07a
fix(mosaic): clear Solana picker errors on reopen
austincalvelage Oct 7, 2026
ef222c7
docs(mosaic): clarify Web3 wallet recovery TODOs
austincalvelage Oct 7, 2026
c305de2
refactor(mosaic): keep discovery local
austincalvelage Oct 7, 2026
1573efe
refactor(mosaic): keep sorting local
austincalvelage Oct 7, 2026
8a66223
fix(mosaic): handle Solana wallet discovery failures
austincalvelage Oct 7, 2026
95fd29f
fix(mosaic): discover Solana wallets when the picker opens
austincalvelage Oct 7, 2026
af18a32
fix(mosaic): reuse shared fake FAPI missing response
austincalvelage Oct 7, 2026
2363883
Merge branch 'austin/web-3-wire-up' of https://github.com/clerk/javas…
austincalvelage Oct 7, 2026
ccf4feb
fix(mosaic): handle Solana wallet discovery failures
austincalvelage Oct 8, 2026
c81e01f
fix(mosaic): use shared Web3 error localization
austincalvelage Oct 8, 2026
484b781
refactor(mosaic): colocate Web3 wallet views
austincalvelage Oct 8, 2026
1fd023b
refactor(mosaic): share identification creation policy
austincalvelage Oct 8, 2026
0f24aa0
chore(mosaic): note Solana picker pending cleanup
austincalvelage Oct 8, 2026
f236489
refactor(mosaic): combine Solana picker state
austincalvelage Oct 8, 2026
136112c
chore(mosaic): note shared wallet removal lock
austincalvelage Oct 8, 2026
fbc0956
chore: use empty Web3 changeset
austincalvelage Oct 8, 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 @@
---
---
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) |
| A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) |
| Current time for display labels | `useNow` + `MosaicProvider` ([#10098](https://github.com/clerk/javascript/pull/10098), once available) |
| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) |
| An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `styles/feedback.styles.ts` |
| A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"count": 1
}
},
"src/features/user-profile/user-profile-web3-wallet-row.view.tsx": {
"src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx": {
"sonarjs/cognitive-complexity": {
"count": 1
}
Expand Down Expand Up @@ -147,4 +147,4 @@
"count": 1
}
}
}
}
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 @@ -10,6 +10,7 @@ import type {
SessionJSON,
UserJSON,
UserOrganizationInvitationJSON,
Web3WalletJSON,
} from '@clerk/shared/types';
import { http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';
Expand All @@ -34,6 +35,7 @@ import {
fapiPage,
fapiToken,
fapiVerification,
fapiWeb3Wallet,
} from './fapi';

export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k';
Expand Down Expand Up @@ -140,6 +142,20 @@ function updateOrganization(state: FakeFapiState, organization: OrganizationJSON
};
}

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

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

export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const { verification, enterpriseLinking, passkeys, ...rest } = seed;
const state: FakeFapiState = {
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 @@ -26,11 +26,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 @@ -354,6 +363,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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { MosaicProvider } from '../../../mosaic-provider';
import { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view';
import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view';
import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view';
import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view';
import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section.view';

function DeleteAccount() {
const controller = useDestructiveController({ onDelete: () => Promise.resolve() });
Expand Down
Loading
Loading