Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
69b9420
refactor(ui): share connected account removal confirmation
austincalvelage Sep 15, 2026
9d08e78
docs(swingset): demonstrate async connected account removal
austincalvelage Sep 15, 2026
12b69b5
refactor(ui): share email removal confirmation
austincalvelage Sep 15, 2026
aba01f6
refactor(ui): share phone removal confirmation
austincalvelage Sep 15, 2026
4fa3abe
refactor(ui): share Web3 wallet removal confirmation
austincalvelage Sep 15, 2026
bb941f7
refactor(ui): remove unused contact dialog render hook
austincalvelage Sep 15, 2026
82cba68
refactor(ui): simplify user profile dialog descriptions
austincalvelage Sep 15, 2026
074cfde
refactor(ui): remove obsolete confirmation plumbing
austincalvelage Sep 15, 2026
981f8a9
Merge branch 'main' into austin/user-profile-confirmation
austincalvelage Sep 15, 2026
7f481ca
fix(ui): align profile removal callbacks and register stories
austincalvelage Sep 15, 2026
6f07514
fix(swingset): wire Web3 actions in profile examples
austincalvelage Sep 16, 2026
949f35d
fix(ui): restore profile contact message templates
austincalvelage Sep 16, 2026
9f373a1
fix(ui): simplify wallet removal confirmation copy
austincalvelage Sep 16, 2026
8595735
fix(ui): use flow autofocus for email and phone verification (#9780)
austincalvelage Sep 16, 2026
15921a2
Merge branch 'main' into austin/user-profile-confirmation
austincalvelage Sep 16, 2026
bdf1911
test(ui): use compatible deferred promises in profile tests
austincalvelage Sep 16, 2026
8a4a928
Merge main into austin/user-profile-confirmation
austincalvelage Sep 16, 2026
fbe994f
Merge main into austin/user-profile-confirmation
austincalvelage Sep 16, 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/connected-account-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/honest-chairs-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
8 changes: 8 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,14 @@ import {
Default as UserProfileAccountSectionDefault,
EmailLinkResendFails as UserProfileAccountSectionEmailLinkResendFails,
EmailLinkVerification as UserProfileAccountSectionEmailLinkVerification,
EmailRemovalError as UserProfileAccountSectionEmailRemovalError,
EmailRemovalPending as UserProfileAccountSectionEmailRemovalPending,
EmailSsoConnectFails as UserProfileAccountSectionEmailSsoConnectFails,
EmailSsoVerification as UserProfileAccountSectionEmailSsoVerification,
meta as userProfileAccountSectionMeta,
MultipleAccounts as UserProfileAccountSectionMultipleAccounts,
PhoneRemovalError as UserProfileAccountSectionPhoneRemovalError,
PhoneRemovalPending as UserProfileAccountSectionPhoneRemovalPending,
} from '../stories/user-profile-account-section.stories';
import {
Default as UserProfileActiveDevicesSectionDefault,
Expand Down Expand Up @@ -529,6 +533,10 @@ const userProfileAccountSectionModule: StoryModule = {
EmailLinkResendFails: UserProfileAccountSectionEmailLinkResendFails,
EmailSsoVerification: UserProfileAccountSectionEmailSsoVerification,
EmailSsoConnectFails: UserProfileAccountSectionEmailSsoConnectFails,
EmailRemovalPending: UserProfileAccountSectionEmailRemovalPending,
EmailRemovalError: UserProfileAccountSectionEmailRemovalError,
PhoneRemovalPending: UserProfileAccountSectionPhoneRemovalPending,
PhoneRemovalError: UserProfileAccountSectionPhoneRemovalError,
};
const userProfileProfilePanelModule: StoryModule = {
meta: userProfileProfilePanelMeta,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function useConnectedAccountsFixture({
providers?: UserProfileConnectionProvider[];
removalState?: 'pending' | 'error';
} = {}) {
const [hasRemovalFailed, setHasRemovalFailed] = useState(false);
const [accounts, setAccounts] = useState(initialAccounts);
const availableProviders = providers.filter(provider => !accounts.some(account => account.id === provider.id));

Expand All @@ -47,18 +48,15 @@ export function useConnectedAccountsFixture({
: account,
),
),
onRemove: (id: string) =>
setAccounts(current => {
const account = current.find(item => item.id === id);
if (removalState === 'pending') {
return current.map(item => (item.id === id ? { ...item, isRemoving: true } : item));
}
if (removalState === 'error' && account && !account.removalError) {
return current.map(item =>
item.id === id ? { ...item, removalError: 'Unable to remove this account. Please try again.' } : item,
);
}
return current.filter(item => item.id !== id);
}),
onRemove: async (id: string) => {
if (removalState === 'pending') {
await new Promise(resolve => setTimeout(resolve, 1500));
}
if (removalState === 'error' && !hasRemovalFailed) {
setHasRemovalFailed(true);
throw new Error('Unable to remove this account. Please try again.');
}
setAccounts(current => current.filter(item => item.id !== id));
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function useWeb3WalletsFixture({
} = {}) {
const [wallets, setWallets] = useState(initialWallets);
const [connectionProviders, setConnectionProviders] = useState(availableProviders);
const [removalFailed, setRemovalFailed] = useState(false);
const [primaryFailed, setPrimaryFailed] = useState(false);

return {
Expand Down Expand Up @@ -94,22 +95,15 @@ export function useWeb3WalletsFixture({
current.map(wallet => ({ ...wallet, isPrimary: wallet.id === id, primaryError: undefined })),
);
},
onRemove: (id: string) => {
onRemove: async (id: string) => {
if (removalState === 'pending') {
setWallets(current => current.map(wallet => (wallet.id === id ? { ...wallet, isRemoving: true } : wallet)));
setTimeout(() => {
setWallets(current => current.filter(wallet => wallet.id !== id));
}, 1500);
return;
await new Promise(resolve => setTimeout(resolve, 1500));
}
setWallets(current => {
if (removalState === 'error' && !current.find(wallet => wallet.id === id)?.removalError) {
return current.map(wallet =>
wallet.id === id ? { ...wallet, removalError: 'Unable to remove wallet. Please try again.' } : wallet,
);
}
return current.filter(wallet => wallet.id !== id);
});
if (removalState === 'error' && !removalFailed) {
setRemovalFailed(true);
throw new Error('Unable to remove wallet. Please try again.');
}
setWallets(current => current.filter(wallet => wallet.id !== id));
},
};
}
7 changes: 7 additions & 0 deletions packages/swingset/src/stories/fixtures/user-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useConnectedAccountsFixture } from './user-profile-connected-accounts';
import { useUserProfileEditNameFixture } from './user-profile-edit-name';
import { useUserProfileEditPasswordFixture } from './user-profile-edit-password';
import { useUserProfileEditUsernameFixture } from './user-profile-edit-username';
import { useWeb3WalletsFixture } from './user-profile-web3-wallets';

export interface UserProfileFixtureOptions {
/** Replaces the default OTP flow, e.g. for a custom dialog example. */
Expand Down Expand Up @@ -52,6 +53,7 @@ const initialAPIKeys: UserProfileAPIKey[] = [
*/
export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) {
const connections = useConnectedAccountsFixture();
const wallets = useWeb3WalletsFixture();
const editName = useUserProfileEditNameFixture();
const editUsername = useUserProfileEditUsernameFixture();
const editPassword = useUserProfileEditPasswordFixture();
Expand Down Expand Up @@ -132,6 +134,11 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions
onConnectAccount: connections.onConnect,
onReconnectAccount: connections.onReconnect,
onRemoveConnectedAccount: connections.onRemove,
web3Wallets: wallets.wallets,
availableWeb3Providers: wallets.availableProviders,
onConnectWeb3Wallet: wallets.onConnect,
onSetPrimaryWeb3Wallet: wallets.onSetPrimary,
onRemoveWeb3Wallet: wallets.onRemove,
allowMultipleAccounts: true,
hasImage: Boolean(imageUrl),
imageUrl,
Expand Down
24 changes: 24 additions & 0 deletions packages/swingset/src/stories/user-profile-account-section.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,27 @@ simulates completion and closes the dialog.
Connect to see the supplied error message. The user can retry or cancel.

<Story name='EmailSsoConnectFails' storyModule={Stories} />

## Email removal pending

Confirm removal to see a short pending state before the email disappears.

<Story name='EmailRemovalPending' storyModule={Stories} />

## Email removal error

The first attempt fails inside the confirmation. Retry succeeds.

<Story name='EmailRemovalError' storyModule={Stories} />

## Phone removal pending

Remove a phone number to see the confirmation remain open until removal completes.

<Story name='PhoneRemovalPending' storyModule={Stories} />

## Phone removal error

The first removal fails. Retry in the confirmation to remove the phone number.

<Story name='PhoneRemovalError' storyModule={Stories} />
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,19 @@ function AccountSection({
failWith,
usernameFailWith,
failEmailVerification = false,
emailRemovalState,
phoneRemovalState,
}: {
allowMultipleAccounts: boolean;
failAt?: UserProfileAddPhoneDialogProps['step'];
failWith?: UserProfileFormError;
usernameFailWith?: UserProfileFormError;
failEmailVerification?: boolean;
emailRemovalState?: 'pending' | 'error';
phoneRemovalState?: 'pending' | 'error';
}) {
const [phoneRemovalFailed, setPhoneRemovalFailed] = useState(false);
const [emailRemovalFailed, setEmailRemovalFailed] = useState(false);
const editName = useUserProfileEditNameFixture({ failWith });
const editUsername = useUserProfileEditUsernameFixture({ failWith: usernameFailWith });
const [emails, setEmails] = useState<UserProfileEmail[]>(
Expand Down Expand Up @@ -84,9 +90,27 @@ function AccountSection({
onRemoveProfilePicture={clearImage}
onManageEmail={() => undefined}
onManagePhone={() => undefined}
onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))}
onRemoveEmail={async id => {
if (emailRemovalState === 'pending') {
await new Promise(resolve => setTimeout(resolve, 1500));
}
if (emailRemovalState === 'error' && !emailRemovalFailed) {
setEmailRemovalFailed(true);
throw new Error('Unable to remove this email address. Try again.');
}
setEmails(current => current.filter(email => email.id !== id));
}}
onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))}
onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))}
onRemovePhone={async id => {
if (phoneRemovalState === 'pending') {
await new Promise(resolve => setTimeout(resolve, 1500));
}
if (phoneRemovalState === 'error' && !phoneRemovalFailed) {
setPhoneRemovalFailed(true);
throw new Error('Unable to remove this phone number. Try again.');
}
setPhones(current => current.filter(phone => phone.id !== id));
}}
onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))}
/>
);
Expand Down Expand Up @@ -210,3 +234,39 @@ export function AddPhoneFails() {
/>
);
}

