@@ -33,7 +39,7 @@ const TotpBinding = () => {
>
diff --git a/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx b/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx
index dc2508c55..27b97a49c 100644
--- a/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx
+++ b/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx
@@ -1,7 +1,10 @@
+import { conditional } from '@silverhand/essentials';
+
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import Button from '@/components/Button';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
-import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
+import useMfaFlowState from '@/hooks/use-mfa-factors-state';
+import useSkipMfa from '@/hooks/use-skip-mfa';
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
@@ -9,25 +12,28 @@ import { UserMfaFlow } from '@/types';
import * as styles from './index.module.scss';
const WebAuthnBinding = () => {
- const mfaFactorsState = useMfaFactorsState();
+ const flowState = useMfaFlowState();
const bindWebAuthn = useWebAuthnOperation(UserMfaFlow.MfaBinding);
+ const skipMfa = useSkipMfa();
- if (!mfaFactorsState) {
+ if (!flowState) {
return
;
}
- const { availableFactors } = mfaFactorsState;
+ const { skippable } = flowState;
return (
-
+
- {availableFactors.length > 1 && (
-
- )}
+
);
};
diff --git a/packages/experience/src/pages/MfaBinding/index.tsx b/packages/experience/src/pages/MfaBinding/index.tsx
index 3c9670321..8a4e934df 100644
--- a/packages/experience/src/pages/MfaBinding/index.tsx
+++ b/packages/experience/src/pages/MfaBinding/index.tsx
@@ -1,20 +1,28 @@
+import { conditional } from '@silverhand/essentials';
+
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import MfaFactorList from '@/containers/MfaFactorList';
-import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
+import useMfaFlowState from '@/hooks/use-mfa-factors-state';
+import useSkipMfa from '@/hooks/use-skip-mfa';
import { UserMfaFlow } from '@/types';
import ErrorPage from '../ErrorPage';
const MfaBinding = () => {
- const { availableFactors } = useMfaFactorsState() ?? {};
+ const flowState = useMfaFlowState();
+ const skipMfa = useSkipMfa();
- if (!availableFactors || availableFactors.length === 0) {
+ if (!flowState) {
return ;
}
return (
-
-
+
+
);
};
diff --git a/packages/experience/src/pages/MfaVerification/BackupCodeVerification/index.tsx b/packages/experience/src/pages/MfaVerification/BackupCodeVerification/index.tsx
index a79c4abc7..1def3a252 100644
--- a/packages/experience/src/pages/MfaVerification/BackupCodeVerification/index.tsx
+++ b/packages/experience/src/pages/MfaVerification/BackupCodeVerification/index.tsx
@@ -8,7 +8,7 @@ import SectionLayout from '@/Layout/SectionLayout';
import Button from '@/components/Button';
import { InputField } from '@/components/InputFields';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
-import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
+import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
@@ -20,7 +20,7 @@ type FormState = {
};
const BackupCodeVerification = () => {
- const mfaFactorsState = useMfaFactorsState();
+ const flowState = useMfaFlowState();
const sendMfaPayload = useSendMfaPayload();
const { register, handleSubmit } = useForm({ defaultValues: { code: '' } });
@@ -36,12 +36,10 @@ const BackupCodeVerification = () => {
[handleSubmit, sendMfaPayload]
);
- if (!mfaFactorsState) {
+ if (!flowState) {
return ;
}
- const { availableFactors } = mfaFactorsState;
-
return (
{
- {availableFactors.length > 1 && (
-
- )}
+
);
};
diff --git a/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx b/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx
index 6f3e80e9a..04d38a498 100644
--- a/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx
+++ b/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx
@@ -2,21 +2,19 @@ import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import SectionLayout from '@/Layout/SectionLayout';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import TotpCodeVerification from '@/containers/TotpCodeVerification';
-import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
+import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
import * as styles from './index.module.scss';
const TotpVerification = () => {
- const mfaFactorsState = useMfaFactorsState();
+ const flowState = useMfaFlowState();
- if (!mfaFactorsState) {
+ if (!flowState) {
return ;
}
- const { availableFactors } = mfaFactorsState;
-
return (
{
>
- {availableFactors.length > 1 && (
-
- )}
+
);
};
diff --git a/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx b/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx
index 807a19e1b..f566bf783 100644
--- a/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx
+++ b/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx
@@ -2,7 +2,7 @@ import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import SectionLayout from '@/Layout/SectionLayout';
import Button from '@/components/Button';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
-import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
+import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
@@ -10,15 +10,13 @@ import { UserMfaFlow } from '@/types';
import * as styles from './index.module.scss';
const WebAuthnVerification = () => {
- const mfaFactorsState = useMfaFactorsState();
+ const flowState = useMfaFlowState();
const verifyWebAuthn = useWebAuthnOperation(UserMfaFlow.MfaVerification);
- if (!mfaFactorsState) {
+ if (!flowState) {
return ;
}
- const { availableFactors } = mfaFactorsState;
-
return (
{
onClick={verifyWebAuthn}
/>
- {availableFactors.length > 1 && (
-
- )}
+
);
};
diff --git a/packages/experience/src/pages/MfaVerification/index.tsx b/packages/experience/src/pages/MfaVerification/index.tsx
index 2fb6bccb0..677d796cd 100644
--- a/packages/experience/src/pages/MfaVerification/index.tsx
+++ b/packages/experience/src/pages/MfaVerification/index.tsx
@@ -1,20 +1,20 @@
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import MfaFactorList from '@/containers/MfaFactorList';
-import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
+import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import { UserMfaFlow } from '@/types';
import ErrorPage from '../ErrorPage';
const MfaVerification = () => {
- const { availableFactors } = useMfaFactorsState() ?? {};
+ const flowState = useMfaFlowState();
- if (!availableFactors || availableFactors.length === 0) {
+ if (!flowState) {
return ;
}
return (
-
+
);
};
diff --git a/packages/experience/src/types/guard.ts b/packages/experience/src/types/guard.ts
index fcbcc2340..57b3d37c3 100644
--- a/packages/experience/src/types/guard.ts
+++ b/packages/experience/src/types/guard.ts
@@ -73,18 +73,19 @@ const mfaFactorsGuard = s.array(
export const mfaErrorDataGuard = s.object({
availableFactors: mfaFactorsGuard,
+ skippable: s.optional(s.boolean()),
});
-export const mfaFactorsStateGuard = mfaErrorDataGuard;
+export const mfaFlowStateGuard = mfaErrorDataGuard;
-export type MfaFactorsState = s.Infer;
+export type MfaFlowState = s.Infer;
export const totpBindingStateGuard = s.assign(
s.object({
secret: s.string(),
secretQrCode: s.string(),
}),
- mfaFactorsStateGuard
+ mfaFlowStateGuard
);
export type TotpBindingState = s.Infer;
diff --git a/packages/integration-tests/src/tests/experience/mfa/user-controlled.test.ts b/packages/integration-tests/src/tests/experience/mfa/user-controlled.test.ts
new file mode 100644
index 000000000..080b8b3da
--- /dev/null
+++ b/packages/integration-tests/src/tests/experience/mfa/user-controlled.test.ts
@@ -0,0 +1,94 @@
+import { ConnectorType } from '@logto/connector-kit';
+import { SignInIdentifier } from '@logto/schemas';
+
+import { deleteUser } from '#src/api/admin-user.js';
+import { updateSignInExperience } from '#src/api/sign-in-experience.js';
+import { demoAppUrl } from '#src/constants.js';
+import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
+import {
+ enableUserControlledMfaWithTotp,
+ resetMfaSettings,
+} from '#src/helpers/sign-in-experience.js';
+import { generateNewUser } from '#src/helpers/user.js';
+import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js';
+import { waitFor } from '#src/utils.js';
+
+describe('MFA - User controlled', () => {
+ beforeAll(async () => {
+ await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]);
+ await setSocialConnector();
+ await updateSignInExperience({
+ signUp: {
+ identifiers: [SignInIdentifier.Username],
+ password: true,
+ verify: false,
+ },
+ signIn: {
+ methods: [
+ {
+ identifier: SignInIdentifier.Username,
+ password: true,
+ verificationCode: false,
+ isPasswordPrimary: true,
+ },
+ ],
+ },
+ });
+
+ await enableUserControlledMfaWithTotp();
+ });
+
+ afterAll(async () => {
+ await resetMfaSettings();
+ });
+
+ it('can skip MFA binding when signing in at the first time', async () => {
+ const { userProfile, user } = await generateNewUser({ username: true, password: true });
+
+ const experience = new ExpectTotpExperience(await browser.newPage());
+ await experience.startWith(demoAppUrl, 'sign-in');
+
+ await experience.toFillForm(
+ {
+ identifier: userProfile.username,
+ password: userProfile.password,
+ },
+ { submit: true, shouldNavigate: false }
+ );
+ // Wait for the TOTP page rendered
+ await waitFor(1000);
+ await experience.toClick('div[role=button][class$=skipButton]');
+ await experience.verifyThenEnd();
+ await deleteUser(user.id);
+ });
+
+ it('should verify MFA when the user has not skip the MFA binding', async () => {
+ const { userProfile, user } = await generateNewUser({ username: true, password: true });
+ const experience = new ExpectTotpExperience(await browser.newPage());
+ await experience.startWith(demoAppUrl, 'sign-in');
+
+ await experience.toFillForm(
+ {
+ identifier: userProfile.username,
+ password: userProfile.password,
+ },
+ { submit: true }
+ );
+ const totpSecret = await experience.toBindTotp();
+ await experience.verifyThenEnd(false);
+
+ await experience.startWith(demoAppUrl, 'sign-in');
+ await experience.toFillForm(
+ {
+ identifier: userProfile.username,
+ password: userProfile.password,
+ },
+ { submit: true }
+ );
+
+ await experience.toVerifyTotp(totpSecret);
+ await experience.verifyThenEnd();
+
+ await deleteUser(user.id);
+ });
+});
diff --git a/packages/phrases-experience/src/locales/de/action.ts b/packages/phrases-experience/src/locales/de/action.ts
index 222c210f7..2a3a59d64 100644
--- a/packages/phrases-experience/src/locales/de/action.ts
+++ b/packages/phrases-experience/src/locales/de/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Verknüpfen und weiter',
back: 'Gehe zurück',
nav_back: 'Zurück',
+ nav_skip: 'Überspringen',
agree: 'Zustimmen',
got_it: 'Alles klar',
sign_in_with: 'Mit {{name}} anmelden',
diff --git a/packages/phrases-experience/src/locales/en/action.ts b/packages/phrases-experience/src/locales/en/action.ts
index 8b11ff42b..494703821 100644
--- a/packages/phrases-experience/src/locales/en/action.ts
+++ b/packages/phrases-experience/src/locales/en/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Link and continue',
back: 'Go back',
nav_back: 'Back',
+ nav_skip: 'Skip',
agree: 'Agree',
got_it: 'Got it',
sign_in_with: 'Continue with {{name}}',
diff --git a/packages/phrases-experience/src/locales/es/action.ts b/packages/phrases-experience/src/locales/es/action.ts
index 6b29454a9..20a7fd905 100644
--- a/packages/phrases-experience/src/locales/es/action.ts
+++ b/packages/phrases-experience/src/locales/es/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Vincular y continuar',
back: 'Regresar',
nav_back: 'Atrás',
+ nav_skip: 'Omitir',
agree: 'Aceptar',
got_it: 'Entendido',
sign_in_with: 'Continuar con {{name}}',
diff --git a/packages/phrases-experience/src/locales/fr/action.ts b/packages/phrases-experience/src/locales/fr/action.ts
index a8002598a..59d1ffcea 100644
--- a/packages/phrases-experience/src/locales/fr/action.ts
+++ b/packages/phrases-experience/src/locales/fr/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Lier et continuer',
back: 'Aller en arrière',
nav_back: 'Retour',
+ nav_skip: 'Passer',
agree: 'Accepter',
got_it: 'Compris',
sign_in_with: 'Continuer avec {{name}}',
diff --git a/packages/phrases-experience/src/locales/it/action.ts b/packages/phrases-experience/src/locales/it/action.ts
index a6080a74c..d59cf1515 100644
--- a/packages/phrases-experience/src/locales/it/action.ts
+++ b/packages/phrases-experience/src/locales/it/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Collega e continua',
back: 'Torna indietro',
nav_back: 'Indietro',
+ nav_skip: 'Salta',
agree: 'Accetto',
got_it: 'Capito',
sign_in_with: 'Continua con {{name}}',
diff --git a/packages/phrases-experience/src/locales/ja/action.ts b/packages/phrases-experience/src/locales/ja/action.ts
index 9f72a187f..1fd737472 100644
--- a/packages/phrases-experience/src/locales/ja/action.ts
+++ b/packages/phrases-experience/src/locales/ja/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'リンクして続行する',
back: '戻る',
nav_back: '戻る',
+ nav_skip: 'スキップ',
agree: '同意する',
got_it: 'わかりました',
sign_in_with: '{{name}} で続ける',
diff --git a/packages/phrases-experience/src/locales/ko/action.ts b/packages/phrases-experience/src/locales/ko/action.ts
index 332a41519..b0954c24e 100644
--- a/packages/phrases-experience/src/locales/ko/action.ts
+++ b/packages/phrases-experience/src/locales/ko/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: '연동하고 계속하기',
back: '뒤로 가기',
nav_back: '뒤로',
+ nav_skip: '건너뛰기',
agree: '동의',
got_it: '알겠습니다',
sign_in_with: '{{name}} 계속',
diff --git a/packages/phrases-experience/src/locales/pl-pl/action.ts b/packages/phrases-experience/src/locales/pl-pl/action.ts
index 3406b509d..50a849ae9 100644
--- a/packages/phrases-experience/src/locales/pl-pl/action.ts
+++ b/packages/phrases-experience/src/locales/pl-pl/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Połącz i kontynuuj',
back: 'Wróć',
nav_back: 'Wstecz',
+ nav_skip: 'Pomiń',
agree: 'Zgadzam się',
got_it: 'Zrozumiałem',
sign_in_with: 'Kontynuuj z {{name}}',
diff --git a/packages/phrases-experience/src/locales/pt-br/action.ts b/packages/phrases-experience/src/locales/pt-br/action.ts
index 3c0dd7a53..96a7dbf58 100644
--- a/packages/phrases-experience/src/locales/pt-br/action.ts
+++ b/packages/phrases-experience/src/locales/pt-br/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Linkar e continuar',
back: 'Voltar',
nav_back: 'Voltar',
+ nav_skip: 'Pular',
agree: 'Aceito',
got_it: 'Entendido',
sign_in_with: 'Continuar com {{name}}',
diff --git a/packages/phrases-experience/src/locales/pt-pt/action.ts b/packages/phrases-experience/src/locales/pt-pt/action.ts
index 2a96c828c..8841fd017 100644
--- a/packages/phrases-experience/src/locales/pt-pt/action.ts
+++ b/packages/phrases-experience/src/locales/pt-pt/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Ligar e continuar',
back: 'Voltar',
nav_back: 'Anterior',
+ nav_skip: 'Saltar',
agree: 'Aceito',
got_it: 'Entendi',
sign_in_with: 'Continuar com {{name}}',
diff --git a/packages/phrases-experience/src/locales/ru/action.ts b/packages/phrases-experience/src/locales/ru/action.ts
index f53ff3e93..ef0b86be9 100644
--- a/packages/phrases-experience/src/locales/ru/action.ts
+++ b/packages/phrases-experience/src/locales/ru/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Привязать и продолжить',
back: 'Назад',
nav_back: 'Назад',
+ nav_skip: 'Пропустить',
agree: 'Согласен',
got_it: 'Понял',
sign_in_with: 'Войти через {{name}}',
diff --git a/packages/phrases-experience/src/locales/tr-tr/action.ts b/packages/phrases-experience/src/locales/tr-tr/action.ts
index eea6cd82c..c19be1f3e 100644
--- a/packages/phrases-experience/src/locales/tr-tr/action.ts
+++ b/packages/phrases-experience/src/locales/tr-tr/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Bağla ve devam et',
back: 'Geri Dön',
nav_back: 'Geri',
+ nav_skip: 'Atla',
agree: 'Kabul Et',
got_it: 'Anladım',
sign_in_with: '{{name}} ile ilerle',
diff --git a/packages/phrases-experience/src/locales/zh-cn/action.ts b/packages/phrases-experience/src/locales/zh-cn/action.ts
index 1dc48db78..e0fb432e4 100644
--- a/packages/phrases-experience/src/locales/zh-cn/action.ts
+++ b/packages/phrases-experience/src/locales/zh-cn/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: '绑定并继续',
back: '返回',
nav_back: '返回',
+ nav_skip: '跳过',
agree: '同意',
got_it: '知道了',
sign_in_with: '通过 {{name}} 继续',
diff --git a/packages/phrases-experience/src/locales/zh-hk/action.ts b/packages/phrases-experience/src/locales/zh-hk/action.ts
index f0c01e512..801950816 100644
--- a/packages/phrases-experience/src/locales/zh-hk/action.ts
+++ b/packages/phrases-experience/src/locales/zh-hk/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: '綁定並繼續',
back: '返回',
nav_back: '返回',
+ nav_skip: '跳過',
agree: '同意',
got_it: '知道了',
sign_in_with: '通過 {{name}} 繼續',
diff --git a/packages/phrases-experience/src/locales/zh-tw/action.ts b/packages/phrases-experience/src/locales/zh-tw/action.ts
index eae9ea5fb..ee4f4af13 100644
--- a/packages/phrases-experience/src/locales/zh-tw/action.ts
+++ b/packages/phrases-experience/src/locales/zh-tw/action.ts
@@ -12,6 +12,7 @@ const action = {
bind_and_continue: '綁定並繼續',
back: '返回',
nav_back: '返回',
+ nav_skip: '跳過',
agree: '同意',
got_it: '知道了',
sign_in_with: '通過 {{name}} 繼續',