Skip to content

Commit a55d668

Browse files
authored
feat(core,experience): show all MFA factors in suggestion flow (#7765)
feat(experience): show all MFA factors in suggestion flow with disabled state
1 parent 7d45c10 commit a55d668

File tree

25 files changed

+147
-43
lines changed

25 files changed

+147
-43
lines changed

packages/core/src/routes/experience/classes/mfa.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import {
1717
OrganizationRequiredMfaPolicy,
1818
MfaFactor,
1919
} from '@logto/schemas';
20-
import { generateStandardId } from '@logto/shared';
21-
import { cond, deduplicate, pick } from '@silverhand/essentials';
20+
import { generateStandardId, maskEmail, maskPhone } from '@logto/shared';
21+
import { cond, condObject, deduplicate, pick } from '@silverhand/essentials';
2222
import { z } from 'zod';
2323

2424
import RequestError from '#src/errors/RequestError/index.js';
@@ -333,9 +333,30 @@ export class Mfa {
333333
return;
334334
}
335335

336+
// Get user data for masking
337+
const user = await this.interactionContext.getIdentifiedUser();
338+
const { primaryEmail, primaryPhone } = user;
339+
340+
// Build masked identifiers for bound factors
341+
const maskedIdentifiers = condObject({
342+
[MfaFactor.EmailVerificationCode]:
343+
factorsInUser.includes(MfaFactor.EmailVerificationCode) &&
344+
primaryEmail &&
345+
maskEmail(primaryEmail),
346+
[MfaFactor.PhoneVerificationCode]:
347+
factorsInUser.includes(MfaFactor.PhoneVerificationCode) &&
348+
primaryPhone &&
349+
maskPhone(primaryPhone),
350+
});
351+
336352
throw new RequestError(
337353
{ code: 'session.mfa.suggest_additional_mfa', status: 422 },
338-
{ availableFactors: additionalFactors, skippable: true, suggestion: true }
354+
{
355+
availableFactors, // Return all available factors, not just additional ones
356+
maskedIdentifiers,
357+
skippable: true,
358+
suggestion: true,
359+
}
339360
);
340361
}
341362

packages/experience/src/components/Button/MfaFactorButton.module.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
gap: _.unit(4);
88
border-radius: 12px;
99
border-color: var(--color-line-divider);
10+
11+
&.disabled {
12+
cursor: not-allowed;
13+
border: none;
14+
background: var(--color-bg-body-base);
15+
}
1016
}
1117