export function EmailRemovalPending() {
return (
<AccountSection
allowMultipleAccounts
emailRemovalState='pending'
/>
);
}

export function EmailRemovalError() {
return (
<AccountSection
allowMultipleAccounts
emailRemovalState='error'
/>
);
}

export function PhoneRemovalPending() {
return (
<AccountSection
allowMultipleAccounts
phoneRemovalState='pending'
/>
);
}

export function PhoneRemovalError() {
return (
<AccountSection
allowMultipleAccounts
phoneRemovalState='error'
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Stories from './user-profile-connected-accounts-section.stories';

# UserProfileConnectedAccountsSection

Linked accounts and available providers are supplied separately. These examples simulate state through props; they do not start OAuth or mutate a user. Reload to reset interactive examples.
Linked accounts and available providers are supplied separately. Removal uses one shared confirmation per section, which awaits the removal callback and displays failures. These examples simulate actions; they do not start OAuth or mutate a user. Reload to reset interactive examples.

<Story name='Default' storyModule={Stories} />

Expand Down Expand Up @@ -30,7 +30,7 @@ Recoverable verification errors and additional scope requirements show a Disconn

## Removal Pending

Choose Remove from the row menu, then confirm to show the pending state.
Choose Remove from the row menu, then confirm. The dialog shows pending for a short delay, then the account becomes available to connect again.

<Story name='RemovalPending' storyModule={Stories} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-ph
import { useConnectedAccountsFixture } from './fixtures/user-profile-connected-accounts';
import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name';
import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username';
import { useWeb3WalletsFixture } from './fixtures/user-profile-web3-wallets';

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';
Expand All @@ -38,6 +38,7 @@ export function Default(_args: Record<string, unknown>) {
]);
const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl);
const connections = useConnectedAccountsFixture();
const wallets = useWeb3WalletsFixture();
const editName = useUserProfileEditNameFixture();
const editUsername = useUserProfileEditUsernameFixture();
const emailFlow = createUserProfileAddEmailFixture({
Expand All @@ -54,19 +55,8 @@ export function Default(_args: Record<string, unknown>) {
connectedAccounts={connections.accounts}
availableConnectionProviders={connections.availableProviders}
onReconnectAccount={connections.onReconnect}
web3Wallets={[
{
id: 'metamask',
address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
provider: 'MetaMask',
iconUrl: providerIconUrl('metamask'),
isPrimary: true,
isVerified: true,
},
]}
availableWeb3Providers={[
{ id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: providerIconUrl('coinbase_wallet') },
]}
web3Wallets={wallets.wallets}
availableWeb3Providers={wallets.availableProviders}
hasImage={Boolean(imageUrl)}
imageUrl={imageUrl}
phones={phones}
Expand All @@ -82,9 +72,9 @@ export function Default(_args: Record<string, unknown>) {
onRemoveProfilePicture={clearImage}
onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))}
onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))}
onConnectWeb3Wallet={() => undefined}
onRemoveWeb3Wallet={() => undefined}
onSetPrimaryWeb3Wallet={() => undefined}
onConnectWeb3Wallet={wallets.onConnect}
onRemoveWeb3Wallet={wallets.onRemove}
onSetPrimaryWeb3Wallet={wallets.onSetPrimary}
onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))}
onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))}
onVerifyEmail={() => undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ Wallets and available providers are supplied separately. These examples simulate
{ name: 'Button', href: '/components/button', layer: 'Components' },
{ name: 'Icon', href: '/components/icon', layer: 'Components' },
{ name: 'Menu', href: '/components/menu', layer: 'Components' },
{ name: 'Dialog', href: '/components/dialog', layer: 'Components' },
{ name: 'Banner', href: '/components/banner', layer: 'Components' },
{ name: 'Confirmation', href: '/blocks/confirmation', layer: 'Blocks' },
]}
/>

