diff --git a/.changeset/connected-account-removal-dialog.md b/.changeset/connected-account-removal-dialog.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/connected-account-removal-dialog.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/connected-accounts-view-states.md b/.changeset/connected-accounts-view-states.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/connected-accounts-view-states.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index bc9fe0a5055..a469462e1e3 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -222,8 +222,15 @@ import { meta as userProfileBillingPanelMeta, } from '../stories/user-profile-billing-panel.stories'; import { + ConnectionError as ConnectedAccountsConnectionError, + ConnectOnly as ConnectedAccountsConnectOnly, Default as UserProfileConnectedAccountsSectionDefault, + LinkedAccounts as ConnectedAccountsLinkedAccounts, meta as userProfileConnectedAccountsSectionMeta, + ReconnectRequired as ConnectedAccountsReconnectRequired, + RemovalError as ConnectedAccountsRemovalError, + RemovalPending as ConnectedAccountsRemovalPending, + VerificationError as ConnectedAccountsVerificationError, } from '../stories/user-profile-connected-accounts-section.stories'; import { Default as UserProfileDeleteSectionDefault, @@ -532,6 +539,13 @@ const userProfilePaymentMethodsSectionModule: StoryModule = { const userProfileConnectedAccountsSectionModule: StoryModule = { meta: userProfileConnectedAccountsSectionMeta, Default: UserProfileConnectedAccountsSectionDefault, + LinkedAccounts: ConnectedAccountsLinkedAccounts, + ConnectOnly: ConnectedAccountsConnectOnly, + ReconnectRequired: ConnectedAccountsReconnectRequired, + VerificationError: ConnectedAccountsVerificationError, + ConnectionError: ConnectedAccountsConnectionError, + RemovalPending: ConnectedAccountsRemovalPending, + RemovalError: ConnectedAccountsRemovalError, }; const userProfileWeb3WalletsSectionModule: StoryModule = { meta: userProfileWeb3WalletsSectionMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts new file mode 100644 index 00000000000..b2ddebf7df9 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts @@ -0,0 +1,64 @@ +import type { + UserProfileConnectedAccount, + UserProfileConnectionProvider, +} from '@clerk/ui/mosaic/features/user-profile/user-profile-connected-accounts-section.view'; +import { useState } from 'react'; + +export const connectedAccount = { + id: 'google', + provider: 'Google', + identifier: 'test@example.com', + iconUrl: 'https://img.clerk.com/static/google.svg', +}; +const connectionProviders: UserProfileConnectionProvider[] = [ + { id: 'google', provider: 'Google', iconUrl: 'https://img.clerk.com/static/google.svg' }, + { id: 'apple', provider: 'Apple', iconUrl: 'https://img.clerk.com/static/apple.svg' }, +]; + +export function useConnectedAccountsFixture({ + initialAccounts = [connectedAccount], + providers = connectionProviders, + removalState, +}: { + initialAccounts?: UserProfileConnectedAccount[]; + providers?: UserProfileConnectionProvider[]; + removalState?: 'pending' | 'error'; +} = {}) { + const [accounts, setAccounts] = useState(initialAccounts); + const availableProviders = providers.filter(provider => !accounts.some(account => account.id === provider.id)); + + return { + accounts, + availableProviders, + onConnect: (id: string) => { + const provider = providers.find(item => item.id === id); + if (provider) { + setAccounts(current => [ + ...current, + { id: provider.id, provider: provider.provider, iconUrl: provider.iconUrl, identifier: 'test@example.com' }, + ]); + } + }, + onReconnect: (id: string) => + setAccounts(current => + current.map(account => + account.id === id + ? { ...account, status: 'connected', reconnectError: undefined, verificationError: undefined } + : 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); + }), + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index ccf6fe53617..bc1bdc1a66f 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -18,6 +18,7 @@ import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; import { createUserProfileAddEmailFixture } from './user-profile-add-email'; import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; +import { useConnectedAccountsFixture } from './user-profile-connected-accounts'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; @@ -49,6 +50,7 @@ const initialAPIKeys: UserProfileAPIKey[] = [ * stories that need a realistic profile surface without being about it. */ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) { + const connections = useConnectedAccountsFixture(); const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const [activePage, setActivePage] = useState('account'); @@ -123,6 +125,11 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions account: { ...editName, ...editUsername, + connectedAccounts: connections.accounts, + availableConnectionProviders: connections.availableProviders, + onConnectAccount: connections.onConnect, + onReconnectAccount: connections.onReconnect, + onRemoveConnectedAccount: connections.onRemove, allowMultipleAccounts: true, hasImage: Boolean(imageUrl), imageUrl, diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.mdx b/packages/swingset/src/stories/user-profile-connected-accounts-section.mdx index ef3e6e1564e..ea938dfcc4f 100644 --- a/packages/swingset/src/stories/user-profile-connected-accounts-section.mdx +++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.mdx @@ -2,14 +2,40 @@ import * as Stories from './user-profile-connected-accounts-section.stories'; # UserProfileConnectedAccountsSection -Connected and available external identity providers with provider-specific actions. - - +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 + + + +## Connect Only + + + +## Reconnect Required + +Recoverable verification errors and additional scope requirements show a Disconnected badge. Choose Reconnect from the account menu to restore the connection. + + + +## Verification Error + + + +## Connection Error + + + +## Removal Pending + +Choose Remove from the row menu, then confirm to show the pending state. + + + +## Removal Error + +The first removal attempt shows an error inside the dialog. Retry succeeds. + + diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx index 41ce1a8260a..bd4276360a7 100644 --- a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx @@ -2,8 +2,9 @@ import { UserProfileConnectedAccountsSectionView } from '@clerk/ui/mosaic/featur import type { StoryMeta } from '@/lib/types'; -export { default as __source } from './user-profile-connected-accounts-section.stories?raw'; +import { connectedAccount, useConnectedAccountsFixture } from './fixtures/user-profile-connected-accounts'; +export { default as __source } from './user-profile-connected-accounts-section.stories?raw'; export const meta: StoryMeta = { group: 'User Profile', status: 'wip', @@ -12,27 +13,49 @@ export const meta: StoryMeta = { navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx', }; - export function Default() { - return ( - undefined} - onManage={() => undefined} - /> - ); + const fixture = useConnectedAccountsFixture(); + return ; +} +export function LinkedAccounts() { + const fixture = useConnectedAccountsFixture({ providers: [connectedAccount] }); + return ; +} +export function ConnectOnly() { + const fixture = useConnectedAccountsFixture({ initialAccounts: [] }); + return ; +} +export function ReconnectRequired() { + const fixture = useConnectedAccountsFixture({ initialAccounts: [{ ...connectedAccount, status: 'reconnect' }] }); + return ; +} +export function VerificationError() { + const fixture = useConnectedAccountsFixture({ + initialAccounts: [ + { ...connectedAccount, status: 'error', verificationError: 'The provider could not verify this account.' }, + ], + }); + return ; +} +export function ConnectionError() { + const fixture = useConnectedAccountsFixture({ + providers: [ + connectedAccount, + { + id: 'apple', + provider: 'Apple', + iconUrl: 'https://img.clerk.com/static/apple.svg', + connectError: 'Unable to connect. Please try again.', + }, + ], + }); + return ; +} +export function RemovalPending() { + const fixture = useConnectedAccountsFixture({ removalState: 'pending' }); + return ; +} +export function RemovalError() { + const fixture = useConnectedAccountsFixture({ removalState: 'error' }); + return ; } diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 5991daf46dd..14b2eaa5256 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -10,6 +10,7 @@ import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; +import { useConnectedAccountsFixture } from './fixtures/user-profile-connected-accounts'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; @@ -36,6 +37,7 @@ export function Default(_args: Record) { { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); + const connections = useConnectedAccountsFixture(); const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const emailFlow = createUserProfileAddEmailFixture({ @@ -49,16 +51,9 @@ export function Default(_args: Record) { {...emailFlow} allowMultipleAccounts emails={emails} - connectedAccounts={[ - { - id: 'google', - provider: 'Google', - identifier: 'test@google.com', - iconUrl: providerIconUrl('google'), - connected: true, - }, - { id: 'apple', provider: 'Apple', iconUrl: providerIconUrl('apple'), connected: false }, - ]} + connectedAccounts={connections.accounts} + availableConnectionProviders={connections.availableProviders} + onReconnectAccount={connections.onReconnect} web3Wallets={[ { id: 'metamask', @@ -81,12 +76,12 @@ export function Default(_args: Record) { {...createUserProfileAddPhoneFixture({ onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), })} - onConnectAccount={() => undefined} + onConnectAccount={connections.onConnect} onDeleteAccount={() => Promise.resolve()} onManageEmail={() => undefined} onManagePhone={() => undefined} onProfilePictureChange={showFile} - onRemoveConnectedAccount={() => undefined} + onRemoveConnectedAccount={connections.onRemove} onRemoveProfilePicture={clearImage} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx new file mode 100644 index 00000000000..d9ef673f720 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx @@ -0,0 +1,86 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view'; + +const account = { id: 'github', provider: 'GitHub', identifier: 'test' }; + +function renderAccounts(onRemove: (id: string) => void) { + return render( + + + , + ); +} + +async function openRemoval(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Manage GitHub' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove' })); +} + +describe('connected account removal', () => { + it('returns keyboard focus to the menu when canceled', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + renderAccounts(onRemove); + const trigger = screen.getByRole('button', { name: 'Manage GitHub' }); + trigger.focus(); + await user.keyboard('{Enter}{Enter}'); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + await waitFor(() => expect(trigger).toHaveFocus()); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it('closes confirmation when the caller removes the account', async () => { + const user = userEvent.setup(); + function Example() { + const [accounts, setAccounts] = useState([account]); + return ( + + setAccounts(current => current.filter(item => item.id !== id))} + /> + + ); + } + render(); + await openRemoval(user); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage GitHub' })).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('shows a removal failure and allows retrying', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + function Example() { + const [removalError, setRemovalError] = useState(); + return ( + + { + onRemove(id); + setRemovalError(onRemove.mock.calls.length === 1 ? 'Unable to disconnect' : undefined); + }} + /> + + ); + } + render(); + await openRemoval(user); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to disconnect'); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + expect(onRemove).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx new file mode 100644 index 00000000000..f7cd162a1ec --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view'; + +const account = { id: 'account_1', provider: 'Google', identifier: 'test@example.com' }; + +describe('connected accounts section', () => { + it.each([{ availableProviders: [] }, { availableProviders: [{ id: 'google', provider: 'Google' }] }])( + 'hides the entire section without accounts or actionable providers (%j)', + ({ availableProviders }) => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }, + ); + + it('shows provider names without announcing decorative logos', () => { + render( + , + ); + expect(screen.getByText('Google')).toBeVisible(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('shows connection errors while keeping Connect available', () => { + render( + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Connection failed'); + expect(screen.getByRole('button', { name: 'Connect Apple' })).toBeEnabled(); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('keeps confirmation open for pending and error props and forwards retry', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + const { rerender } = render( + , + ); + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove' })); + const dialog = screen.getByRole('alertdialog'); + expect(dialog).toHaveTextContent('dependent features'); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledExactlyOnceWith('account_1'); + rerender( + , + ); + expect(within(dialog).getByRole('button', { name: 'Remove', exact: true })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(within(dialog).getByRole('progressbar')).toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledTimes(1); + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled(); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(dialog).toBeInTheDocument(); + rerender( + , + ); + expect(within(dialog).getByRole('alert')).toHaveTextContent('Try again'); + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeEnabled(); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledTimes(2); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Manage Google' })).toHaveFocus(); + }); + + it('renders reconnect and verification errors without a generic manage action', async () => { + const user = userEvent.setup(); + const onReconnect = vi.fn(); + render( + , + ); + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Reconnect' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled(); + expect(screen.getByText('Provider error')).toBeInTheDocument(); + expect(screen.getAllByRole('alert').some(alert => alert.textContent?.includes('Reconnection failed'))).toBe(true); + expect(screen.queryByRole('button', { name: 'Manage Other' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 6901098a761..b1ab09dbfa4 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -29,6 +29,24 @@ function renderView(overrides: Partial = {}) { } describe('UserProfileProfilePanelView', () => { + it('hides connected accounts when only providers without a connect callback are supplied', () => { + renderView({ + connectedAccounts: [], + availableConnectionProviders: [{ id: 'google', provider: 'Google' }], + }); + expect(screen.queryByRole('region', { name: 'Connected accounts' })).not.toBeInTheDocument(); + }); + + it('keeps available providers visible without connected accounts', () => { + const onConnectAccount = vi.fn(); + renderView({ + connectedAccounts: [], + availableConnectionProviders: [{ id: 'google', provider: 'Google' }], + onConnectAccount, + }); + expect(screen.getByRole('button', { name: 'Connect Google' })).toBeVisible(); + }); + it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => { renderView({ allowMultipleAccounts, @@ -196,44 +214,33 @@ describe('UserProfileProfilePanelView', () => { }); it('renders connected accounts and the danger zone when provided', async () => { - const onConnectAccount = vi.fn(); - const onManageConnectedAccount = vi.fn(); + const onRemoveConnectedAccount = vi.fn(); const onDeleteAccount = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ connectedAccounts: [ { id: 'google', provider: 'Google', identifier: 'test@google.com', iconUrl: 'https://example.com/google.svg' }, - { id: 'apple', provider: 'Apple', connected: false }, ], - onConnectAccount, - onManageConnectedAccount, + onRemoveConnectedAccount, onDeleteAccount, }); expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); - expect( - screen.getByRole('region', { name: 'Connected accounts' }).querySelector('.cl-section-media[data-size="lg"] img'), - ).toHaveAttribute('src', 'https://example.com/google.svg'); + expect(screen.getByText('Google')).toBeVisible(); expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( 'cl-section-description', ); - await user.click(screen.getByRole('button', { name: 'Manage Google' })); - expect(onManageConnectedAccount).not.toHaveBeenCalled(); - await user.click(screen.getByRole('menuitem', { name: 'Manage' })); - await user.click(screen.getByRole('button', { name: 'Connect' })); await user.click(screen.getByRole('button', { name: 'Delete account' })); const deleteDialog = screen.getByRole('dialog'); await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); - expect(onManageConnectedAccount).toHaveBeenCalledWith('google'); - expect(onConnectAccount).toHaveBeenCalledWith('apple'); expect(onDeleteAccount).toHaveBeenCalledOnce(); }); - it('renders provider images in icon frames', () => { + it('renders connected provider and Web3 images inside icon frames', () => { const { container } = renderView({ connectedAccounts: [{ id: 'google', provider: 'Google', iconUrl: '/google.svg' }], web3Wallets: [{ id: 'metamask', provider: 'MetaMask', iconUrl: '/metamask.svg' }], @@ -242,6 +249,7 @@ describe('UserProfileProfilePanelView', () => { const frames = container.querySelectorAll('.cl-icon-frame'); const images = container.querySelectorAll('img'); expect(frames).toHaveLength(2); + expect(screen.queryByRole('img', { name: 'Google' })).not.toBeInTheDocument(); expect(frames[0]).toContainElement(images[0]); expect(frames[1]).toContainElement(images[1]); frames.forEach(frame => expect(frame.closest('.cl-section-media')).toHaveAttribute('data-size', 'lg')); @@ -459,6 +467,12 @@ describe('UserProfileProfilePanelView', () => { const removeConnectedAccount = screen.getByRole('menuitem', { name: 'Remove' }); expect(removeConnectedAccount).toHaveAttribute('data-color', 'negative'); await user.click(removeConnectedAccount); + expect(onRemoveConnectedAccount).not.toHaveBeenCalled(); + await user.click( + within(screen.getByRole('alertdialog', { name: 'Remove connected account' })).getByRole('button', { + name: 'Remove', + }), + ); expect(onRemoveConnectedAccount).toHaveBeenCalledWith('github'); }); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-account-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-account-row.view.tsx new file mode 100644 index 00000000000..e2b1b7152a4 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-account-row.view.tsx @@ -0,0 +1,115 @@ +import * as stylex from '@stylexjs/stylex'; +import { useState } from 'react'; + +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Icon, IconFrame } from '../../components/icon'; +import { Section } from '../../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { userProfileConnectedAccountsMessages as m } from './user-profile-connected-accounts.messages'; +import { styles } from './user-profile-connected-accounts.styles'; +import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view'; +import { UserProfileRemoveConnectedAccountDialog } from './user-profile-remove-connected-account.dialog'; + +export function UserProfileConnectedAccountRowView({ + account, + onConnect, + onReconnect, + onRemove, +}: { + account: UserProfileConnectedAccount; + onConnect?: (id: string) => void; + onReconnect?: (id: string) => void; + onRemove?: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + const iconUrl = account.iconUrl?.trim(); + const actions: UserProfileMenuAction[] = []; + if (account.status === 'reconnect' && onReconnect) { + actions.push({ label: m.reconnect, onClick: () => onReconnect(account.id) }); + } + if (onRemove && account.canRemove !== false) { + actions.push({ label: m.remove, color: 'negative', onClick: () => setOpen(true) }); + } + return ( + + + + + {iconUrl ? ( + + ) : ( + + {account.provider.trim().charAt(0).toUpperCase()} + + )} + + + + + {account.provider} + {account.status === 'reconnect' ? {m.disconnected} : null} + + {account.identifier ? ( + + {account.identifier} + + ) : null} + + {onConnect ? ( + + + + ) : actions.length > 0 ? ( + + + {onRemove && account.canRemove !== false ? ( + onRemove(account.id)} + isPending={account.isRemoving} + errorMessage={account.removalError} + /> + ) : null} + + + ) : null} + + {account.connectError ? {account.connectError} : null} + {account.reconnectError ? {account.reconnectError} : null} + {account.status === 'error' && account.verificationError ? ( + {account.verificationError} + ) : null} + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx index f0b4b990981..5a951ec4fe0 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx @@ -1,80 +1,64 @@ -import { Button } from '../../components/button'; -import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import type { UserProfileMenuAction } from './user-profile-action-menu'; -import { UserProfileActionMenu } from './user-profile-action-menu'; -import { UserProfileProviderIcon } from './user-profile-provider-icon'; +import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view'; +import { userProfileConnectedAccountsMessages as m } from './user-profile-connected-accounts.messages'; -export interface UserProfileConnectedAccount { +export interface UserProfileConnectionProvider { id: string; provider: string; - identifier?: string; iconUrl?: string; - connected?: boolean; + connectError?: string; +} + +export interface UserProfileConnectedAccount extends UserProfileConnectionProvider { + identifier?: string; canRemove?: boolean; + status?: 'connected' | 'reconnect' | 'error'; + verificationError?: string; + reconnectError?: string; + isRemoving?: boolean; + removalError?: string; } export interface UserProfileConnectedAccountsSectionViewProps { accounts: UserProfileConnectedAccount[]; + availableProviders?: UserProfileConnectionProvider[]; onConnect?: (id: string) => void; - onManage?: (id: string) => void; + onReconnect?: (id: string) => void; onRemove?: (id: string) => void; } export function UserProfileConnectedAccountsSectionView({ accounts, + availableProviders = [], onConnect, - onManage, + onReconnect, onRemove, }: UserProfileConnectedAccountsSectionViewProps) { + if (accounts.length === 0 && (availableProviders.length === 0 || !onConnect)) { + return null; + } + return ( - Connected accounts + {m.title} - {accounts.map(account => { - const connected = account.connected ?? Boolean(account.identifier); - const actions: UserProfileMenuAction[] = []; - if (onRemove && account.canRemove !== false) { - actions.push({ label: 'Remove', color: 'negative', onClick: () => onRemove(account.id) }); - } else if (!onRemove && onManage) { - actions.push({ label: 'Manage', onClick: () => onManage(account.id) }); - } - - return ( - - - {account.iconUrl ? : null} - - {account.provider} - {account.identifier ? {account.identifier} : null} - - - {connected ? ( - - ) : null} - {!connected && onConnect ? ( - - ) : null} - - - - ); - })} + {accounts.map(account => ( + + ))} + {onConnect + ? availableProviders.map(provider => ( + + )) + : null} ); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts.messages.ts new file mode 100644 index 00000000000..1efd85fdc17 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts.messages.ts @@ -0,0 +1,17 @@ +export const userProfileConnectedAccountsMessages = { + title: 'Connected accounts', + connect: 'Connect', + connectLabel: 'Connect {provider}', + reconnect: 'Reconnect', + disconnected: 'Disconnected', + manageLabel: 'Manage {provider}', + remove: 'Remove', + removeDialog: { + title: 'Remove connected account', + description: + '{provider} will be removed from this account. You will no longer be able to use this connected account and any dependent features will no longer work.', + confirm: 'Remove', + pending: 'Removing…', + cancel: 'Cancel', + }, +}; diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts.styles.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts.styles.ts new file mode 100644 index 00000000000..1cc5a4d1947 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts.styles.ts @@ -0,0 +1,21 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + connectRow: { justifyContent: 'center' }, + icon: { display: 'block', objectFit: 'contain', height: space['6'], width: space['6'] }, + fallback: { + borderRadius: radiusVars['--cl-radius-sm'], + alignItems: 'center', + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, + color: colorVars['--cl-color-neutral'], + display: 'inline-flex', + fontSize: '0.625rem', + justifyContent: 'center', + height: space['6'], + width: space['6'], + }, + label: { gap: space['2'], alignItems: 'center', display: 'flex', flexWrap: 'wrap' }, + text: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }, +}); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx index 774c0681390..f80433f4901 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx @@ -9,7 +9,10 @@ import type { UserProfilePhone, } from './user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from './user-profile-account-section/user-profile-account-section.view'; -import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view'; +import type { + UserProfileConnectedAccount, + UserProfileConnectionProvider, +} from './user-profile-connected-accounts-section.view'; import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; import { styles } from './user-profile-profile-panel.styles'; @@ -25,9 +28,10 @@ export type { UserProfileEditNameValue } from './user-profile-account-section/us export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { connectedAccounts?: UserProfileConnectedAccount[]; + availableConnectionProviders?: UserProfileConnectionProvider[]; + onReconnectAccount?: (id: string) => void; web3Wallets?: UserProfileWeb3Wallet[]; onConnectAccount?: (id: string) => void; - onManageConnectedAccount?: (id: string) => void; onRemoveConnectedAccount?: (id: string) => void; onConnectWeb3Wallet?: (id: string) => void; onManageWeb3Wallet?: (id: string) => void; @@ -50,6 +54,8 @@ export function UserProfileProfilePanelView({ emails = [], phones = [], connectedAccounts = [], + availableConnectionProviders = [], + onReconnectAccount, web3Wallets = [], onProfilePictureChange, onProfilePictureReject, @@ -70,7 +76,6 @@ export function UserProfileProfilePanelView({ onSetPrimaryPhone, onRemovePhone, onConnectAccount, - onManageConnectedAccount, onRemoveConnectedAccount, onConnectWeb3Wallet, onManageWeb3Wallet, @@ -113,11 +118,12 @@ export function UserProfileProfilePanelView({ onSubmitName={onSubmitName} onSubmitUsername={onSubmitUsername} /> - {connectedAccounts.length > 0 ? ( + {connectedAccounts.length > 0 || (availableConnectionProviders.length > 0 && onConnectAccount) ? ( ) : null} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-connected-account.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-connected-account.dialog.tsx new file mode 100644 index 00000000000..2664ea40c4f --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-connected-account.dialog.tsx @@ -0,0 +1,64 @@ +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Dialog } from '../../components/dialog'; +import { Heading } from '../../components/heading'; +import { Text } from '../../components/text'; +import { userProfileConnectedAccountsMessages as m } from './user-profile-connected-accounts.messages'; + +export interface UserProfileRemoveConnectedAccountDialogProps { + provider: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isPending?: boolean; + errorMessage?: string; +} + +export function UserProfileRemoveConnectedAccountDialog({ + provider, + open, + onOpenChange, + onConfirm, + isPending, + errorMessage, +}: UserProfileRemoveConnectedAccountDialogProps) { + return ( + + + }>{m.removeDialog.title} + }> + {m.removeDialog.description.replace('{provider}', provider)} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + } + > + {m.removeDialog.cancel} + + + {m.removeDialog.confirm} + + + + + ); +}