1218
.icon {
@@ -23,6 +29,7 @@
2329

2430
.name {
2531
font: var(--font-label-1);
32+
color: var(--color-type-primary);
2633
}
2734

2835
.description {

packages/experience/src/components/Button/MfaFactorButton.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import styles from './index.module.scss';
1818
export type Props = {
1919
readonly factor: MfaFactor;
2020
readonly isBinding: boolean;
21+
readonly isDisabled?: boolean;
22+
readonly maskedIdentifier?: string;
2123
readonly onClick?: () => void;
2224
};
2325

@@ -53,7 +55,7 @@ const linkFactorDescription: Record<MfaFactor, TFuncKey> = {
5355
[MfaFactor.PhoneVerificationCode]: 'mfa.link_phone_verification_code_description',
5456
};
5557

56-
const MfaFactorButton = ({ factor, isBinding, onClick }: Props) => {
58+
const MfaFactorButton = ({ factor, isBinding, isDisabled, maskedIdentifier, onClick }: Props) => {
5759
const Icon = factorIcon[factor];
5860

5961
return (
@@ -62,23 +64,31 @@ const MfaFactorButton = ({ factor, isBinding, onClick }: Props) => {
6264
styles.button,
6365
styles.secondary,
6466
styles.large,
65-
mfaFactorButtonStyles.mfaFactorButton
67+
mfaFactorButtonStyles.mfaFactorButton,
68+
isDisabled && mfaFactorButtonStyles.disabled
6669
)}
6770
type="button"
68-
onClick={onClick}
71+
disabled={isDisabled}
72+
onClick={isDisabled ? undefined : onClick}
6973
>
7074
<Icon className={mfaFactorButtonStyles.icon} />
7175
<div className={mfaFactorButtonStyles.title}>
7276
<div className={mfaFactorButtonStyles.name}>
7377
<DynamicT forKey={factorName[factor]} />
7478
</div>
7579
<div className={mfaFactorButtonStyles.description}>
76-
<DynamicT forKey={(isBinding ? linkFactorDescription : factorDescription)[factor]} />
80+
{maskedIdentifier ? (
81+
<span>{maskedIdentifier}</span>
82+
) : (
83+
<DynamicT forKey={(isBinding ? linkFactorDescription : factorDescription)[factor]} />
84+
)}
7785
</div>
7886
</div>
79-
<FlipOnRtl>
80-
<ArrowNext className={mfaFactorButtonStyles.icon} />
81-
</FlipOnRtl>
87+
{!isDisabled && (
88+
<FlipOnRtl>
89+
<ArrowNext className={mfaFactorButtonStyles.icon} />
90+
</FlipOnRtl>
91+
)}
8292
</button>
8393
);
8494
};

packages/experience/src/containers/MfaFactorList/index.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,27 @@ const MfaFactorList = ({ flow, flowState }: Props) => {
5050

5151
return (
5252
<div className={styles.factorList}>
53-
{availableFactors.map((factor) => (
54-
<MfaFactorButton
55-
key={factor}
56-
factor={factor}
57-
isBinding={flow === UserMfaFlow.MfaBinding}
58-
onClick={async () => {
59-
await handleSelectFactor(factor);
60-
}}
61-
/>
62-
))}
53+
{availableFactors.map((factor) => {
54+
const isEmailOrPhone =
55+
factor === MfaFactor.EmailVerificationCode || factor === MfaFactor.PhoneVerificationCode;
56+
const isDisabled = Boolean(
57+
flowState.suggestion && isEmailOrPhone && flowState.maskedIdentifiers?.[factor]
58+
);
59+
const maskedIdentifier = isEmailOrPhone ? flowState.maskedIdentifiers?.[factor] : undefined;
60+
61+
return (
62+
<MfaFactorButton
63+
key={factor}
64+
factor={factor}
65+
isBinding={flow === UserMfaFlow.MfaBinding}
66+
isDisabled={isDisabled}
67+
maskedIdentifier={maskedIdentifier}
68+
onClick={async () => {
69+
await handleSelectFactor(factor);
70+
}}
71+
/>
72+
);
73+
})}
6374
</div>
6475
);
6576
};

packages/experience/src/pages/MfaBinding/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ const MfaBinding = () => {
2020

2121
return (
2222
<SecondaryPageLayout
23-
title="mfa.add_mfa_factors"
24-
description="mfa.add_mfa_description"
23+
title={flowState.suggestion ? 'mfa.add_another_mfa_factor' : 'mfa.add_mfa_factors'}
24+
description={
25+
flowState.suggestion ? 'mfa.add_another_mfa_description' : 'mfa.add_mfa_description'
26+
}
2527
onSkip={conditional(
2628
flowState.skippable && (flowState.suggestion ? skipOptionalMfa : skipMfa)
2729
)}

packages/integration-tests/src/tests/api/experience-api/bind-mfa/mfa-suggestion.test.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,23 @@ describe('Register interaction - optional additional MFA suggestion', () => {
7171
await client.updateProfile({ type: 'password', value: password });
7272
await client.identifyUser({ verificationId });
7373

74-
await expectRejects<{ availableFactors: MfaFactor[]; skippable: boolean }>(
75-
client.submitInteraction(),
76-
{
77-
code: 'session.mfa.suggest_additional_mfa',
78-
status: 422,
79-
expectData: (data) => {
80-
expect(data.availableFactors).toEqual([MfaFactor.TOTP]);
81-
expect(data.skippable).toBe(true);
82-
},
83-
}
84-
);
74+
await expectRejects<{
75+
availableFactors: MfaFactor[];
76+
skippable: boolean;
77+
maskedIdentifiers?: Record<string, string>;
78+
suggestion?: boolean;
79+
}>(client.submitInteraction(), {
80+
code: 'session.mfa.suggest_additional_mfa',
81+
status: 422,
82+
expectData: (data) => {
83+
// Should now include both Email and TOTP
84+
expect(data.availableFactors).toEqual([MfaFactor.EmailVerificationCode, MfaFactor.TOTP]);
85+
expect(data.maskedIdentifiers).toBeDefined();
86+
expect(data.maskedIdentifiers?.[MfaFactor.EmailVerificationCode]).toMatch(/\*{4}/);
87+
expect(data.skippable).toBe(true);
88+
expect(data.suggestion).toBe(true);
89+
},
90+
});
8591

8692
// Skip suggestion
8793
await client.skipMfaSuggestion();

packages/integration-tests/src/tests/experience/mfa/suggest-additional-on-register.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@ devFeatureTest.describe('Experience - suggest additional MFA after email registr
6464
await experience.waitForPathname('continue/password');
6565
await experience.toFillNewPasswords(password);
6666

67-
// Wait for suggestion navigation and page render (list page)
68-
await experience.waitForPathname('mfa-binding/Totp');
67+
// Wait for suggestion navigation to MFA list page
68+
await experience.waitForPathname('mfa-binding');
6969

70-
// Skip optional suggestion
70+
// Skip optional suggestion from list page
7171
await experience.toClick('div[role=button][class$=skipButton]');
7272
await experience.page.waitForNetworkIdle();
7373

@@ -97,7 +97,11 @@ devFeatureTest.describe('Experience - suggest additional MFA after email registr
9797
await experience.waitForPathname('continue/password');
9898
await experience.toFillNewPasswords(password);
9999

100-
// Land on optional MFA suggestion list
100+
// Land on optional MFA suggestion list page
101+
await experience.waitForPathname('mfa-binding');
102+
103+
// Click on TOTP factor button to proceed with binding
104+
await experience.toClick('button', 'Authenticator app OTP');
101105
await experience.waitForPathname('mfa-binding/Totp');
102106
await experience.toBindTotp();
103107
const userId = await experience.getUserIdFromDemoAppPage();
@@ -127,8 +131,8 @@ devFeatureTest.describe('Experience - suggest additional MFA after email registr
127131
await experience.waitForPathname('continue/password');
128132
await experience.toFillNewPasswords(password);
129133

130-
// Optional suggestion detail page for TOTP
131-
await experience.waitForPathname('mfa-binding/Totp');
134+
// Optional suggestion list page
135+
await experience.waitForPathname('mfa-binding');
132136

133137
// Click skip for optional suggestion; backend should require backup code
134138
await experience.toClick('div[role=button][class$=skipButton]');

packages/phrases-experience/src/locales/ar/mfa.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const mfa = {
1818
verify_phone_verification_code_description: 'أدخل الرمز المرسل إلى هاتفك',
1919
add_mfa_factors: 'إضافة التحقق من خطوتين',
2020
add_mfa_description: 'تم تمكين التحقق من خطوتين. حدد طريقة التحقق الثانية لتسجيل الدخول الآمن.',
21+
add_another_mfa_factor: 'إضافة تحقق آخر من خطوتين',
22+
add_another_mfa_description: 'حدد طريقة أخرى للتحقق من هويتك عند تسجيل الدخول.',
2123
verify_mfa_factors: 'التحقق من خطوتين',
2224
verify_mfa_description:
2325
'تم تمكين التحقق من خطوتين لهذا الحساب. يرجى تحديد الطريقة الثانية للتحقق من هويتك.',

packages/phrases-experience/src/locales/de/mfa.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const mfa = {
1919
add_mfa_factors: '2-Schritte-Verifizierung hinzufügen',
2020
add_mfa_description:
2121
'Die Zwei-Faktor-Verifizierung ist aktiviert. Wählen Sie Ihre zweite Verifizierungsmethode für sicheres Anmelden aus.',
22+
add_another_mfa_factor: 'Eine weitere 2-Schritte-Verifizierung hinzufügen',
23+
add_another_mfa_description:
24+
'Wählen Sie eine andere Methode zur Verifizierung Ihrer Identität bei der Anmeldung.',
2225
verify_mfa_factors: '2-Schritte-Verifizierung',
2326
verify_mfa_description:
2427
'Die 2-Schritte-Verifizierung ist für dieses Konto aktiviert. Bitte wählen Sie die zweite Methode zur Verifizierung Ihrer Identität aus.',

packages/phrases-experience/src/locales/en/mfa.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const mfa = {
1919
add_mfa_factors: 'Add 2-step verification',
2020
add_mfa_description:
2121
'Two-factor verification is enabled. Select your second verification method for secure sign-in.',
22+
add_another_mfa_factor: 'Add another one 2-step verification',
23+
add_another_mfa_description:
24+
'Select another way to add for verifying your identity when sign-in.',
2225
verify_mfa_factors: '2-step verification',
2326
verify_mfa_description:
2427
'2-step verification has been enabled for this account. Please select the second way to verify your identity.',

0 commit comments

Comments
 (0)