Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 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
b250f74
feat(mosaic): wire up user profile Web3 wallets
austincalvelage Sep 28, 2026
65e307a
fix(mosaic): present reverification for wallet actions
austincalvelage Sep 28, 2026
20c5a21
test(mosaic): cover selected Solana wallet loading
austincalvelage Sep 28, 2026
8abd699
fix(mosaic): show loading on the selected Solana wallet
austincalvelage Sep 28, 2026
d545f6b
fix(mosaic): hide wallets when Web3 is disabled
austincalvelage Sep 28, 2026
599881d
fix(mosaic): align Web3 reverification with controller status
austincalvelage Sep 29, 2026
ff04b50
refactor(mosaic): remove Web3 reverification flow
austincalvelage Sep 29, 2026
a532af8
test(mosaic): cover wallet behavior through real Clerk
austincalvelage Sep 29, 2026
9aa16f4
test(mosaic): replace remaining wallet view tests with feature coverage
austincalvelage Sep 29, 2026
0aa6c7f
fix(mosaic): localize Web3 wallet action errors
austincalvelage Sep 29, 2026
b47006d
chore(mosaic): note Web3 error localization follow-up
austincalvelage Sep 30, 2026
14a80f7
chore(mosaic): mark session reverification as deferred
austincalvelage Sep 30, 2026
14e2ad6
test(mosaic): align Web3 verification fixtures with main
austincalvelage Oct 1, 2026
194ad19
test(mosaic): record shared Solana follow-up
austincalvelage Oct 2, 2026
25ddb35
fix(mosaic): align Web3 imports with main
austincalvelage Oct 2, 2026
5cdc90d
chore(mosaic): keep one empty Web3 changeset
austincalvelage Oct 2, 2026
39f6cdc
test(mosaic): cover unrelated fake FAPI user updates
austincalvelage Oct 2, 2026
1c961d9
fix(mosaic): address Web3 wallet review feedback
austincalvelage Oct 2, 2026
d7a78e5
test(mosaic): cover wallet retries and immutable settings
austincalvelage Oct 3, 2026
580f269
test(mosaic): mirror primary-wallet updates in fake FAPI
austincalvelage Oct 3, 2026
349d0cd
fix(mosaic): recover wallet connections and respect immutable settings
austincalvelage Oct 3, 2026
0cefa0f
test(mosaic): replace wallet hook tests with connected coverage
austincalvelage Oct 3, 2026
4fbcdd2
test(mosaic): focus Solana selection through user interaction
austincalvelage Oct 3, 2026
196c05d
fix(swingset): align wired Web3 stories with live navigation
austincalvelage Oct 3, 2026
1652a0b
fix(ui): keep profile helpers outside initial shared entries
austincalvelage Oct 3, 2026
6660a99
fix(ui): retain deferred wallet registry chunks
austincalvelage Oct 3, 2026
669d3e3
refactor(mosaic): route wallet removal through controller
austincalvelage Oct 6, 2026
68dad58
fix(mosaic): preserve Web3 wallet error details for localization
austincalvelage Oct 6, 2026
bef4738
chore(mosaic): format keyed pending actions
austincalvelage Oct 6, 2026
7aceca7
chore: keep one empty changeset
austincalvelage Oct 6, 2026
8eea7b6
test(mosaic): use Node 20 compatible deferred promise
austincalvelage Oct 6, 2026
786742f
refactor(mosaic): use keyed pending actions for Web3
austincalvelage Oct 6, 2026
caf2ff6
fix(mosaic): clear Solana picker errors on reopen
austincalvelage Oct 7, 2026
1998edd
docs(mosaic): clarify Web3 wallet recovery TODOs
austincalvelage Oct 7, 2026
a84b63e
refactor(mosaic): keep discovery local
austincalvelage Oct 7, 2026
978b687
refactor(mosaic): keep sorting local
austincalvelage Oct 7, 2026
e95d670
fix(mosaic): handle Solana wallet discovery failures
austincalvelage Oct 7, 2026
d5d5d34
fix(mosaic): discover Solana wallets when the picker opens
austincalvelage Oct 7, 2026
077931c
fix(mosaic): reuse shared fake FAPI missing response
austincalvelage Oct 7, 2026
aafb3ea
Merge branch 'austin/web-3-wire-up' of https://github.com/clerk/javas…
austincalvelage Oct 7, 2026
2bf033f
fix(mosaic): handle Solana wallet discovery failures
austincalvelage Oct 8, 2026
f98326f
fix(mosaic): use shared Web3 error localization
austincalvelage Oct 8, 2026
b303fdc
refactor(mosaic): colocate Web3 wallet views
austincalvelage Oct 8, 2026
7c54b39
refactor(mosaic): share identification creation policy
austincalvelage Oct 8, 2026
fbaf1fe
chore(mosaic): note Solana picker pending cleanup
austincalvelage Oct 8, 2026
1be35c1
refactor(mosaic): combine Solana picker state
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
6 changes: 6 additions & 0 deletions .changeset/web3-wallets-wire-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/mosaic": patch
---

Show wallet discovery errors consistently with other dialogs and ask users to refresh the page when Solana wallets cannot load.
Localize Web3 wallet connection errors through the shared error catalog, including custom translations for unavailable providers and missing wallet extensions.
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 @@ -162,4 +162,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 @@ -9,6 +9,7 @@ import type {
SessionJSON,
UserJSON,
UserOrganizationInvitationJSON,
Web3WalletJSON,
} from '@clerk/shared/types';
import { http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';
Expand All @@ -33,6 +34,7 @@ import {
fapiPage,
fapiToken,
fapiVerification,
fapiWeb3Wallet,
} from './fapi';

export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k';
Expand Down Expand Up @@ -121,6 +123,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)),
});
}
}

export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const { verification, enterpriseLinking, passkeys, ...rest } = seed;
const state: FakeFapiState = {
Expand Down Expand Up @@ -221,6 +237,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