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 };