From 68b89170a2d4c475e37056e6d5b59c497c45cfb3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Feb 2025 03:25:13 -0500 Subject: [PATCH] fix(clerk-js): Ensure only one action is present in UserProfile sections (#5030) --- .changeset/seven-games-arrive.md | 5 ++ .../UserProfile/ConnectedAccountsMenu.tsx | 5 +- .../UserProfile/ConnectedAccountsSection.tsx | 23 +++-- .../components/UserProfile/EmailsSection.tsx | 77 ++++++++-------- .../ui/components/UserProfile/MfaSection.tsx | 41 +++++---- .../components/UserProfile/PasskeySection.tsx | 71 ++++++++------- .../components/UserProfile/PhoneSection.tsx | 43 ++++----- .../ui/components/UserProfile/Web3Form.tsx | 3 +- .../ui/components/UserProfile/Web3Section.tsx | 87 ++++++++++--------- .../ConnectedAccountsSection.test.tsx | 24 +++++ .../__tests__/EmailsSection.test.tsx | 58 +++++++++++++ .../UserProfile/__tests__/MfaPage.test.tsx | 47 ++++++++++ .../__tests__/PasskeysSection.test.tsx | 28 ++++++ .../__tests__/PhoneSection.test.tsx | 58 +++++++++++++ .../src/ui/elements/Action/ActionRoot.tsx | 35 ++++++-- packages/clerk-js/src/ui/elements/Section.tsx | 4 +- 16 files changed, 445 insertions(+), 164 deletions(-) create mode 100644 .changeset/seven-games-arrive.md diff --git a/.changeset/seven-games-arrive.md b/.changeset/seven-games-arrive.md new file mode 100644 index 00000000000..dbeb0bb3baa --- /dev/null +++ b/.changeset/seven-games-arrive.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Ensure only one action is open per section within `UserProfile`. diff --git a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx index d1fdbf607d3..a621d9b0c46 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx @@ -10,7 +10,7 @@ import { useEnabledThirdPartyProviders } from '../../hooks'; import { useRouter } from '../../router'; import { handleError, sleep } from '../../utils'; -const ConnectMenuButton = (props: { strategy: OAuthStrategy }) => { +const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => void }) => { const { strategy } = props; const card = useCardState(); const { user } = useUser(); @@ -95,7 +95,7 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy }) => { ); }; -export const AddConnectedAccount = () => { +export const AddConnectedAccount = ({ onClick }: { onClick?: () => void }) => { const { user } = useUser(); const { strategies } = useEnabledThirdPartyProviders(); @@ -114,6 +114,7 @@ export const AddConnectedAccount = () => { {unconnectedStrategies.map(strategy => ( (null); if (!user || (!shouldAllowCreation && !hasExternalAccounts)) { return null; @@ -68,7 +70,10 @@ export const ConnectedAccountsSection = withCardStateProvider( id='connectedAccounts' > {card.error} - + {accounts.map(account => ( ))} - {shouldAllowCreation && } + {shouldAllowCreation && setActionValue(null)} />} ); @@ -89,6 +94,7 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => const { navigate } = useRouter(); const { user } = useUser(); const card = useCardState(); + const accountId = account.id; const isModal = mode === 'modal'; const redirectUrl = isModal @@ -158,7 +164,7 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => ); return ( - + ({ overflow: 'hidden', gap: t.space.$2 })}> @@ -181,7 +187,7 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => - + {shouldDisplayReconnect && ( )} - + - + ); }; -const ConnectedAccountMenu = () => { +const ConnectedAccountMenu = ({ account }: { account: ExternalAccountResource }) => { const { open } = useActionContext(); + const accountId = account.id; const actions = ( [ { label: localizationKeys('userProfile.start.connectedAccountsSection.destructiveActionTitle'), isDestructive: true, - onClick: () => open('remove'), + onClick: () => open(`remove-${accountId}`), }, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; diff --git a/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx index 3f4558e7178..22c1b2f39aa 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx @@ -1,5 +1,6 @@ import { useUser } from '@clerk/shared/react'; import type { EmailAddressResource } from '@clerk/types'; +import { Fragment } from 'react'; import { sortIdentificationBasedOnVerification } from '../../components/UserProfile/utils'; import { Badge, Flex, localizationKeys, Text } from '../../customizables'; @@ -46,39 +47,42 @@ export const EmailsSection = ({ shouldAllowCreation = true }) => { > - {sortIdentificationBasedOnVerification(user?.emailAddresses, user?.primaryEmailAddressId).map(email => ( - - - ({ overflow: 'hidden', gap: t.space.$1 })}> - ({ color: t.colors.$colorText })} - truncate - > - {email.emailAddress} - - {user?.primaryEmailAddressId === email.id && ( - - )} - {email.verification.status !== 'verified' && ( - - )} - - - + {sortIdentificationBasedOnVerification(user?.emailAddresses, user?.primaryEmailAddressId).map(email => { + const emailId = email.id; + return ( + + + ({ overflow: 'hidden', gap: t.space.$1 })}> + ({ color: t.colors.$colorText })} + truncate + > + {email.emailAddress} + + {user?.primaryEmailAddressId === emailId && ( + + )} + {email.verification.status !== 'verified' && ( + + )} + + + - - - - - + + + + + - - - - - - - ))} + + + + + + + ); + })} {shouldAllowCreation && ( <> @@ -104,10 +108,11 @@ const EmailMenu = ({ email }: { email: EmailAddressResource }) => { const card = useCardState(); const { user } = useUser(); const { open } = useActionContext(); - const isPrimary = user?.primaryEmailAddressId === email.id; + const emailId = email.id; + const isPrimary = user?.primaryEmailAddressId === emailId; const isVerified = email.verification.status === 'verified'; const setPrimary = () => { - return user?.update({ primaryEmailAddressId: email.id }).catch(e => handleError(e, [], card.setError)); + return user?.update({ primaryEmailAddressId: emailId }).catch(e => handleError(e, [], card.setError)); }; const actions = ( @@ -115,7 +120,7 @@ const EmailMenu = ({ email }: { email: EmailAddressResource }) => { isPrimary && !isVerified ? { label: localizationKeys('userProfile.start.emailAddressesSection.detailsAction__primary'), - onClick: () => open('verify'), + onClick: () => open(`verify-${emailId}`), } : null, !isPrimary && isVerified @@ -127,13 +132,13 @@ const EmailMenu = ({ email }: { email: EmailAddressResource }) => { !isPrimary && !isVerified ? { label: localizationKeys('userProfile.start.emailAddressesSection.detailsAction__unverified'), - onClick: () => open('verify'), + onClick: () => open(`verify-${emailId}`), } : null, { label: localizationKeys('userProfile.start.emailAddressesSection.destructiveAction'), isDestructive: true, - onClick: () => open('remove'), + onClick: () => open(`remove-${emailId}`), }, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; diff --git a/packages/clerk-js/src/ui/components/UserProfile/MfaSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/MfaSection.tsx index f00a9544a0f..d694796e262 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/MfaSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/MfaSection.tsx @@ -1,6 +1,6 @@ import { useUser } from '@clerk/shared/react'; import type { PhoneNumberResource, VerificationStrategy } from '@clerk/types'; -import React, { useState } from 'react'; +import React, { Fragment, useState } from 'react'; import { useEnvironment } from '../../contexts'; import { Badge, Flex, Icon, localizationKeys, Text } from '../../customizables'; @@ -19,6 +19,7 @@ export const MfaSection = () => { userSettings: { attributes }, } = useEnvironment(); const { user } = useUser(); + const [actionValue, setActionValue] = useState(null); if (!user) { return null; @@ -41,10 +42,13 @@ export const MfaSection = () => { centered={false} id='mfa' > - + {showTOTP && ( - + <> { - + - + )} {secondFactors.includes('phone_code') && mfaPhones.map(phone => { const isDefault = !showTOTP && phone.defaultSecondFactor; + const phoneId = phone.id; return ( - + { /> - + - + - + ); })} {showBackupCode && ( - + <> { - + )} - + setActionValue(null)} + /> @@ -147,6 +155,7 @@ type MfaPhoneCodeMenuProps = { const MfaPhoneCodeMenu = ({ phone, showTOTP }: MfaPhoneCodeMenuProps) => { const { open } = useActionContext(); const card = useCardState(); + const phoneId = phone.id; const actions = ( [ @@ -160,7 +169,7 @@ const MfaPhoneCodeMenu = ({ phone, showTOTP }: MfaPhoneCodeMenuProps) => { { label: localizationKeys('userProfile.start.mfaSection.phoneCode.destructiveActionLabel'), isDestructive: true, - onClick: () => open('remove'), + onClick: () => open(`remove-${phoneId}`), }, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; @@ -191,7 +200,7 @@ const MfaTOTPMenu = () => { { label: localizationKeys('userProfile.start.mfaSection.totp.destructiveActionTitle'), isDestructive: true, - onClick: () => open('remove'), + onClick: () => open('remove-totp'), }, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; @@ -201,11 +210,12 @@ const MfaTOTPMenu = () => { type MfaAddMenuProps = ProfileSectionActionMenuItemProps & { secondFactorsAvailableToAdd: string[]; + onClick?: () => void; }; const MfaAddMenu = (props: MfaAddMenuProps) => { const { open } = useActionContext(); - const { secondFactorsAvailableToAdd } = props; + const { secondFactorsAvailableToAdd, onClick } = props; const [selectedStrategy, setSelectedStrategy] = useState(); const strategies = React.useMemo( @@ -245,6 +255,7 @@ const MfaAddMenu = (props: MfaAddMenuProps) => { {strategies.map( method => diff --git a/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx b/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx index 940a6b2ae23..6f327377d99 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx @@ -1,6 +1,6 @@ import { useClerk, useReverification, useUser } from '@clerk/shared/react'; import type { PasskeyResource } from '@clerk/types'; -import React from 'react'; +import React, { Fragment, useState } from 'react'; import { Col, Flex, localizationKeys, Text, useLocalizations } from '../../customizables'; import { @@ -89,6 +89,7 @@ export const UpdatePasskeyForm = withCardStateProvider((props: UpdatePasskeyForm export const PasskeySection = () => { const { user } = useUser(); + const [actionValue, setActionValue] = useState(null); if (!user) { return null; @@ -100,30 +101,38 @@ export const PasskeySection = () => { centered={false} id='passkeys' > - - {user.passkeys.map(passkey => ( - - - - - - - - - - - - - - - - ))} - - - + + + {user.passkeys.map(passkey => { + const passkeyId = passkey.id; + return ( + + + + + + + + + + + + + + + + ); + })} + + setActionValue(null)} /> + + ); }; @@ -138,7 +147,7 @@ const PasskeyItem = (props: PasskeyResource) => { }} > - + ); }; @@ -168,18 +177,19 @@ const PasskeyInfo = (props: PasskeyResource) => { ); }; -const ActiveDeviceMenu = () => { +const ActiveDeviceMenu = ({ passkey }: { passkey: PasskeyResource }) => { const { open } = useActionContext(); + const passkeyId = passkey.id; const actions = [ { label: localizationKeys('userProfile.start.passkeysSection.menuAction__rename'), - onClick: () => open('rename'), + onClick: () => open(`rename-${passkeyId}`), }, { label: localizationKeys('userProfile.start.passkeysSection.menuAction__destructive'), isDestructive: true, - onClick: () => open('remove'), + onClick: () => open(`remove-${passkeyId}`), }, ] satisfies PropsOfComponent['actions']; @@ -187,13 +197,14 @@ const ActiveDeviceMenu = () => { }; // TODO-PASSKEYS: Should the error be scope to the section ? -const AddPasskeyButton = () => { +const AddPasskeyButton = ({ onClick }: { onClick?: () => void }) => { const card = useCardState(); const { isSatellite } = useClerk(); const { user } = useUser(); const [createPasskey] = useReverification(() => user?.createPasskey()); const handleCreatePasskey = async () => { + onClick?.(); if (!user) { return; } diff --git a/packages/clerk-js/src/ui/components/UserProfile/PhoneSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/PhoneSection.tsx index ea98b20f3a4..2e0a84e2479 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PhoneSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PhoneSection.tsx @@ -1,5 +1,6 @@ import { useUser } from '@clerk/shared/react'; import type { PhoneNumberResource } from '@clerk/types'; +import { Fragment } from 'react'; import { Badge, Box, Flex, localizationKeys, Text } from '../../customizables'; import { ProfileSection, ThreeDotsMenu, useCardState } from '../../elements'; @@ -51,9 +52,10 @@ export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreati > - {sortIdentificationBasedOnVerification(user?.phoneNumbers, user?.primaryPhoneNumberId).map(phone => ( - - + {sortIdentificationBasedOnVerification(user?.phoneNumbers, user?.primaryPhoneNumberId).map(phone => { + const phoneId = phone.id; + return ( + ({ color: t.colors.$colorText })}> {stringToFormattedPhoneString(phone.phoneNumber)} - {user?.primaryPhoneNumberId === phone.id && ( + {user?.primaryPhoneNumberId === phoneId && ( )} {phone.verification.status !== 'verified' && ( @@ -74,21 +76,21 @@ export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreati - - - - - - + + + + + - - - - - - - ))} + + + + + + + ); + })} {shouldAllowCreation && ( <> @@ -114,6 +116,7 @@ const PhoneMenu = ({ phone }: { phone: PhoneNumberResource }) => { const card = useCardState(); const { open } = useActionContext(); const { user } = useUser(); + const phoneId = phone.id; if (!user) { return null; @@ -131,7 +134,7 @@ const PhoneMenu = ({ phone }: { phone: PhoneNumberResource }) => { ? { label: localizationKeys('userProfile.start.phoneNumbersSection.detailsAction__primary'), // TODO-STEPUP: Is this a sensitive action ? - onClick: () => open('verify'), + onClick: () => open(`verify-${phoneId}`), } : null, !isPrimary && isVerified @@ -144,13 +147,13 @@ const PhoneMenu = ({ phone }: { phone: PhoneNumberResource }) => { !isPrimary && !isVerified ? { label: localizationKeys('userProfile.start.phoneNumbersSection.detailsAction__unverified'), - onClick: () => open('verify'), + onClick: () => open(`verify-${phoneId}`), } : null, { label: localizationKeys('userProfile.start.phoneNumbersSection.destructiveAction'), isDestructive: true, - onClick: () => open('remove'), + onClick: () => open(`remove-${phoneId}`), }, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; diff --git a/packages/clerk-js/src/ui/components/UserProfile/Web3Form.tsx b/packages/clerk-js/src/ui/components/UserProfile/Web3Form.tsx index 7992b5e89d3..f6f0ba20f39 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/Web3Form.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/Web3Form.tsx @@ -7,7 +7,7 @@ import { ProfileSection, useCardState, withCardStateProvider } from '../../eleme import { useEnabledThirdPartyProviders } from '../../hooks'; import { getFieldError, handleError } from '../../utils'; -export const AddWeb3WalletActionMenu = withCardStateProvider(() => { +export const AddWeb3WalletActionMenu = withCardStateProvider(({ onClick }: { onClick?: () => void }) => { const card = useCardState(); const { user } = useUser(); const { strategies, strategyToDisplayData } = useEnabledThirdPartyProviders(); @@ -56,6 +56,7 @@ export const AddWeb3WalletActionMenu = withCardStateProvider(() => { {unconnectedStrategies.map(strategy => ( (null); if (!shouldAllowCreation && !hasWeb3Wallets) { return null; @@ -45,74 +47,75 @@ export const Web3Section = withCardStateProvider( id='web3Wallets' > {card.error} - + {user?.web3Wallets.map(wallet => { const strategy = wallet.verification.strategy as keyof typeof strategyToDisplayData; - + const walletId = wallet.id; return ( strategyToDisplayData[strategy] && ( - - - - ({ alignItems: 'center', gap: t.space.$2, width: '100%' })}> - {strategyToDisplayData[strategy].iconUrl && ( - {strategyToDisplayData[strategy].name} ({ width: theme.sizes.$4 })} - /> - )} - - - - {strategyToDisplayData[strategy].name} ({shortenWeb3Address(wallet.web3Wallet)}) - - {user?.primaryWeb3WalletId === wallet.id && ( - - )} - {wallet.verification.status !== 'verified' && ( - - )} - - - - - - + + + ({ alignItems: 'center', gap: t.space.$2, width: '100%' })}> + {strategyToDisplayData[strategy].iconUrl && ( + {strategyToDisplayData[strategy].name} ({ width: theme.sizes.$4 })} + /> + )} + + + + {strategyToDisplayData[strategy].name} ({shortenWeb3Address(wallet.web3Wallet)}) + + {user?.primaryWeb3WalletId === walletId && ( + + )} + {wallet.verification.status !== 'verified' && ( + + )} + + + + + - + - + ) ); })} - {shouldAllowCreation && } + {shouldAllowCreation && setActionValue(null)} />} ); }, ); -const Web3WalletMenu = () => { +const Web3WalletMenu = ({ walletId }: { walletId: string }) => { const { open } = useActionContext(); const actions = ( [ { label: localizationKeys('userProfile.start.web3WalletsSection.destructiveAction'), - onClick: () => open('remove'), + onClick: () => open(`remove-${walletId}`), }, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx index e161498ad30..c5448ccda39 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx @@ -276,4 +276,28 @@ describe('ConnectedAccountsSection ', () => { expect(queryByRole('heading', { name: /Remove connected account/i })).not.toBeInTheDocument(); }); }); + + describe('Handles opening/closing actions', () => { + it('closes remove account form when connect account action is clicked', async () => { + const { wrapper } = await createFixtures(withSomeConnections); + const { userEvent, getByText, getByRole, queryByRole } = render(, { wrapper }); + + const item = getByText(/google/i); + const menuButton = item.parentElement?.parentElement?.parentElement?.parentElement?.children?.[1]; + await act(async () => { + await userEvent.click(menuButton!); + }); + getByRole('menuitem', { name: /remove/i }); + await userEvent.click(getByRole('menuitem', { name: /remove/i })); + await waitFor(() => getByRole('heading', { name: /remove connected account/i })); + + await expect(queryByRole('heading', { name: /remove connected account/i })).toBeInTheDocument(); + + await userEvent.click(getByRole('button', { name: /connect account/i })); + + await waitFor(() => + expect(queryByRole('heading', { name: /remove connected account/i })).not.toBeInTheDocument(), + ); + }); + }); }); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/EmailsSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/EmailsSection.test.tsx index acbd48ac551..0b01f1de4e9 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/EmailsSection.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/EmailsSection.test.tsx @@ -187,4 +187,62 @@ describe('EmailSection', () => { }); }); }); + + describe('Handles opening/closing actions', () => { + it('closes add email form when remove an email address action is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(withEmails); + const { getByText, userEvent, getByRole, queryByRole } = render( + + + , + { wrapper }, + ); + + fixtures.clerk.user?.emailAddresses[0].destroy.mockResolvedValue(); + + await userEvent.click(getByRole('button', { name: /add email address/i })); + await waitFor(() => getByRole('heading', { name: /add email address/i })); + + const item = getByText(emails[0]); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove email/i }); + await userEvent.click(getByRole('menuitem', { name: /remove email/i })); + await waitFor(() => getByRole('heading', { name: /remove email address/i })); + + await waitFor(() => expect(queryByRole('heading', { name: /remove email address/i })).toBeInTheDocument()); + await waitFor(() => expect(queryByRole('heading', { name: /add email address/i })).not.toBeInTheDocument()); + }); + + it('closes remove email address form when add email address action is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(withEmails); + const { getByText, userEvent, getByRole, queryByRole } = render( + + + , + { wrapper }, + ); + + fixtures.clerk.user?.emailAddresses[0].destroy.mockResolvedValue(); + + const item = getByText(emails[0]); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove email/i }); + await userEvent.click(getByRole('menuitem', { name: /remove email/i })); + await waitFor(() => getByRole('heading', { name: /remove email address/i })); + + await userEvent.click(getByRole('button', { name: /add email address/i })); + await waitFor(() => getByRole('heading', { name: /add email address/i })); + + await waitFor(() => expect(queryByRole('heading', { name: /remove email address/i })).not.toBeInTheDocument()); + await waitFor(() => expect(queryByRole('heading', { name: /add email address/i })).toBeInTheDocument()); + }); + }); }); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/MfaPage.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/MfaPage.test.tsx index 76edf9aec2f..100c2793db4 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/MfaPage.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/MfaPage.test.tsx @@ -340,4 +340,51 @@ describe('MfaPage', () => { expect(fixtures.clerk.user?.disableTOTP).toHaveBeenCalled(); }); }); + + describe('Handles opening/closing actions', () => { + it('closes remove sms code form when add two-step verification action is clicked', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber({ second_factors: ['phone_code'], used_for_second_factor: true }); + f.withUser({ + phone_numbers: [ + { + phone_number: '+306911111111', + id: 'id', + reserved_for_second_factor: true, + verification: { status: 'verified', strategy: 'phone_code' } as VerificationJSON, + }, + ], + two_factor_enabled: true, + }); + }); + + const { getByText, userEvent, getByRole, queryByRole } = render( + + + , + { wrapper }, + ); + await waitFor(() => getByText('Two-step verification')); + + const itemButton = getByText(/\+30 691 1111111/i)?.parentElement?.parentElement?.parentElement?.children[1]; + + expect(itemButton).toBeDefined(); + + await act(async () => { + await userEvent.click(itemButton!); + }); + await waitFor(() => getByText(/^remove$/i)); + await userEvent.click(getByText(/^remove$/i)); + + await expect(queryByRole('heading', { name: /remove two-step verification/i })).toBeInTheDocument(); + + await act(async () => { + await userEvent.click(getByRole('button', { name: /Add two-step verification/i })); + }); + + await waitFor(() => + expect(queryByRole('heading', { name: /remove two-step verification/i })).not.toBeInTheDocument(), + ); + }); + }); }); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasskeysSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasskeysSection.test.tsx index 7c5b443c33f..e75d14e48ac 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasskeysSection.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasskeysSection.test.tsx @@ -203,4 +203,32 @@ describe('PasskeySection', () => { }); }); }); + + describe('Handles opening/closing actions', () => { + it('closes remove passkey form when add a passkey action is clicked', async () => { + const { wrapper } = await createFixtures(withPasskeys); + const { getByRole, userEvent, getByText, queryByRole } = render( + + + , + { wrapper }, + ); + + const item = getByText(passkeys[0].name); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove/i }); + await userEvent.click(getByRole('menuitem', { name: /remove/i })); + await waitFor(() => getByRole('heading', { name: /remove passkey/i })); + + await waitFor(() => expect(queryByRole('heading', { name: /remove passkey/i })).toBeInTheDocument()); + + await userEvent.click(getByRole('button', { name: /add a passkey/i })); + + await waitFor(() => expect(queryByRole('heading', { name: /remove passkey/i })).not.toBeInTheDocument()); + }); + }); }); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PhoneSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PhoneSection.test.tsx index ee48702baf5..e2be3715386 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PhoneSection.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PhoneSection.test.tsx @@ -204,5 +204,63 @@ describe('PhoneSection', () => { }); }); + describe('Handles opening/closing actions', () => { + it('closes add phone number form when remove an phone number action is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(withNumberCofig); + const { getByText, userEvent, getByRole, queryByRole } = render( + + + , + { wrapper }, + ); + + fixtures.clerk.user?.phoneNumbers[0].destroy.mockResolvedValue(); + + await userEvent.click(getByRole('button', { name: /add phone number/i })); + await waitFor(() => getByRole('heading', { name: /add phone number/i })); + + const item = getByText(numbers[0]); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove phone number/i }); + await userEvent.click(getByRole('menuitem', { name: /remove phone number/i })); + await waitFor(() => getByRole('heading', { name: /remove phone number/i })); + + await waitFor(() => expect(queryByRole('heading', { name: /remove phone number/i })).toBeInTheDocument()); + await waitFor(() => expect(queryByRole('heading', { name: /add phone number/i })).not.toBeInTheDocument()); + }); + + it('closes remove phone number form when add phone number action is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(withNumberCofig); + const { getByText, userEvent, getByRole, queryByRole } = render( + + + , + { wrapper }, + ); + + fixtures.clerk.user?.phoneNumbers[0].destroy.mockResolvedValue(); + + const item = getByText(numbers[0]); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove phone number/i }); + await userEvent.click(getByRole('menuitem', { name: /remove phone number/i })); + await waitFor(() => getByRole('heading', { name: /remove phone number/i })); + + await userEvent.click(getByRole('button', { name: /add phone number/i })); + await waitFor(() => getByRole('heading', { name: /add phone number/i })); + + await waitFor(() => expect(queryByRole('heading', { name: /remove phone number/i })).not.toBeInTheDocument()); + await waitFor(() => expect(queryByRole('heading', { name: /add phone number/i })).toBeInTheDocument()); + }); + }); + it.todo('Test for verification of added phone number'); }); diff --git a/packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx b/packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx index 1c83c70342e..85fe48c86ef 100644 --- a/packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx +++ b/packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx @@ -4,7 +4,11 @@ import { useCallback, useState } from 'react'; import { Animated } from '..'; -type ActionRootProps = PropsWithChildren<{ animate?: boolean }>; +type ActionRootProps = PropsWithChildren<{ + animate?: boolean; + value?: string | null; + onChange?: (value: string | null) => void; +}>; type ActionOpen = (value: string) => void; @@ -15,16 +19,29 @@ export const [ActionContext, useActionContext, _] = createContextAndHook<{ }>('ActionContext'); export const ActionRoot = (props: ActionRootProps) => { - const { animate = true, children } = props; - const [active, setActive] = useState(null); + const { animate = true, children, value: controlledValue, onChange } = props; + const [internalValue, setInternalValue] = useState(null); - const close = useCallback(() => { - setActive(null); - }, []); + const active = controlledValue !== undefined ? controlledValue : internalValue; - const open: ActionOpen = useCallback(value => { - setActive(value); - }, []); + const close = useCallback(() => { + if (onChange) { + onChange(null); + } else { + setInternalValue(null); + } + }, [onChange]); + + const open: ActionOpen = useCallback( + newValue => { + if (onChange) { + onChange(newValue); + } else { + setInternalValue(newValue); + } + }, + [onChange], + ); const body = {children}; diff --git a/packages/clerk-js/src/ui/elements/Section.tsx b/packages/clerk-js/src/ui/elements/Section.tsx index b7ab30c10d0..a043776e62d 100644 --- a/packages/clerk-js/src/ui/elements/Section.tsx +++ b/packages/clerk-js/src/ui/elements/Section.tsx @@ -268,10 +268,11 @@ type ProfileSectionActionMenuProps = { triggerLocalizationKey?: LocalizationKey; triggerSx?: ThemableCssProp; id: ProfileSectionId; + onClick?: () => void; }; export const ProfileSectionActionMenu = (props: ProfileSectionActionMenuProps) => { - const { children, triggerLocalizationKey, id, triggerSx } = props; + const { children, triggerLocalizationKey, id, triggerSx, onClick } = props; return ( ({ width: t.sizes.$4, height: t.sizes.$4 })} + onClick={onClick} />