From 9e5ca04bfeb0f5fd365acfe67042e85ab7de5c19 Mon Sep 17 00:00:00 2001 From: Vaggeilis Yfantis Date: Mon, 2 Feb 2026 14:55:44 +0200 Subject: [PATCH 1/2] feat(ui): Hide delete actions for last MFA strategy when required - Added logic to hide the "Remove" action for the last available second factor when MFA is required. - Updated UserSettings and MfaSection components to support MFA requirements. - Enhanced tests to verify the visibility of delete actions based on MFA status. --- .changeset/every-friends-sort.md | 7 + .../src/core/resources/UserSettings.ts | 3 + packages/shared/src/types/userSettings.ts | 3 + .../src/components/UserProfile/MfaSection.tsx | 107 +++++----- .../UserProfile/__tests__/MfaPage.test.tsx | 189 ++++++++++++++++++ packages/ui/src/test/fixture-helpers.ts | 7 +- packages/ui/src/test/fixtures.ts | 3 + 7 files changed, 270 insertions(+), 49 deletions(-) create mode 100644 .changeset/every-friends-sort.md diff --git a/.changeset/every-friends-sort.md b/.changeset/every-friends-sort.md new file mode 100644 index 00000000000..cd57822f694 --- /dev/null +++ b/.changeset/every-friends-sort.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Hide the "Remove" action from the last available 2nd factor strategy when MFA is required diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index 017bcca0ab0..93078997ebc 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -123,6 +123,9 @@ export class UserSettings extends BaseResource implements UserSettingsResource { legal_consent_enabled: false, mode: 'public', progressive: true, + mfa: { + required: false, + }, }; social: OAuthProviders = {} as OAuthProviders; usernameSettings: UsernameSettingsData = {} as UsernameSettingsData; diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index 9407cd8e92b..149db66220f 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -58,6 +58,9 @@ export type SignUpData = { captcha_enabled: boolean; mode: SignUpModes; legal_consent_enabled: boolean; + mfa?: { + required: boolean; + }; }; export type PasswordSettingsData = { diff --git a/packages/ui/src/components/UserProfile/MfaSection.tsx b/packages/ui/src/components/UserProfile/MfaSection.tsx index 185cf8f88f2..6560d90d344 100644 --- a/packages/ui/src/components/UserProfile/MfaSection.tsx +++ b/packages/ui/src/components/UserProfile/MfaSection.tsx @@ -20,7 +20,7 @@ import { MfaBackupCodeCreateScreen, MfaScreen, RemoveMfaPhoneCodeScreen, RemoveM export const MfaSection = () => { const { - userSettings: { attributes }, + userSettings: { attributes, signUp }, } = useEnvironment(); const { user } = useUser(); const [actionValue, setActionValue] = useState(null); @@ -34,12 +34,16 @@ export const MfaSection = () => { const showTOTP = secondFactors.includes('totp') && user.totpEnabled; const showBackupCode = secondFactors.includes('backup_code') && user.backupCodeEnabled; + const showPhoneCode = secondFactors.includes('phone_code'); const mfaPhones = user.phoneNumbers .filter(ph => ph.verification.status === 'verified') .filter(ph => ph.reservedForSecondFactor) .sort(defaultFirst); + const hideTOTPDeleteAction = Boolean(signUp.mfa?.required && mfaPhones.length === 0); + const hidePhoneCodeDeleteAction = Boolean(signUp.mfa?.required && !showTOTP && mfaPhones.length === 1); + return ( { - + {!hideTOTPDeleteAction && } @@ -79,7 +83,7 @@ export const MfaSection = () => { )} - {secondFactors.includes('phone_code') && + {showPhoneCode && mfaPhones.map(phone => { const isDefault = !showTOTP && phone.defaultSecondFactor; const phoneId = phone.id; @@ -102,7 +106,8 @@ export const MfaSection = () => { @@ -153,30 +158,41 @@ export const MfaSection = () => { type MfaPhoneCodeMenuProps = { phone: PhoneNumberResource; - showTOTP: boolean; + isDefault: boolean; + hidePhoneCodeDeleteAction: boolean; }; -const MfaPhoneCodeMenu = ({ phone, showTOTP }: MfaPhoneCodeMenuProps) => { +const MfaPhoneCodeMenu = ({ phone, isDefault, hidePhoneCodeDeleteAction }: MfaPhoneCodeMenuProps) => { const { open } = useActionContext(); const card = useCardState(); const phoneId = phone.id; const actions = ( [ - !showTOTP && !phone.defaultSecondFactor + !isDefault ? { label: localizationKeys('userProfile.start.mfaSection.phoneCode.actionLabel__setDefault'), onClick: () => phone.makeDefaultSecondFactor().catch(err => handleError(err, [], card.setError)), } : null, - { - label: localizationKeys('userProfile.start.mfaSection.phoneCode.destructiveActionLabel'), - isDestructive: true, - onClick: () => open(`remove-${phoneId}`), - }, + !hidePhoneCodeDeleteAction + ? { + label: localizationKeys('userProfile.start.mfaSection.phoneCode.destructiveActionLabel'), + isDestructive: true, + onClick: () => open(`remove-${phoneId}`), + } + : null, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; + console.log('actions', actions); + console.log('hidePhoneCodeDeleteAction', hidePhoneCodeDeleteAction); + console.log('isDefault', isDefault); + + if (actions.length === 0) { + return null; + } + return ; }; @@ -216,6 +232,24 @@ type MfaAddMenuProps = ProfileSectionActionMenuItemProps & { onClick?: () => void; }; +const strategiesMap = { + phone_code: { + icon: Mobile, + text: 'SMS code', + key: 'phone_code', + }, + totp: { + icon: AuthApp, + text: 'Authenticator application', + key: 'totp', + }, + backup_code: { + icon: DotCircle, + text: 'Backup code', + key: 'backup_code', + }, +} as const; + const MfaAddMenu = (props: MfaAddMenuProps) => { const { open } = useActionContext(); const { secondFactorsAvailableToAdd, onClick } = props; @@ -225,27 +259,7 @@ const MfaAddMenu = (props: MfaAddMenuProps) => { () => secondFactorsAvailableToAdd .map(key => { - if (key === 'phone_code') { - return { - icon: Mobile, - text: 'SMS code', - key: 'phone_code', - } as const; - } else if (key === 'totp') { - return { - icon: AuthApp, - text: 'Authenticator application', - key: 'totp', - } as const; - } else if (key === 'backup_code') { - return { - icon: DotCircle, - text: 'Backup code', - key: 'backup_code', - } as const; - } - - return null; + return strategiesMap[key as keyof typeof strategiesMap] || null; }) .filter(element => element !== null), [secondFactorsAvailableToAdd], @@ -260,21 +274,18 @@ const MfaAddMenu = (props: MfaAddMenuProps) => { triggerLocalizationKey={localizationKeys('userProfile.start.mfaSection.primaryButton')} onClick={onClick} > - {strategies.map( - method => - method && ( - { - setSelectedStrategy(method.key); - open('multi-factor'); - }} - /> - ), - )} + {strategies.map(method => ( + { + setSelectedStrategy(method.key); + open('multi-factor'); + }} + /> + ))} )} diff --git a/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx b/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx index 3fa7152f9e2..330db3c55d3 100644 --- a/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx @@ -386,4 +386,193 @@ describe('MfaPage', () => { }); }); }); + + describe('Hide delete actions when MFA is required', () => { + it('hides TOTP delete action when MFA is required and TOTP is the only second factor', async () => { + const { wrapper } = await createFixtures(f => { + f.withAuthenticatorApp(); + f.withMfaRequired(true); + f.withUser({ + two_factor_enabled: true, + totp_enabled: true, + }); + }); + + const { findByText } = render( + + + , + { wrapper }, + ); + + await findByText('Two-step verification'); + await findByText(/Authenticator application/i); + + const itemButton = (await findByText(/Authenticator application/i))?.parentElement?.parentElement?.children[1]; + expect(itemButton).toBeUndefined(); + }); + + it('shows TOTP delete action when MFA is required but phone_code is also available', async () => { + const { wrapper } = await createFixtures(f => { + f.withAuthenticatorApp(); + f.withPhoneNumber({ second_factors: ['phone_code'], used_for_second_factor: true }); + f.withMfaRequired(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, + totp_enabled: true, + }); + }); + + const { findByText } = render( + + + , + { wrapper }, + ); + + await findByText('Two-step verification'); + await findByText(/Authenticator application/i); + + const itemButton = (await findByText(/Authenticator application/i))?.parentElement?.parentElement?.children[1]; + expect(itemButton).toBeDefined(); + }); + + it('shows TOTP delete action when MFA is not required', async () => { + const { wrapper } = await createFixtures(f => { + f.withAuthenticatorApp(); + f.withMfaRequired(false); + f.withUser({ + two_factor_enabled: true, + totp_enabled: true, + }); + }); + + const { findByText } = render( + + + , + { wrapper }, + ); + + await findByText('Two-step verification'); + await findByText(/Authenticator application/i); + + const itemButton = (await findByText(/Authenticator application/i))?.parentElement?.parentElement?.children[1]; + expect(itemButton).toBeDefined(); + }); + + it('hides phone code delete action when it is the last one reserved for second factor and TOTP is not available', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber({ second_factors: ['phone_code'], used_for_second_factor: true }); + f.withMfaRequired(true); + f.withUser({ + phone_numbers: [ + { + phone_number: '+306911111111', + id: 'id', + reserved_for_second_factor: true, + default_second_factor: true, + verification: { status: 'verified', strategy: 'phone_code' } as VerificationJSON, + }, + ], + two_factor_enabled: true, + }); + }); + + const { findByText } = render( + + + , + { wrapper }, + ); + + await findByText('Two-step verification'); + await findByText(/\+30 691 1111111/i); + + const itemButton = (await findByText(/\+30 691 1111111/i))?.parentElement?.parentElement?.parentElement + ?.children[1]; + + expect(itemButton).toBeUndefined(); + }); + + it('shows phone code delete action when TOTP is also available', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber({ second_factors: ['phone_code'], used_for_second_factor: true }); + f.withAuthenticatorApp(); + 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, + totp_enabled: true, + }); + }); + + const { findByText } = render( + + + , + { wrapper }, + ); + + await findByText('Two-step verification'); + await findByText(/\+30 691 1111111/i); + + const itemButton = (await findByText(/\+30 691 1111111/i))?.parentElement?.parentElement?.parentElement + ?.children[1]; + + expect(itemButton).toBeDefined(); + }); + + it('shows phone code delete action when multiple phone numbers are registered', 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: 'id1', + reserved_for_second_factor: true, + verification: { status: 'verified', strategy: 'phone_code' } as VerificationJSON, + }, + { + phone_number: '+306922222222', + id: 'id2', + reserved_for_second_factor: true, + verification: { status: 'verified', strategy: 'phone_code' } as VerificationJSON, + }, + ], + two_factor_enabled: true, + }); + }); + + const { findByText } = render( + + + , + { wrapper }, + ); + + await findByText('Two-step verification'); + await findByText(/\+30 691 1111111/i); + + const itemButton = (await findByText(/\+30 691 1111111/i))?.parentElement?.parentElement?.parentElement + ?.children[1]; + + expect(itemButton).toBeDefined(); + }); + }); }); diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index 012403b792e..b9be8bc522b 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -16,10 +16,10 @@ import type { VerificationJSON, } from '@clerk/shared/types'; +import { SIGN_UP_MODES } from '@/core/constants'; import type { OrgParams } from '@/test/core-fixtures'; import { createUser, getOrganizationId } from '@/test/core-fixtures'; -import { SIGN_UP_MODES } from '@/core/constants'; import { createUserFixture } from './fixtures'; export const createEnvironmentFixtureHelpers = (baseEnvironment: EnvironmentJSON) => { @@ -581,6 +581,10 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { us.sign_up.mode = SIGN_UP_MODES.WAITLIST; }; + const withMfaRequired = (required: boolean = true) => { + us.sign_up.mfa = { required }; + }; + // TODO: Add the rest, consult pkg/generate/auth_config.go return { @@ -601,5 +605,6 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { withRestrictedMode, withLegalConsent, withWaitlistMode, + withMfaRequired, }; }; diff --git a/packages/ui/src/test/fixtures.ts b/packages/ui/src/test/fixtures.ts index 34bc98dbdca..8591a0df459 100644 --- a/packages/ui/src/test/fixtures.ts +++ b/packages/ui/src/test/fixtures.ts @@ -221,6 +221,9 @@ const createBaseUserSettings = (): UserSettingsJSON => { captcha_enabled: false, disable_hibp: false, mode: 'public', + mfa: { + required: false, + }, }, restrictions: { allowlist: { From 525f6e5ed820ccb4b0b21f887d3c558f249f1271 Mon Sep 17 00:00:00 2001 From: Vaggeilis Yfantis Date: Mon, 2 Feb 2026 19:46:00 +0200 Subject: [PATCH 2/2] chore(ui): Remove console logs --- packages/ui/src/components/UserProfile/MfaSection.tsx | 4 ---- .../ui/src/components/UserProfile/__tests__/MfaPage.test.tsx | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/components/UserProfile/MfaSection.tsx b/packages/ui/src/components/UserProfile/MfaSection.tsx index 6560d90d344..01d193306f0 100644 --- a/packages/ui/src/components/UserProfile/MfaSection.tsx +++ b/packages/ui/src/components/UserProfile/MfaSection.tsx @@ -185,10 +185,6 @@ const MfaPhoneCodeMenu = ({ phone, isDefault, hidePhoneCodeDeleteAction }: MfaPh ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; - console.log('actions', actions); - console.log('hidePhoneCodeDeleteAction', hidePhoneCodeDeleteAction); - console.log('isDefault', isDefault); - if (actions.length === 0) { return null; } diff --git a/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx b/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx index 330db3c55d3..ff6e64e3a85 100644 --- a/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/MfaPage.test.tsx @@ -505,6 +505,7 @@ describe('MfaPage', () => { it('shows phone code delete action when TOTP is also available', async () => { const { wrapper } = await createFixtures(f => { + f.withMfaRequired(true); f.withPhoneNumber({ second_factors: ['phone_code'], used_for_second_factor: true }); f.withAuthenticatorApp(); f.withUser({