Expand Down Expand Up @@ -48,7 +47,7 @@ Choose Set as primary on Coinbase Wallet to show an error. Retry succeeds.

## Removal Pending

Choose Remove wallet, then confirm to hold the pending state. Cancel is disabled while removal is pending.
Choose Remove wallet, then confirm. The confirmation stays open until removal completes after a short delay.

<Story name='RemovalPending' storyModule={Stories} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ describe('UserProfileAddEmailDialog', () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<VerificationExample onSubmit={onSubmit} />);
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus());
await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus());
await user.click(screen.getByRole('textbox', { name: 'Verification code' }));

if (method === 'typing') {
await user.keyboard('12345');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ describe('profile add email', () => {
expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument();
await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com');
await user.click(screen.getByRole('button', { name: 'Send code' }));
await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456');
const codeInput = await screen.findByRole('textbox', { name: 'Verification code' });
await waitFor(() => expect(codeInput).toHaveFocus());
await user.keyboard('123456');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com');
expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ describe('UserProfileAddPhoneDialog', () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<VerificationExample onSubmit={onSubmit} />);
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus());
await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus());
await user.click(screen.getByRole('textbox', { name: 'Verification code' }));

if (method === 'typing') {
await user.keyboard('12345');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ describe('profile add phone', () => {
expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument();
await user.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100');
await user.click(screen.getByRole('button', { name: 'Send code' }));
await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456');
const codeInput = await screen.findByRole('textbox', { name: 'Verification code' });
await waitFor(() => expect(codeInput).toHaveFocus());
await user.keyboard('123456');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100');
expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456');
Expand Down
Loading
Loading