From 44fd6f38afffeb6ba5f32881abb4bbd50d6e02e5 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 25 Feb 2026 14:48:47 -0500 Subject: [PATCH 1/3] feat(ui): Immutable attributes in UserProfile --- .changeset/bumpy-wings-travel.md | 7 ++ packages/shared/src/types/userSettings.ts | 1 + .../components/UserProfile/AccountPage.tsx | 24 +++++- .../components/UserProfile/EmailsSection.tsx | 37 +++++++-- .../components/UserProfile/PhoneSection.tsx | 37 +++++++-- .../UserProfile/UsernameSection.tsx | 25 +++++- .../__tests__/EmailsSection.test.tsx | 77 +++++++++++++++++++ .../__tests__/PhoneSection.test.tsx | 77 +++++++++++++++++++ .../__tests__/UsernameSection.test.tsx | 24 ++++++ .../ui/src/contexts/components/UserProfile.ts | 12 +++ 10 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 .changeset/bumpy-wings-travel.md diff --git a/.changeset/bumpy-wings-travel.md b/.changeset/bumpy-wings-travel.md new file mode 100644 index 00000000000..24204214708 --- /dev/null +++ b/.changeset/bumpy-wings-travel.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Prevent modification of immutable attributes in UserProfile diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index 149db66220f..f8424d1eba6 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -30,6 +30,7 @@ export type OAuthProviderSettings = { export type AttributeDataJSON = { enabled: boolean; required: boolean; + immutable?: boolean; verifications: VerificationStrategy[]; used_for_first_factor: boolean; first_factors: VerificationStrategy[]; diff --git a/packages/ui/src/components/UserProfile/AccountPage.tsx b/packages/ui/src/components/UserProfile/AccountPage.tsx index 087a0e245b4..3d1b7186855 100644 --- a/packages/ui/src/components/UserProfile/AccountPage.tsx +++ b/packages/ui/src/components/UserProfile/AccountPage.tsx @@ -18,7 +18,7 @@ export const AccountPage = withCardStateProvider(() => { const { attributes, social, enterpriseSSO } = useEnvironment().userSettings; const card = useCardState(); const { user } = useUser(); - const { shouldAllowIdentificationCreation } = useUserProfileContext(); + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); const showUsername = attributes.username?.enabled; const showEmail = attributes.email_address?.enabled; @@ -27,6 +27,12 @@ export const AccountPage = withCardStateProvider(() => { const showEnterpriseAccounts = user && enterpriseSSO.enabled; const showWeb3 = attributes.web3_wallet?.enabled; + const isEmailImmutable = immutableAttributes.has('email_address'); + const isPhoneImmutable = immutableAttributes.has('phone_number'); + const isUsernameImmutable = immutableAttributes.has('username'); + + console.log('[clerk-ui] immutableAttributes:', [...immutableAttributes]); + return ( { {card.error} - {showUsername && } - {showEmail && } - {showPhone && } + {showUsername && } + {showEmail && ( + + )} + {showPhone && ( + + )} {showConnectedAccounts && } {/*TODO-STEP-UP: Verify that these work as expected*/} diff --git a/packages/ui/src/components/UserProfile/EmailsSection.tsx b/packages/ui/src/components/UserProfile/EmailsSection.tsx index cbc75f77292..31f40774c79 100644 --- a/packages/ui/src/components/UserProfile/EmailsSection.tsx +++ b/packages/ui/src/components/UserProfile/EmailsSection.tsx @@ -39,7 +39,13 @@ const EmailScreen = (props: EmailScreenProps) => { ); }; -export const EmailsSection = ({ shouldAllowCreation = true }) => { +export const EmailsSection = ({ + shouldAllowCreation = true, + shouldAllowDeletion = true, +}: { + shouldAllowCreation?: boolean; + shouldAllowDeletion?: boolean; +}) => { const { user } = useUser(); return ( @@ -69,7 +75,10 @@ export const EmailsSection = ({ shouldAllowCreation = true }) => { )} - + @@ -107,7 +116,13 @@ export const EmailsSection = ({ shouldAllowCreation = true }) => { ); }; -const EmailMenu = ({ email }: { email: EmailAddressResource }) => { +const EmailMenu = ({ + email, + shouldAllowDeletion = true, +}: { + email: EmailAddressResource; + shouldAllowDeletion?: boolean; +}) => { const card = useCardState(); const { user } = useUser(); const { open } = useActionContext(); @@ -140,13 +155,19 @@ const EmailMenu = ({ email }: { email: EmailAddressResource }) => { onClick: () => open(`verify-${emailId}`), } : null, - { - label: localizationKeys('userProfile.start.emailAddressesSection.destructiveAction'), - isDestructive: true, - onClick: () => open(`remove-${emailId}`), - }, + shouldAllowDeletion + ? { + label: localizationKeys('userProfile.start.emailAddressesSection.destructiveAction'), + isDestructive: true, + onClick: () => open(`remove-${emailId}`), + } + : null, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; + if (actions.length === 0) { + return null; + } + return ; }; diff --git a/packages/ui/src/components/UserProfile/PhoneSection.tsx b/packages/ui/src/components/UserProfile/PhoneSection.tsx index 4f6b3585ea5..f4dc543f6f1 100644 --- a/packages/ui/src/components/UserProfile/PhoneSection.tsx +++ b/packages/ui/src/components/UserProfile/PhoneSection.tsx @@ -40,7 +40,13 @@ const PhoneScreen = (props: PhoneScreenProps) => { ); }; -export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreation?: boolean }) => { +export const PhoneSection = ({ + shouldAllowCreation = true, + shouldAllowDeletion = true, +}: { + shouldAllowCreation?: boolean; + shouldAllowDeletion?: boolean; +}) => { const { user } = useUser(); const hasPhoneNumbers = Boolean(user?.phoneNumbers?.length); @@ -78,7 +84,10 @@ export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreati - + @@ -116,7 +125,13 @@ export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreati ); }; -const PhoneMenu = ({ phone }: { phone: PhoneNumberResource }) => { +const PhoneMenu = ({ + phone, + shouldAllowDeletion = true, +}: { + phone: PhoneNumberResource; + shouldAllowDeletion?: boolean; +}) => { const card = useCardState(); const { open } = useActionContext(); const { user } = useUser(); @@ -152,13 +167,19 @@ const PhoneMenu = ({ phone }: { phone: PhoneNumberResource }) => { onClick: () => open(`verify-${phoneId}`), } : null, - { - label: localizationKeys('userProfile.start.phoneNumbersSection.destructiveAction'), - isDestructive: true, - onClick: () => open(`remove-${phoneId}`), - }, + shouldAllowDeletion + ? { + label: localizationKeys('userProfile.start.phoneNumbersSection.destructiveAction'), + isDestructive: true, + onClick: () => open(`remove-${phoneId}`), + } + : null, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; + if (actions.length === 0) { + return null; + } + return ; }; diff --git a/packages/ui/src/components/UserProfile/UsernameSection.tsx b/packages/ui/src/components/UserProfile/UsernameSection.tsx index aa2c0d98979..c8680ca30bb 100644 --- a/packages/ui/src/components/UserProfile/UsernameSection.tsx +++ b/packages/ui/src/components/UserProfile/UsernameSection.tsx @@ -18,13 +18,36 @@ const UsernameScreen = () => { ); }; -export const UsernameSection = () => { +export const UsernameSection = ({ isImmutable }: { isImmutable?: boolean }) => { const { user } = useUser(); if (!user) { return null; } + if (isImmutable) { + if (!user.username) { + return null; + } + + return ( + + + ({ color: t.colors.$colorForeground })} + > + {user.username} + + + + ); + } + return ( { }); }); + describe('Immutable email addresses', () => { + const withImmutableEmails = createFixtures.config(f => { + f.withEmailAddress({ immutable: true }); + f.withUser({ + email_addresses: ['test@clerk.com', 'test2@clerk.com'], + }); + }); + + it('hides the "Add email address" button when email is immutable', async () => { + const { wrapper } = await createFixtures(withImmutableEmails); + + const { queryByRole } = render( + + + , + { wrapper }, + ); + + expect(queryByRole('button', { name: /add email address/i })).not.toBeInTheDocument(); + }); + + it('hides the "Remove" menu action when email is immutable', async () => { + const { wrapper } = await createFixtures(withImmutableEmails); + + const { getByText, userEvent, queryByRole } = render( + + + , + { wrapper }, + ); + + const item = getByText('test@clerk.com'); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + expect(queryByRole('menuitem', { name: /remove email/i })).not.toBeInTheDocument(); + }); + + it('still shows verify and set-as-primary actions when email is immutable', async () => { + const { wrapper } = await createFixtures( + createFixtures.config(f => { + f.withEmailAddress({ immutable: true }); + f.withUser({ + email_addresses: [ + { email_address: 'primary@clerk.com', id: 'email_primary', verification: { status: 'verified' } }, + { email_address: 'secondary@clerk.com', id: 'email_secondary', verification: { status: 'verified' } }, + ], + primary_email_address_id: 'email_primary', + }); + }), + ); + + const { getByText, userEvent, getByRole } = render( + + + , + { wrapper }, + ); + + const item = getByText('secondary@clerk.com'); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /set as primary/i }); + }); + }); + 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); diff --git a/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx index 76614d4ddc1..a2734dd0871 100644 --- a/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/PhoneSection.test.tsx @@ -202,6 +202,83 @@ describe('PhoneSection', () => { }); }); + describe('Immutable phone numbers', () => { + const withImmutablePhones = createFixtures.config(f => { + f.withPhoneNumber({ immutable: true }); + f.withUser({ + phone_numbers: ['+30 691 1111111', '+30 692 2222222'], + }); + }); + + it('hides the "Add phone number" button when phone is immutable', async () => { + const { wrapper } = await createFixtures(withImmutablePhones); + + const { queryByRole } = render( + + + , + { wrapper }, + ); + + expect(queryByRole('button', { name: /add phone number/i })).not.toBeInTheDocument(); + }); + + it('hides the "Remove" menu action when phone is immutable', async () => { + const { wrapper } = await createFixtures(withImmutablePhones); + + const { getByText, userEvent, queryByRole } = render( + + + , + { wrapper }, + ); + + const item = getByText('+30 691 1111111'); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + expect(queryByRole('menuitem', { name: /remove phone number/i })).not.toBeInTheDocument(); + }); + + it('still shows verify and set-as-primary actions when phone is immutable', async () => { + const { wrapper } = await createFixtures( + createFixtures.config(f => { + f.withPhoneNumber({ immutable: true }); + f.withUser({ + phone_numbers: [ + { phone_number: '+30 691 1111111', id: 'phone_primary', verification: { status: 'verified' } }, + { phone_number: '+30 692 2222222', id: 'phone_secondary', verification: { status: 'verified' } }, + ], + primary_phone_number_id: 'phone_primary', + }); + }), + ); + + const { getByText, userEvent, getByRole } = render( + + + , + { wrapper }, + ); + + const item = getByText('+30 692 2222222'); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /set as primary/i }); + }); + }); + 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); diff --git a/packages/ui/src/components/UserProfile/__tests__/UsernameSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/UsernameSection.test.tsx index d676368ffde..b6401a266ec 100644 --- a/packages/ui/src/components/UserProfile/__tests__/UsernameSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/UsernameSection.test.tsx @@ -33,6 +33,30 @@ describe('UsernameScreen', () => { screen.getByRole('heading', { name: /Update username/i }); }); + describe('Immutable username', () => { + it('displays username as read-only when immutable and user has a username', async () => { + const { wrapper } = await createFixtures(initConfig); + + const { queryByRole } = render(, { wrapper }); + + screen.getByText('georgeclerk'); + expect(queryByRole('button', { name: /update username/i })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /set username/i })).not.toBeInTheDocument(); + }); + + it('hides the section when immutable and user has no username', async () => { + const { wrapper } = await createFixtures( + createFixtures.config(f => { + f.withUser({ username: '' }); + }), + ); + + const { container } = render(, { wrapper }); + + expect(container.innerHTML).toBe(''); + }); + }); + describe('Actions', () => { it('calls the appropriate function upon pressing save', async () => { const { wrapper, fixtures } = await createFixtures(initConfig); diff --git a/packages/ui/src/contexts/components/UserProfile.ts b/packages/ui/src/contexts/components/UserProfile.ts index 4ea08089fe3..f156dc40890 100644 --- a/packages/ui/src/contexts/components/UserProfile.ts +++ b/packages/ui/src/contexts/components/UserProfile.ts @@ -23,6 +23,7 @@ export type UserProfileContextType = UserProfileCtx & { pages: PagesType; shouldAllowIdentificationCreation: boolean; shouldShowBilling: boolean; + immutableAttributes: Set; }; export const UserProfileContext = createContext(null); @@ -71,6 +72,16 @@ export const useUserProfileContext = (): UserProfileContextType => { ); }, [user, environment.userSettings.enterpriseSSO]); + const immutableAttributes = useMemo(() => { + const result = new Set(); + for (const [name, data] of Object.entries(environment.userSettings.attributes)) { + if (data.immutable) { + result.add(name); + } + } + return result; + }, [environment.userSettings.attributes]); + return { ...ctx, pages, @@ -79,5 +90,6 @@ export const useUserProfileContext = (): UserProfileContextType => { authQueryString: '', shouldAllowIdentificationCreation, shouldShowBilling, + immutableAttributes, }; }; From d4f717d89130fc4abbe876ee97ba86c3a8f5dc8a Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 25 Feb 2026 15:33:43 -0500 Subject: [PATCH 2/3] chore: Remove leftover debugging log --- packages/ui/src/components/UserProfile/AccountPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui/src/components/UserProfile/AccountPage.tsx b/packages/ui/src/components/UserProfile/AccountPage.tsx index 3d1b7186855..01efd16cf33 100644 --- a/packages/ui/src/components/UserProfile/AccountPage.tsx +++ b/packages/ui/src/components/UserProfile/AccountPage.tsx @@ -31,8 +31,6 @@ export const AccountPage = withCardStateProvider(() => { const isPhoneImmutable = immutableAttributes.has('phone_number'); const isUsernameImmutable = immutableAttributes.has('username'); - console.log('[clerk-ui] immutableAttributes:', [...immutableAttributes]); - return ( Date: Mon, 2 Mar 2026 12:53:30 -0500 Subject: [PATCH 3/3] refactor: DRY up UsernameSection --- .../UserProfile/UsernameSection.tsx | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/components/UserProfile/UsernameSection.tsx b/packages/ui/src/components/UserProfile/UsernameSection.tsx index c8680ca30bb..942c8f6e2ae 100644 --- a/packages/ui/src/components/UserProfile/UsernameSection.tsx +++ b/packages/ui/src/components/UserProfile/UsernameSection.tsx @@ -25,27 +25,8 @@ export const UsernameSection = ({ isImmutable }: { isImmutable?: boolean }) => { return null; } - if (isImmutable) { - if (!user.username) { - return null; - } - - return ( - - - ({ color: t.colors.$colorForeground })} - > - {user.username} - - - - ); + if (isImmutable && !user.username) { + return null; } return ( @@ -71,16 +52,18 @@ export const UsernameSection = ({ isImmutable }: { isImmutable?: boolean }) => { )} - - - + {!isImmutable && ( + + + + )}