diff --git a/.changeset/curvy-years-strive.md b/.changeset/curvy-years-strive.md new file mode 100644 index 00000000000..ff0089cb02b --- /dev/null +++ b/.changeset/curvy-years-strive.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +UserProfile should show attributes enabled for sign in diff --git a/packages/ui/src/components/UserProfile/AccountPage.tsx b/packages/ui/src/components/UserProfile/AccountPage.tsx index 01efd16cf33..d328612de49 100644 --- a/packages/ui/src/components/UserProfile/AccountPage.tsx +++ b/packages/ui/src/components/UserProfile/AccountPage.tsx @@ -12,6 +12,7 @@ import { EnterpriseAccountsSection } from './EnterpriseAccountsSection'; import { PhoneSection } from './PhoneSection'; import { UsernameSection } from './UsernameSection'; import { UserProfileSection } from './UserProfileSection'; +import { isAttributeAvailable } from './utils'; import { Web3Section } from './Web3Section'; export const AccountPage = withCardStateProvider(() => { @@ -20,9 +21,9 @@ export const AccountPage = withCardStateProvider(() => { const { user } = useUser(); const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); - const showUsername = attributes.username?.enabled; - const showEmail = attributes.email_address?.enabled; - const showPhone = attributes.phone_number?.enabled; + const showUsername = isAttributeAvailable(attributes.username); + const showEmail = isAttributeAvailable(attributes.email_address); + const showPhone = isAttributeAvailable(attributes.phone_number); const showConnectedAccounts = social && Object.values(social).filter(p => p.enabled).length > 0; const showEnterpriseAccounts = user && enterpriseSSO.enabled; const showWeb3 = attributes.web3_wallet?.enabled; diff --git a/packages/ui/src/components/UserProfile/EmailForm.tsx b/packages/ui/src/components/UserProfile/EmailForm.tsx index 6f7a0ee6d8c..671f35ca82e 100644 --- a/packages/ui/src/components/UserProfile/EmailForm.tsx +++ b/packages/ui/src/components/UserProfile/EmailForm.tsx @@ -18,6 +18,7 @@ import { useWizard, Wizard } from '../../common'; import { useEnvironment } from '../../contexts'; import type { LocalizationKey } from '../../localization'; import { localizationKeys } from '../../localization'; +import { isAttributeAvailable } from './utils'; import { VerifyWithCode } from './VerifyWithCode'; import { VerifyWithEnterpriseConnection } from './VerifyWithEnterpriseConnection'; import { VerifyWithLink } from './VerifyWithLink'; @@ -138,7 +139,7 @@ const getTranslationKeyByStrategy = (strategy: PrepareEmailAddressVerificationPa function isEmailLinksEnabledForInstance(env: EnvironmentResource): boolean { const { userSettings } = env; const { email_address } = userSettings.attributes; - return Boolean(email_address?.enabled && email_address?.verifications.includes('email_link')); + return Boolean(isAttributeAvailable(email_address) && email_address?.verifications.includes('email_link')); } /** diff --git a/packages/ui/src/components/UserProfile/__tests__/AccountPage.test.tsx b/packages/ui/src/components/UserProfile/__tests__/AccountPage.test.tsx index bdef0fbc4c9..b04bf15a390 100644 --- a/packages/ui/src/components/UserProfile/__tests__/AccountPage.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/AccountPage.test.tsx @@ -68,6 +68,40 @@ describe('AccountPage', () => { expect(queryByText(/Enterprise Accounts/i)).not.toBeInTheDocument(); }); + it('shows sections for attributes disabled for sign-up but used for first factor', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress({ enabled: false, used_for_first_factor: true }); + f.withPhoneNumber({ enabled: false, used_for_first_factor: true }); + f.withUsername({ enabled: false, used_for_first_factor: true }); + f.withUser({ + first_name: 'George', + last_name: 'Clerk', + email_addresses: ['test@clerk.com'], + phone_numbers: ['+11234567890'], + username: 'georgeclerk', + }); + }); + + render(, { wrapper }); + screen.getByText(/Email addresses/i); + screen.getByText(/Phone numbers/i); + screen.getByText('georgeclerk'); + }); + + it('shows phone section when disabled for sign-up but used for second factor', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber({ enabled: false, used_for_first_factor: false, used_for_second_factor: true }); + f.withUser({ + first_name: 'George', + last_name: 'Clerk', + phone_numbers: ['+11234567890'], + }); + }); + + render(, { wrapper }); + screen.getByText(/Phone numbers/i); + }); + it('shows the connected accounts of the user', async () => { const { wrapper } = await createFixtures(f => { f.withSocialProvider({ provider: 'google' }); diff --git a/packages/ui/src/components/UserProfile/__tests__/EmailsSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/EmailsSection.test.tsx index c98e79194f2..fd5d9ee4c49 100644 --- a/packages/ui/src/components/UserProfile/__tests__/EmailsSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/EmailsSection.test.tsx @@ -95,6 +95,42 @@ describe('EmailSection', () => { }); }); + describe('Add email with attribute disabled for sign-up but used for sign-in', () => { + const disabledForSignUpConfig = createFixtures.config(f => { + f.withEmailAddress({ enabled: false, used_for_first_factor: true }); + f.withUser({ username: 'georgeclerk' }); + }); + + it('renders add email screen', async () => { + const { wrapper } = await createFixtures(disabledForSignUpConfig); + + const { getByRole, userEvent, getByLabelText, findByRole } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Add email address' })); + await findByRole('heading', { name: /Add email address/i }); + getByLabelText(/email address/i); + }); + + it('can add an email and reach the verification screen', async () => { + const { wrapper, fixtures } = await createFixtures(disabledForSignUpConfig); + + const { getByRole, userEvent, getByLabelText, findByRole } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Add email address' })); + await findByRole('heading', { name: /Add email address/i }); + + fixtures.clerk.user?.createEmailAddress.mockReturnValueOnce( + Promise.resolve({ + emailAddress: 'test+2@clerk.com', + prepareVerification: vi.fn().mockReturnValueOnce(Promise.resolve({} as any)), + } as any), + ); + + await userEvent.type(getByLabelText(/email address/i), 'test+2@clerk.com'); + await userEvent.click(getByRole('button', { name: /add$/i })); + expect(fixtures.clerk.user?.createEmailAddress).toHaveBeenCalledWith({ email: 'test+2@clerk.com' }); + await findByRole('heading', { name: /Verify email address/i }); + }); + }); + describe('Remove email', () => { it('Renders remove screen', async () => { const { wrapper } = await createFixtures(withEmails); diff --git a/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx index a2734dd0871..3b2776dc5a3 100644 --- a/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx @@ -1,5 +1,5 @@ import { act } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen } from '@/test/utils'; @@ -335,5 +335,39 @@ describe('PhoneSection', () => { }); }); - it.todo('Test for verification of added phone number'); + describe('Add phone with attribute disabled for sign-up but used for sign-in', () => { + const disabledForSignUpConfig = createFixtures.config(f => { + f.withPhoneNumber({ enabled: false, used_for_first_factor: true }); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + it('renders add phone screen', async () => { + const { wrapper } = await createFixtures(disabledForSignUpConfig); + + const { getByRole, userEvent, getByLabelText, findByRole } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Add phone number' })); + await findByRole('heading', { name: /Add phone number/i }); + getByLabelText(/phone number/i); + }); + + it('can add a phone and reach the verification screen', async () => { + const { wrapper, fixtures } = await createFixtures(disabledForSignUpConfig); + + const { getByRole, userEvent, getByLabelText, findByRole } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Add phone number' })); + await findByRole('heading', { name: /Add phone number/i }); + + fixtures.clerk.user?.createPhoneNumber.mockReturnValueOnce( + Promise.resolve({ + phoneNumber: '+16911111111', + prepareVerification: vi.fn().mockReturnValueOnce(Promise.resolve({} as any)), + } as any), + ); + + await userEvent.type(getByLabelText(/phone number/i), '6911111111'); + await userEvent.click(getByRole('button', { name: /add$/i })); + expect(fixtures.clerk.user?.createPhoneNumber).toHaveBeenCalledWith({ phoneNumber: '+16911111111' }); + await findByRole('heading', { name: /Verify phone number/i }); + }); + }); }); diff --git a/packages/ui/src/components/UserProfile/utils.ts b/packages/ui/src/components/UserProfile/utils.ts index b1565b08ed9..13ca67cce73 100644 --- a/packages/ui/src/components/UserProfile/utils.ts +++ b/packages/ui/src/components/UserProfile/utils.ts @@ -1,4 +1,14 @@ -import type { EmailAddressResource, PhoneNumberResource, Web3WalletResource } from '@clerk/shared/types'; +import type { AttributeData, EmailAddressResource, PhoneNumberResource, Web3WalletResource } from '@clerk/shared/types'; + +/** + * An attribute is "available" in the UserProfile if it's enabled for sign-up + * OR used as a first/second factor for sign-in. This covers instances where + * an attribute is disabled for sign-up but still used for authentication + * (e.g. accounts provisioned exclusively by invitation). + */ +export function isAttributeAvailable(attr: AttributeData | undefined): boolean { + return Boolean(attr?.enabled || attr?.used_for_first_factor || attr?.used_for_second_factor); +} type IDable = { id: string };