Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/every-friends-sort.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/types/userSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export type SignUpData = {
captcha_enabled: boolean;
mode: SignUpModes;
legal_consent_enabled: boolean;
mfa?: {
required: boolean;
};
};

export type PasswordSettingsData = {
Expand Down
103 changes: 55 additions & 48 deletions packages/ui/src/components/UserProfile/MfaSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand All @@ -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 (
<ProfileSection.Root
title={localizationKeys('userProfile.start.mfaSection.title')}
Expand Down Expand Up @@ -68,7 +72,7 @@ export const MfaSection = () => {
<Badge localizationKey={localizationKeys('badge__default')} />
</Flex>

<MfaTOTPMenu />
{!hideTOTPDeleteAction && <MfaTOTPMenu />}
</ProfileSection.Item>

<Action.Open value='remove-totp'>
Expand All @@ -79,7 +83,7 @@ export const MfaSection = () => {
</>
)}

{secondFactors.includes('phone_code') &&
{showPhoneCode &&
mfaPhones.map(phone => {
const isDefault = !showTOTP && phone.defaultSecondFactor;
const phoneId = phone.id;
Expand All @@ -102,7 +106,8 @@ export const MfaSection = () => {

<MfaPhoneCodeMenu
phone={phone}
showTOTP={showTOTP}
isDefault={isDefault}
hidePhoneCodeDeleteAction={hidePhoneCodeDeleteAction}
/>
</ProfileSection.Item>

Expand Down Expand Up @@ -153,30 +158,37 @@ 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<typeof ThreeDotsMenu>['actions'][0] | null)[]
).filter(a => a !== null) as PropsOfComponent<typeof ThreeDotsMenu>['actions'];

if (actions.length === 0) {
return null;
}

return <ThreeDotsMenu actions={actions} />;
};

Expand Down Expand Up @@ -216,6 +228,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;
Expand All @@ -225,27 +255,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],
Expand All @@ -260,21 +270,18 @@ const MfaAddMenu = (props: MfaAddMenuProps) => {
triggerLocalizationKey={localizationKeys('userProfile.start.mfaSection.primaryButton')}
onClick={onClick}
>
{strategies.map(
method =>
method && (
<ProfileSection.ActionMenuItem
key={method.key}
id={method.key}
localizationKey={method.text}
leftIcon={method.icon}
onClick={() => {
setSelectedStrategy(method.key);
open('multi-factor');
}}
/>
),
)}
{strategies.map(method => (
<ProfileSection.ActionMenuItem
key={method.key}
id={method.key}
localizationKey={method.text}
leftIcon={method.icon}
onClick={() => {
setSelectedStrategy(method.key);
open('multi-factor');
}}
/>
))}
</ProfileSection.ActionMenu>
</Action.Closed>
)}
Expand Down
Loading
Loading