From f53778894d5ca3614c076b8d75cf4efb5e254dc7 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Sun, 8 Oct 2023 16:20:43 +0800 Subject: [PATCH] feat(experience): implement totp experience flow (#4589) --- packages/experience/package.json | 6 +- packages/experience/src/App.tsx | 25 ++++++ .../Layout/SectionLayout/index.module.scss | 11 +++ .../src/Layout/SectionLayout/index.tsx | 30 +++++++ packages/experience/src/apis/interaction.ts | 17 ++++ .../src/assets/icons/factor-backup-code.svg | 3 + .../src/assets/icons/factor-totp.svg | 3 + .../src/assets/icons/factor-webauthn.svg | 19 +++++ .../Button/MfaFactorButton.module.scss | 29 +++++++ .../src/components/Button/MfaFactorButton.tsx | 73 ++++++++++++++++ .../src/components/Divider/index.tsx | 8 +- packages/experience/src/constants/env.ts | 4 + .../MfaFactorList/index.module.scss | 6 ++ .../src/containers/MfaFactorList/index.tsx | 54 ++++++++++++ .../TotpCodeVerification/index.module.scss | 5 ++ .../containers/TotpCodeVerification/index.tsx | 36 ++++++++ .../use-totp-code-verification.ts | 68 +++++++++++++++ .../use-continue-flow-code-verification.ts | 13 +-- .../use-register-flow-code-verification.ts | 14 ++-- .../use-sign-in-flow-code-verification.ts | 15 ++-- .../use-mfa-verification-error-handler.ts | 84 +++++++++++++++++++ .../src/hooks/use-password-sign-in.ts | 9 +- .../hooks/use-pre-sign-in-error-handler.ts | 29 +++++++ .../use-required-profile-error-handler.ts | 2 +- .../src/hooks/use-social-link-related-user.ts | 8 +- .../src/hooks/use-social-register.ts | 11 +-- .../src/hooks/use-social-sign-in-listener.ts | 17 ++-- .../src/hooks/use-start-binding-totp.ts | 47 +++++++++++ .../experience/src/hooks/use-text-handler.ts | 23 +++++ .../src/pages/Continue/SetPassword/index.tsx | 9 +- .../Continue/SetUsername/use-set-username.ts | 9 +- .../SecretSection/index.module.scss | 35 ++++++++ .../TotpBinding/SecretSection/index.tsx | 57 +++++++++++++ .../TotpBinding/VerificationSection.tsx | 24 ++++++ .../MfaBinding/TotpBinding/index.module.scss | 8 ++ .../pages/MfaBinding/TotpBinding/index.tsx | 45 ++++++++++ .../experience/src/pages/MfaBinding/index.tsx | 27 ++++++ .../TotpVerification/index.module.scss | 5 ++ .../TotpVerification/index.tsx | 43 ++++++++++ .../src/pages/MfaVerification/index.tsx | 27 ++++++ .../src/pages/RegisterPassword/index.tsx | 8 +- packages/experience/src/types/guard.ts | 43 +++++++++- packages/experience/src/types/index.ts | 5 ++ pnpm-lock.yaml | 52 ++++++++---- 44 files changed, 988 insertions(+), 78 deletions(-) create mode 100644 packages/experience/src/Layout/SectionLayout/index.module.scss create mode 100644 packages/experience/src/Layout/SectionLayout/index.tsx create mode 100644 packages/experience/src/assets/icons/factor-backup-code.svg create mode 100644 packages/experience/src/assets/icons/factor-totp.svg create mode 100644 packages/experience/src/assets/icons/factor-webauthn.svg create mode 100644 packages/experience/src/components/Button/MfaFactorButton.module.scss create mode 100644 packages/experience/src/components/Button/MfaFactorButton.tsx create mode 100644 packages/experience/src/constants/env.ts create mode 100644 packages/experience/src/containers/MfaFactorList/index.module.scss create mode 100644 packages/experience/src/containers/MfaFactorList/index.tsx create mode 100644 packages/experience/src/containers/TotpCodeVerification/index.module.scss create mode 100644 packages/experience/src/containers/TotpCodeVerification/index.tsx create mode 100644 packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts create mode 100644 packages/experience/src/hooks/use-mfa-verification-error-handler.ts create mode 100644 packages/experience/src/hooks/use-pre-sign-in-error-handler.ts create mode 100644 packages/experience/src/hooks/use-start-binding-totp.ts create mode 100644 packages/experience/src/hooks/use-text-handler.ts create mode 100644 packages/experience/src/pages/MfaBinding/TotpBinding/SecretSection/index.module.scss create mode 100644 packages/experience/src/pages/MfaBinding/TotpBinding/SecretSection/index.tsx create mode 100644 packages/experience/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx create mode 100644 packages/experience/src/pages/MfaBinding/TotpBinding/index.module.scss create mode 100644 packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx create mode 100644 packages/experience/src/pages/MfaBinding/index.tsx create mode 100644 packages/experience/src/pages/MfaVerification/TotpVerification/index.module.scss create mode 100644 packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx create mode 100644 packages/experience/src/pages/MfaVerification/index.tsx diff --git a/packages/experience/package.json b/packages/experience/package.json index fec0bb7f12f..04a1636970b 100644 --- a/packages/experience/package.json +++ b/packages/experience/package.json @@ -45,6 +45,7 @@ "@testing-library/react": "^14.0.0", "@types/color": "^3.0.3", "@types/jest": "^29.4.0", + "@types/qrcode": "^1.5.2", "@types/react": "^18.0.31", "@types/react-dom": "^18.0.0", "@types/react-helmet": "^6.1.6", @@ -117,5 +118,8 @@ "stylelint": { "extends": "@silverhand/eslint-config-react/.stylelintrc" }, - "prettier": "@silverhand/eslint-config/.prettierrc" + "prettier": "@silverhand/eslint-config/.prettierrc", + "dependencies": { + "qrcode": "^1.5.3" + } } diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index 22fa5fa11b5..46a2923c6ec 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -1,4 +1,5 @@ import { AppInsightsBoundary } from '@logto/app-insights/react'; +import { MfaFactor } from '@logto/schemas'; import { Route, Routes, BrowserRouter } from 'react-router-dom'; import AppLayout from './Layout/AppLayout'; @@ -6,11 +7,16 @@ import AppBoundary from './Providers/AppBoundary'; import LoadingLayerProvider from './Providers/LoadingLayerProvider'; import PageContextProvider from './Providers/PageContextProvider'; import SettingsProvider from './Providers/SettingsProvider'; +import { isDevelopmentFeaturesEnabled } from './constants/env'; import Callback from './pages/Callback'; import Consent from './pages/Consent'; import Continue from './pages/Continue'; import ErrorPage from './pages/ErrorPage'; import ForgotPassword from './pages/ForgotPassword'; +import MfaBinding from './pages/MfaBinding'; +import TotpBinding from './pages/MfaBinding/TotpBinding'; +import MfaVerification from './pages/MfaVerification'; +import TotpVerification from './pages/MfaVerification/TotpVerification'; import Register from './pages/Register'; import RegisterPassword from './pages/RegisterPassword'; import ResetPassword from './pages/ResetPassword'; @@ -21,6 +27,7 @@ import SocialLinkAccount from './pages/SocialLinkAccount'; import SocialSignIn from './pages/SocialSignInCallback'; import Springboard from './pages/Springboard'; import VerificationCode from './pages/VerificationCode'; +import { UserMfaFlow } from './types'; import { handleSearchParametersData } from './utils/search-parameters'; import './scss/normalized.scss'; @@ -66,6 +73,24 @@ const App = () => { {/* Passwordless verification code */} } /> + {isDevelopmentFeaturesEnabled && ( + <> + {/* Mfa binding */} + {/* Todo @xiaoyijun reorg these routes when factors are all implemented */} + + } /> + } /> + + + {/* Mfa verification */} + {/* Todo @xiaoyijun reorg these routes when factors are all implemented */} + + } /> + } /> + + + )} + {/* Continue set up missing profile */} } /> diff --git a/packages/experience/src/Layout/SectionLayout/index.module.scss b/packages/experience/src/Layout/SectionLayout/index.module.scss new file mode 100644 index 00000000000..704c6109013 --- /dev/null +++ b/packages/experience/src/Layout/SectionLayout/index.module.scss @@ -0,0 +1,11 @@ +@use '@/scss/underscore' as _; + +.title { + font: var(--font-title-3); +} + +.description { + font: var(--font-body-2); + color: var(--color-type-secondary); + margin-top: _.unit(1); +} diff --git a/packages/experience/src/Layout/SectionLayout/index.tsx b/packages/experience/src/Layout/SectionLayout/index.tsx new file mode 100644 index 00000000000..7cc3495ea5e --- /dev/null +++ b/packages/experience/src/Layout/SectionLayout/index.tsx @@ -0,0 +1,30 @@ +import { type TFuncKey } from 'i18next'; +import { type ReactNode } from 'react'; + +import DynamicT from '@/components/DynamicT'; + +import * as styles from './index.module.scss'; + +type Props = { + title: TFuncKey; + description: TFuncKey; + titleProps?: Record; + descriptionProps?: Record; + children: ReactNode; +}; + +const SectionLayout = ({ title, description, titleProps, descriptionProps, children }: Props) => { + return ( +
+
+ +
+
+ +
+ {children} +
+ ); +}; + +export default SectionLayout; diff --git a/packages/experience/src/apis/interaction.ts b/packages/experience/src/apis/interaction.ts index fa4861946ab..95e9d4b3c70 100644 --- a/packages/experience/src/apis/interaction.ts +++ b/packages/experience/src/apis/interaction.ts @@ -8,6 +8,8 @@ import type { SocialConnectorPayload, SocialEmailPayload, SocialPhonePayload, + BindMfaPayload, + VerifyMfaPayload, } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; @@ -222,3 +224,18 @@ export const linkWithSocial = async (connectorId: string) => { return api.post(`${interactionPrefix}/submit`).json(); }; + +export const createTotpSecret = async () => + api.post(`${interactionPrefix}/${verificationPath}/totp`).json<{ secret: string }>(); + +export const bindMfa = async (payload: BindMfaPayload) => { + await api.put(`${interactionPrefix}/bind-mfa`, { json: payload }); + + return api.post(`${interactionPrefix}/submit`).json(); +}; + +export const verifyMfa = async (payload: VerifyMfaPayload) => { + await api.put(`${interactionPrefix}/mfa`, { json: payload }); + + return api.post(`${interactionPrefix}/submit`).json(); +}; diff --git a/packages/experience/src/assets/icons/factor-backup-code.svg b/packages/experience/src/assets/icons/factor-backup-code.svg new file mode 100644 index 00000000000..3f32f449ee3 --- /dev/null +++ b/packages/experience/src/assets/icons/factor-backup-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/experience/src/assets/icons/factor-totp.svg b/packages/experience/src/assets/icons/factor-totp.svg new file mode 100644 index 00000000000..826eb5eafec --- /dev/null +++ b/packages/experience/src/assets/icons/factor-totp.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/experience/src/assets/icons/factor-webauthn.svg b/packages/experience/src/assets/icons/factor-webauthn.svg new file mode 100644 index 00000000000..8f9d92c1fcc --- /dev/null +++ b/packages/experience/src/assets/icons/factor-webauthn.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/experience/src/components/Button/MfaFactorButton.module.scss b/packages/experience/src/components/Button/MfaFactorButton.module.scss new file mode 100644 index 00000000000..cc0e3d90507 --- /dev/null +++ b/packages/experience/src/components/Button/MfaFactorButton.module.scss @@ -0,0 +1,29 @@ +@use '@/scss/underscore' as _; + +.mfaFactorButton { + padding: _.unit(3) _.unit(4) _.unit(3) _.unit(3); + height: unset; + gap: _.unit(4); + border-radius: 12px; +} + +.icon { + width: 20px; + height: 20px; + color: var(--color-type-secondary); +} + +.title { + flex: 1; + @include _.flex-column; + align-items: flex-start; + + .name { + font: var(--font-body-1); + } + + .description { + font: var(--font-body-2); + color: var(--color-type-secondary); + } +} diff --git a/packages/experience/src/components/Button/MfaFactorButton.tsx b/packages/experience/src/components/Button/MfaFactorButton.tsx new file mode 100644 index 00000000000..07476726de0 --- /dev/null +++ b/packages/experience/src/components/Button/MfaFactorButton.tsx @@ -0,0 +1,73 @@ +import { MfaFactor } from '@logto/schemas'; +import classNames from 'classnames'; +import { type TFuncKey } from 'i18next'; + +import ArrowNext from '@/assets/icons/arrow-next.svg'; +import FactorBackupCode from '@/assets/icons/factor-backup-code.svg'; +import FactorTotp from '@/assets/icons/factor-totp.svg'; +import FactorWebAuthn from '@/assets/icons/factor-webauthn.svg'; + +import DynamicT from '../DynamicT'; + +import * as mfaFactorButtonStyles from './MfaFactorButton.module.scss'; +import * as styles from './index.module.scss'; + +export type Props = { + factor: MfaFactor; + isBinding: boolean; + onClick?: () => void; +}; + +const factorIcon: Record = { + [MfaFactor.TOTP]: FactorTotp, + [MfaFactor.WebAuthn]: FactorWebAuthn, + [MfaFactor.BackupCode]: FactorBackupCode, +}; + +const factorName: Record = { + [MfaFactor.TOTP]: 'mfa.totp', + [MfaFactor.WebAuthn]: 'mfa.webauthn', + [MfaFactor.BackupCode]: 'mfa.backup_code', +}; + +const factorDescription: Record = { + [MfaFactor.TOTP]: 'mfa.verify_totp_description', + [MfaFactor.WebAuthn]: 'mfa.verify_webauthn_description', + [MfaFactor.BackupCode]: 'mfa.verify_backup_code_description', +}; + +const linkFactorDescription: Record = { + [MfaFactor.TOTP]: 'mfa.link_totp_description', + [MfaFactor.WebAuthn]: 'mfa.link_webauthn_description', + [MfaFactor.BackupCode]: 'mfa.link_backup_code_description', +}; + +const MfaFactorButton = ({ factor, isBinding, onClick }: Props) => { + const Icon = factorIcon[factor]; + + return ( + + ); +}; + +export default MfaFactorButton; diff --git a/packages/experience/src/components/Divider/index.tsx b/packages/experience/src/components/Divider/index.tsx index 39f85bf8227..245afee97f3 100644 --- a/packages/experience/src/components/Divider/index.tsx +++ b/packages/experience/src/components/Divider/index.tsx @@ -14,8 +14,12 @@ const Divider = ({ className, label }: Props) => { return (
- {label && } - + {label && ( + <> + + + + )}
); }; diff --git a/packages/experience/src/constants/env.ts b/packages/experience/src/constants/env.ts new file mode 100644 index 00000000000..d98764a3146 --- /dev/null +++ b/packages/experience/src/constants/env.ts @@ -0,0 +1,4 @@ +import { yes } from '@silverhand/essentials'; + +export const isDevelopmentFeaturesEnabled = + yes(process.env.DEV_FEATURES_ENABLED) || yes(process.env.INTEGRATION_TEST); diff --git a/packages/experience/src/containers/MfaFactorList/index.module.scss b/packages/experience/src/containers/MfaFactorList/index.module.scss new file mode 100644 index 00000000000..20bb1f725d4 --- /dev/null +++ b/packages/experience/src/containers/MfaFactorList/index.module.scss @@ -0,0 +1,6 @@ +@use '@/scss/underscore' as _; + +.factorList { + @include _.flex-column; + gap: _.unit(3); +} diff --git a/packages/experience/src/containers/MfaFactorList/index.tsx b/packages/experience/src/containers/MfaFactorList/index.tsx new file mode 100644 index 00000000000..b4a6fe48edf --- /dev/null +++ b/packages/experience/src/containers/MfaFactorList/index.tsx @@ -0,0 +1,54 @@ +import { MfaFactor } from '@logto/schemas'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import MfaFactorButton from '@/components/Button/MfaFactorButton'; +import useStartTotpBinding from '@/hooks/use-start-binding-totp'; +import { UserMfaFlow } from '@/types'; +import { type TotpVerificationState } from '@/types/guard'; + +import * as styles from './index.module.scss'; + +type Props = { + flow: UserMfaFlow; + factors: MfaFactor[]; +}; + +const MfaFactorList = ({ flow, factors }: Props) => { + const startTotpBinding = useStartTotpBinding(); + const navigate = useNavigate(); + + const handleSelectFactor = useCallback( + async (factor: MfaFactor) => { + if (factor === MfaFactor.TOTP) { + if (flow === UserMfaFlow.MfaBinding) { + await startTotpBinding(factors.length > 1); + } + + if (flow === UserMfaFlow.MfaVerification) { + const state: TotpVerificationState = { allowOtherFactors: true }; + navigate(`/${UserMfaFlow.MfaVerification}/${factor}`, { state }); + } + } + // Todo @xiaoyijun implement other factors + }, + [factors.length, flow, navigate, startTotpBinding] + ); + + return ( +
+ {factors.map((factor) => ( + { + void handleSelectFactor(factor); + }} + /> + ))} +
+ ); +}; + +export default MfaFactorList; diff --git a/packages/experience/src/containers/TotpCodeVerification/index.module.scss b/packages/experience/src/containers/TotpCodeVerification/index.module.scss new file mode 100644 index 00000000000..d4975177631 --- /dev/null +++ b/packages/experience/src/containers/TotpCodeVerification/index.module.scss @@ -0,0 +1,5 @@ +@use '@/scss/underscore' as _; + +.totpCodeInput { + margin-top: _.unit(4); +} diff --git a/packages/experience/src/containers/TotpCodeVerification/index.tsx b/packages/experience/src/containers/TotpCodeVerification/index.tsx new file mode 100644 index 00000000000..e4b14ccbdf1 --- /dev/null +++ b/packages/experience/src/containers/TotpCodeVerification/index.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; + +import VerificationCodeInput from '@/components/VerificationCode'; +import { type UserMfaFlow } from '@/types'; + +import * as styles from './index.module.scss'; +import useTotpCodeVerification from './use-totp-code-verification'; + +const totpCodeLength = 6; + +type Options = { + flow: UserMfaFlow; +}; + +const TotpCodeVerification = ({ flow }: Options) => { + const [code, setCode] = useState([]); + const { errorMessage, onSubmit } = useTotpCodeVerification({ flow }); + + return ( + { + setCode(code); + + if (code.length === totpCodeLength && code.every(Boolean)) { + void onSubmit(code.join('')); + } + }} + /> + ); +}; + +export default TotpCodeVerification; diff --git a/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts b/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts new file mode 100644 index 00000000000..de87351e860 --- /dev/null +++ b/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts @@ -0,0 +1,68 @@ +import { MfaFactor } from '@logto/schemas'; +import { useCallback, useMemo, useState } from 'react'; + +import { bindMfa, verifyMfa } from '@/apis/interaction'; +import useApi from '@/hooks/use-api'; +import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler'; +import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; +import { UserMfaFlow } from '@/types'; + +type Options = { + flow: UserMfaFlow; +}; +const useTotpCodeVerification = ({ flow }: Options) => { + const [errorMessage, setErrorMessage] = useState(); + const asyncBindMfa = useApi(bindMfa); + const asyncVerifyMfa = useApi(verifyMfa); + + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); + const handleError = useErrorHandler(); + + const errorHandlers: ErrorHandlers = useMemo( + () => ({ + 'session.mfa.invalid_totp_code': (error) => { + setErrorMessage(error.message); + }, + ...preSignInErrorHandler, + }), + [preSignInErrorHandler] + ); + + const onSubmit = useCallback( + async (code: string) => { + // Todo @xiaoyijun refactor this logic + if (flow === UserMfaFlow.MfaBinding) { + const [error, result] = await asyncBindMfa({ type: MfaFactor.TOTP, code }); + if (error) { + await handleError(error, errorHandlers); + return; + } + + if (result) { + window.location.replace(result.redirectTo); + } + + return; + } + + // Verify TOTP + const [error, result] = await asyncVerifyMfa({ type: MfaFactor.TOTP, code }); + if (error) { + await handleError(error, errorHandlers); + return; + } + + if (result) { + window.location.replace(result.redirectTo); + } + }, + [asyncBindMfa, asyncVerifyMfa, errorHandlers, flow, handleError] + ); + + return { + errorMessage, + onSubmit, + }; +}; + +export default useTotpCodeVerification; diff --git a/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts index c961827ed25..15fb2bb1946 100644 --- a/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts @@ -7,7 +7,7 @@ import { addProfileWithVerificationCodeIdentifier } from '@/apis/interaction'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; -import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; +import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import type { VerificationCodeIdentifier } from '@/types'; import { SearchParameters } from '@/types'; @@ -27,7 +27,8 @@ const useContinueFlowCodeVerification = ( const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } = useGeneralVerificationCodeErrorHandler(); - const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace: true }); + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); + const showIdentifierErrorAlert = useIdentifierErrorAlert(); const showLinkSocialConfirmModal = useLinkSocialConfirmModal(); const identifierExistErrorHandler = useCallback( @@ -52,14 +53,14 @@ const useContinueFlowCodeVerification = ( identifierExistErrorHandler(SignInIdentifier.Phone, target), 'user.email_already_in_use': async () => identifierExistErrorHandler(SignInIdentifier.Email, target), - ...requiredProfileErrorHandler, + ...preSignInErrorHandler, ...generalVerificationCodeErrorHandlers, }), [ - target, - identifierExistErrorHandler, - requiredProfileErrorHandler, + preSignInErrorHandler, generalVerificationCodeErrorHandlers, + identifierExistErrorHandler, + target, ] ); diff --git a/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts index aaef79ed20c..2bb745509dd 100644 --- a/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts @@ -12,7 +12,7 @@ import useApi from '@/hooks/use-api'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; -import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; +import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import { useSieMethods } from '@/hooks/use-sie'; import type { VerificationCodeIdentifier } from '@/types'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; @@ -37,8 +37,8 @@ const useRegisterFlowCodeVerification = ( const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } = useGeneralVerificationCodeErrorHandler(); + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); - const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({ replace: true }); const showIdentifierErrorAlert = useIdentifierErrorAlert(); const identifierExistErrorHandler = useCallback(async () => { // Should not redirect user to sign-in if is register-only mode @@ -68,7 +68,7 @@ const useRegisterFlowCodeVerification = ( const [error, result] = await signInWithIdentifierAsync(); if (error) { - await handleError(error, requiredProfileErrorHandlers); + await handleError(error, preSignInErrorHandler); return; } @@ -80,7 +80,7 @@ const useRegisterFlowCodeVerification = ( handleError, method, navigate, - requiredProfileErrorHandlers, + preSignInErrorHandler, show, showIdentifierErrorAlert, signInMode, @@ -94,14 +94,14 @@ const useRegisterFlowCodeVerification = ( 'user.email_already_in_use': identifierExistErrorHandler, 'user.phone_already_in_use': identifierExistErrorHandler, ...generalVerificationCodeErrorHandlers, - ...requiredProfileErrorHandlers, + ...preSignInErrorHandler, callback: errorCallback, }), [ - errorCallback, identifierExistErrorHandler, - requiredProfileErrorHandlers, generalVerificationCodeErrorHandlers, + preSignInErrorHandler, + errorCallback, ] ); diff --git a/packages/experience/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts index a8c79071464..5ddbac4e78b 100644 --- a/packages/experience/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts @@ -12,7 +12,7 @@ import useApi from '@/hooks/use-api'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; -import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; +import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import { useSieMethods } from '@/hooks/use-sie'; import type { VerificationCodeIdentifier } from '@/types'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; @@ -37,9 +37,8 @@ const useSignInFlowCodeVerification = ( const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } = useGeneralVerificationCodeErrorHandler(); - const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({ - replace: true, - }); + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); + const showIdentifierErrorAlert = useIdentifierErrorAlert(); const identifierNotExistErrorHandler = useCallback(async () => { @@ -72,7 +71,7 @@ const useSignInFlowCodeVerification = ( ); if (error) { - await handleError(error, requiredProfileErrorHandlers); + await handleError(error, preSignInErrorHandler); return; } @@ -85,7 +84,7 @@ const useSignInFlowCodeVerification = ( method, navigate, registerWithIdentifierAsync, - requiredProfileErrorHandlers, + preSignInErrorHandler, show, showIdentifierErrorAlert, signInMode, @@ -97,13 +96,13 @@ const useSignInFlowCodeVerification = ( () => ({ 'user.user_not_exist': identifierNotExistErrorHandler, ...generalVerificationCodeErrorHandlers, - ...requiredProfileErrorHandlers, + ...preSignInErrorHandler, callback: errorCallback, }), [ errorCallback, identifierNotExistErrorHandler, - requiredProfileErrorHandlers, + preSignInErrorHandler, generalVerificationCodeErrorHandlers, ] ); diff --git a/packages/experience/src/hooks/use-mfa-verification-error-handler.ts b/packages/experience/src/hooks/use-mfa-verification-error-handler.ts new file mode 100644 index 00000000000..d0ddddcbe3b --- /dev/null +++ b/packages/experience/src/hooks/use-mfa-verification-error-handler.ts @@ -0,0 +1,84 @@ +import { MfaFactor } from '@logto/schemas'; +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { validate } from 'superstruct'; + +import { UserMfaFlow } from '@/types'; +import { + type MfaFactorsState, + missingMfaFactorsErrorDataGuard, + requireMfaFactorsErrorDataGuard, + type TotpVerificationState, +} from '@/types/guard'; + +import type { ErrorHandlers } from './use-error-handler'; +import useStartTotpBinding from './use-start-binding-totp'; +import useToast from './use-toast'; + +export type Options = { + replace?: boolean; +}; + +const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => { + const navigate = useNavigate(); + const { setToast } = useToast(); + const startBindingTotp = useStartTotpBinding({ replace }); + + const mfaVerificationErrorHandler = useMemo( + () => ({ + 'user.missing_mfa': (error) => { + const [_, data] = validate(error.data, missingMfaFactorsErrorDataGuard); + const missingFactors = data?.missingFactors ?? []; + + if (missingFactors.length === 0) { + setToast(error.message); + return; + } + + if (missingFactors.length > 1) { + const state: MfaFactorsState = { factors: missingFactors }; + navigate({ pathname: `/${UserMfaFlow.MfaBinding}` }, { replace, state }); + return; + } + + const factor = missingFactors[0]; + + if (factor === MfaFactor.TOTP) { + void startBindingTotp(); + } + // Todo: @xiaoyijun handle other factors + }, + 'session.mfa.require_mfa_verification': async (error) => { + const [_, data] = validate(error.data, requireMfaFactorsErrorDataGuard); + const availableFactors = data?.availableFactors ?? []; + if (availableFactors.length === 0) { + setToast(error.message); + return; + } + + if (availableFactors.length > 1) { + const state: MfaFactorsState = { factors: availableFactors }; + navigate({ pathname: `/${UserMfaFlow.MfaVerification}` }, { replace, state }); + return; + } + + const factor = availableFactors[0]; + if (!factor) { + setToast(error.message); + return; + } + + if (factor === MfaFactor.TOTP) { + const state: TotpVerificationState = { allowOtherFactors: false }; + navigate({ pathname: `/${UserMfaFlow.MfaVerification}/${factor}` }, { replace, state }); + } + // Todo: @xiaoyijun handle other factors + }, + }), + [navigate, replace, setToast, startBindingTotp] + ); + + return mfaVerificationErrorHandler; +}; + +export default useMfaVerificationErrorHandler; diff --git a/packages/experience/src/hooks/use-password-sign-in.ts b/packages/experience/src/hooks/use-password-sign-in.ts index c406e059e88..c05d196d92b 100644 --- a/packages/experience/src/hooks/use-password-sign-in.ts +++ b/packages/experience/src/hooks/use-password-sign-in.ts @@ -6,7 +6,7 @@ import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; -import useRequiredProfileErrorHandler from './use-required-profile-error-handler'; +import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; const usePasswordSignIn = () => { const [errorMessage, setErrorMessage] = useState(); @@ -17,17 +17,16 @@ const usePasswordSignIn = () => { const handleError = useErrorHandler(); const asyncSignIn = useApi(signInWithPasswordIdentifier); - - const requiredProfileErrorHandler = useRequiredProfileErrorHandler(); + const preSignInErrorHandler = usePreSignInErrorHandler(); const errorHandlers: ErrorHandlers = useMemo( () => ({ 'session.invalid_credentials': (error) => { setErrorMessage(error.message); }, - ...requiredProfileErrorHandler, + ...preSignInErrorHandler, }), - [requiredProfileErrorHandler] + [preSignInErrorHandler] ); const onSubmit = useCallback( diff --git a/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts b/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts new file mode 100644 index 00000000000..9e4e59b9757 --- /dev/null +++ b/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts @@ -0,0 +1,29 @@ +import { conditional } from '@silverhand/essentials'; +import { useMemo } from 'react'; + +import { isDevelopmentFeaturesEnabled } from '@/constants/env'; + +import { type ErrorHandlers } from './use-error-handler'; +import useMfaVerificationErrorHandler, { + type Options as UseMfaVerificationErrorHandlerOptions, +} from './use-mfa-verification-error-handler'; +import useRequiredProfileErrorHandler, { + type Options as UseRequiredProfileErrorHandlerOptions, +} from './use-required-profile-error-handler'; + +type Options = UseRequiredProfileErrorHandlerOptions & UseMfaVerificationErrorHandlerOptions; + +const usePreSignInErrorHandler = ({ replace, linkSocial }: Options = {}): ErrorHandlers => { + const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, linkSocial }); + const mfaVerificationErrorHandler = useMfaVerificationErrorHandler({ replace }); + + return useMemo( + () => ({ + ...requiredProfileErrorHandler, + ...conditional(isDevelopmentFeaturesEnabled && mfaVerificationErrorHandler), + }), + [mfaVerificationErrorHandler, requiredProfileErrorHandler] + ); +}; + +export default usePreSignInErrorHandler; diff --git a/packages/experience/src/hooks/use-required-profile-error-handler.ts b/packages/experience/src/hooks/use-required-profile-error-handler.ts index 24a0ef7d7ce..af74b4f0961 100644 --- a/packages/experience/src/hooks/use-required-profile-error-handler.ts +++ b/packages/experience/src/hooks/use-required-profile-error-handler.ts @@ -10,7 +10,7 @@ import { queryStringify } from '@/utils'; import type { ErrorHandlers } from './use-error-handler'; import useToast from './use-toast'; -type Options = { +export type Options = { replace?: boolean; linkSocial?: string; }; diff --git a/packages/experience/src/hooks/use-social-link-related-user.ts b/packages/experience/src/hooks/use-social-link-related-user.ts index 25a22e35bb1..79d7eecb9df 100644 --- a/packages/experience/src/hooks/use-social-link-related-user.ts +++ b/packages/experience/src/hooks/use-social-link-related-user.ts @@ -4,11 +4,11 @@ import { bindSocialRelatedUser } from '@/apis/interaction'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; -import useRequiredProfileErrorHandler from './use-required-profile-error-handler'; +import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; const useBindSocialRelatedUser = () => { const handleError = useErrorHandler(); - const requiredProfileErrorHandlers = useRequiredProfileErrorHandler(); + const preSignInErrorHandler = usePreSignInErrorHandler(); const asyncBindSocialRelatedUser = useApi(bindSocialRelatedUser); @@ -17,7 +17,7 @@ const useBindSocialRelatedUser = () => { const [error, result] = await asyncBindSocialRelatedUser(...payload); if (error) { - await handleError(error, requiredProfileErrorHandlers); + await handleError(error, preSignInErrorHandler); return; } @@ -26,7 +26,7 @@ const useBindSocialRelatedUser = () => { window.location.replace(result.redirectTo); } }, - [asyncBindSocialRelatedUser, handleError, requiredProfileErrorHandlers] + [asyncBindSocialRelatedUser, handleError, preSignInErrorHandler] ); }; diff --git a/packages/experience/src/hooks/use-social-register.ts b/packages/experience/src/hooks/use-social-register.ts index 9180064a8e1..3312103cdb9 100644 --- a/packages/experience/src/hooks/use-social-register.ts +++ b/packages/experience/src/hooks/use-social-register.ts @@ -4,23 +4,20 @@ import { registerWithVerifiedSocial } from '@/apis/interaction'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; -import useRequiredProfileErrorHandler from './use-required-profile-error-handler'; +import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; const useSocialRegister = (connectorId?: string, replace?: boolean) => { const handleError = useErrorHandler(); const asyncRegisterWithSocial = useApi(registerWithVerifiedSocial); - const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({ - linkSocial: connectorId, - replace, - }); + const preSignInErrorHandler = usePreSignInErrorHandler({ linkSocial: connectorId, replace }); return useCallback( async (connectorId: string) => { const [error, result] = await asyncRegisterWithSocial(connectorId); if (error) { - await handleError(error, requiredProfileErrorHandlers); + await handleError(error, preSignInErrorHandler); return; } @@ -29,7 +26,7 @@ const useSocialRegister = (connectorId?: string, replace?: boolean) => { window.location.replace(result.redirectTo); } }, - [asyncRegisterWithSocial, handleError, requiredProfileErrorHandlers] + [asyncRegisterWithSocial, handleError, preSignInErrorHandler] ); }; diff --git a/packages/experience/src/hooks/use-social-sign-in-listener.ts b/packages/experience/src/hooks/use-social-sign-in-listener.ts index cfd4dd93cc6..e7ec9af2962 100644 --- a/packages/experience/src/hooks/use-social-sign-in-listener.ts +++ b/packages/experience/src/hooks/use-social-sign-in-listener.ts @@ -13,7 +13,7 @@ import { stateValidation } from '@/utils/social-connectors'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; import type { ErrorHandlers } from './use-error-handler'; -import useRequiredProfileErrorHandler from './use-required-profile-error-handler'; +import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; import { useSieMethods } from './use-sie'; import useSocialRegister from './use-social-register'; import useTerms from './use-terms'; @@ -58,9 +58,8 @@ const useSocialSignInListener = (connectorId?: string) => { }, [connectorId, navigate, registerWithSocial] ); - const requiredProfileErrorHandlers = useRequiredProfileErrorHandler({ - replace: true, - }); + + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); const signInWithSocialErrorHandlers: ErrorHandlers = useMemo( () => ({ @@ -79,15 +78,9 @@ const useSocialSignInListener = (connectorId?: string) => { await accountNotExistErrorHandler(error); }, - ...requiredProfileErrorHandlers, + ...preSignInErrorHandler, }), - [ - requiredProfileErrorHandlers, - signInMode, - termsValidation, - accountNotExistErrorHandler, - setToast, - ] + [preSignInErrorHandler, signInMode, termsValidation, accountNotExistErrorHandler, setToast] ); const signInWithSocialHandler = useCallback( diff --git a/packages/experience/src/hooks/use-start-binding-totp.ts b/packages/experience/src/hooks/use-start-binding-totp.ts new file mode 100644 index 00000000000..5b268bf495c --- /dev/null +++ b/packages/experience/src/hooks/use-start-binding-totp.ts @@ -0,0 +1,47 @@ +import { MfaFactor } from '@logto/schemas'; +import qrcode from 'qrcode'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { createTotpSecret } from '@/apis/interaction'; +import useApi from '@/hooks/use-api'; +import useErrorHandler from '@/hooks/use-error-handler'; +import { UserMfaFlow } from '@/types'; +import { type TotpBindingState } from '@/types/guard'; + +type Options = { + replace?: boolean; +}; + +const useStartTotpBinding = ({ replace }: Options = {}) => { + const navigate = useNavigate(); + const asyncCreateTotpSecret = useApi(createTotpSecret); + + const handleError = useErrorHandler(); + + return useCallback( + async (allowOtherFactors = false) => { + const [error, result] = await asyncCreateTotpSecret(); + + if (error) { + await handleError(error); + return; + } + + const { secret } = result ?? {}; + + if (secret) { + const state: TotpBindingState = { + secret, + // Todo @wangsijie generate QR code on the server side + secretQrCode: await qrcode.toDataURL(`otpauth://totp/?secret=${secret}`), + allowOtherFactors, + }; + navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state }); + } + }, + [asyncCreateTotpSecret, handleError, navigate, replace] + ); +}; + +export default useStartTotpBinding; diff --git a/packages/experience/src/hooks/use-text-handler.ts b/packages/experience/src/hooks/use-text-handler.ts new file mode 100644 index 00000000000..5ddb26b807d --- /dev/null +++ b/packages/experience/src/hooks/use-text-handler.ts @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; + +import useToast from './use-toast'; + +const useTextHandler = () => { + const { setToast } = useToast(); + + const copyText = useCallback( + async (text: string, successMessage: string) => { + await navigator.clipboard.writeText(text); + setToast(successMessage); + }, + [setToast] + ); + + // Todo: @xiaoyijun add download text file handler + + return { + copyText, + }; +}; + +export default useTextHandler; diff --git a/packages/experience/src/pages/Continue/SetPassword/index.tsx b/packages/experience/src/pages/Continue/SetPassword/index.tsx index e21dba1d1b4..b0f57c667e4 100644 --- a/packages/experience/src/pages/Continue/SetPassword/index.tsx +++ b/packages/experience/src/pages/Continue/SetPassword/index.tsx @@ -7,7 +7,7 @@ import SetPasswordForm from '@/containers/SetPassword'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action'; -import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; +import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import { usePasswordPolicy } from '@/hooks/use-sie'; const SetPassword = () => { @@ -19,16 +19,17 @@ const SetPassword = () => { const navigate = useNavigate(); const { show } = useConfirmModal(); - const requiredProfileErrorHandler = useRequiredProfileErrorHandler(); + const preSignInErrorHandler = usePreSignInErrorHandler(); + const errorHandlers: ErrorHandlers = useMemo( () => ({ 'user.password_exists_in_profile': async (error) => { await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); navigate(-1); }, - ...requiredProfileErrorHandler, + ...preSignInErrorHandler, }), - [navigate, requiredProfileErrorHandler, show] + [navigate, preSignInErrorHandler, show] ); const successHandler: SuccessHandler = useCallback((result) => { if (result?.redirectTo) { diff --git a/packages/experience/src/pages/Continue/SetUsername/use-set-username.ts b/packages/experience/src/pages/Continue/SetUsername/use-set-username.ts index 3cd87980473..a0d98364f1b 100644 --- a/packages/experience/src/pages/Continue/SetUsername/use-set-username.ts +++ b/packages/experience/src/pages/Continue/SetUsername/use-set-username.ts @@ -4,7 +4,7 @@ import { addProfile } from '@/apis/interaction'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; -import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler'; +import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; const useSetUsername = () => { const [errorMessage, setErrorMessage] = useState(); @@ -15,16 +15,17 @@ const useSetUsername = () => { const asyncAddProfile = useApi(addProfile); const handleError = useErrorHandler(); - const requiredProfileErrorHandler = useRequiredProfileErrorHandler(); + + const preSignInErrorHandler = usePreSignInErrorHandler(); const errorHandlers: ErrorHandlers = useMemo( () => ({ 'user.username_already_in_use': (error) => { setErrorMessage(error.message); }, - ...requiredProfileErrorHandler, + ...preSignInErrorHandler, }), - [requiredProfileErrorHandler] + [preSignInErrorHandler] ); const onSubmit = useCallback( diff --git a/packages/experience/src/pages/MfaBinding/TotpBinding/SecretSection/index.module.scss b/packages/experience/src/pages/MfaBinding/TotpBinding/SecretSection/index.module.scss new file mode 100644 index 00000000000..7b83c3ca169 --- /dev/null +++ b/packages/experience/src/pages/MfaBinding/TotpBinding/SecretSection/index.module.scss @@ -0,0 +1,35 @@ +@use '@/scss/underscore' as _; + +.secretContent { + @include _.flex-column(center); + + .qrCode { + border: 1px solid var(--color-line-divider); + margin: _.unit(4); + height: 136px; + width: 136px; + + > img { + width: 100%; + height: 100%; + display: block; + @include _.image-align-center; + } + } + + .rawSecret { + padding: _.unit(4); + width: 100%; + text-align: center; + font: var(--font-label-1); + border-radius: var(--radius); + background-color: var(--color-bg-layer-2); + color: var(--color-type-primary); + margin-bottom: _.unit(3); + } + + .copySecret { + width: 100%; + margin: _.unit(4); + } +} diff --git a/packages/experience/src/pages/MfaBinding/TotpBinding/SecretSection/index.tsx b/packages/experience/src/pages/MfaBinding/TotpBinding/SecretSection/index.tsx new file mode 100644 index 00000000000..b45e91b0afb --- /dev/null +++ b/packages/experience/src/pages/MfaBinding/TotpBinding/SecretSection/index.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import SectionLayout from '@/Layout/SectionLayout'; +import Button from '@/components/Button'; +import TextLink from '@/components/TextLink'; +import useTextHandler from '@/hooks/use-text-handler'; +import { type TotpBindingState } from '@/types/guard'; + +import * as styles from './index.module.scss'; + +const SecretSection = ({ secret, secretQrCode }: TotpBindingState) => { + const { t } = useTranslation(); + const [isQrCodeFormat, setIsQrCodeFormat] = useState(true); + const { copyText } = useTextHandler(); + + return ( + +
+ {isQrCodeFormat && secretQrCode && ( +
+ QR code +
+ )} + {!isQrCodeFormat && ( +
+
{secret}
+
+ )} + { + setIsQrCodeFormat(!isQrCodeFormat); + }} + /> +
+
+ ); +}; + +export default SecretSection; diff --git a/packages/experience/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx b/packages/experience/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx new file mode 100644 index 00000000000..3f46d54e0e7 --- /dev/null +++ b/packages/experience/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next'; + +import SectionLayout from '@/Layout/SectionLayout'; +import TotpCodeVerification from '@/containers/TotpCodeVerification'; +import { UserMfaFlow } from '@/types'; + +const VerificationSection = () => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + +export default VerificationSection; diff --git a/packages/experience/src/pages/MfaBinding/TotpBinding/index.module.scss b/packages/experience/src/pages/MfaBinding/TotpBinding/index.module.scss new file mode 100644 index 00000000000..760ca4d19b8 --- /dev/null +++ b/packages/experience/src/pages/MfaBinding/TotpBinding/index.module.scss @@ -0,0 +1,8 @@ +@use '@/scss/underscore' as _; + +.container { + @include _.flex-column; + gap: _.unit(6); + margin-bottom: _.unit(6); + align-items: stretch; +} diff --git a/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx b/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx new file mode 100644 index 00000000000..68e7c0e14a4 --- /dev/null +++ b/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx @@ -0,0 +1,45 @@ +import { useLocation } from 'react-router-dom'; +import { validate } from 'superstruct'; + +import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import SwitchIcon from '@/assets/icons/switch-icon.svg'; +import Divider from '@/components/Divider'; +import TextLink from '@/components/TextLink'; +import ErrorPage from '@/pages/ErrorPage'; +import { UserMfaFlow } from '@/types'; +import { totpBindingStateGuard } from '@/types/guard'; + +import SecretSection from './SecretSection'; +import VerificationSection from './VerificationSection'; +import * as styles from './index.module.scss'; + +const TotpBinding = () => { + const { state } = useLocation(); + const [, totpBindingState] = validate(state, totpBindingStateGuard); + + if (!totpBindingState) { + return ; + } + + return ( + +
+ + + + {totpBindingState.allowOtherFactors && ( + <> + + } + /> + + )} +
+
+ ); +}; + +export default TotpBinding; diff --git a/packages/experience/src/pages/MfaBinding/index.tsx b/packages/experience/src/pages/MfaBinding/index.tsx new file mode 100644 index 00000000000..cd75591003d --- /dev/null +++ b/packages/experience/src/pages/MfaBinding/index.tsx @@ -0,0 +1,27 @@ +import { useLocation } from 'react-router-dom'; +import { validate } from 'superstruct'; + +import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import MfaFactorList from '@/containers/MfaFactorList'; +import { UserMfaFlow } from '@/types'; +import { mfaFactorsStateGuard } from '@/types/guard'; + +import ErrorPage from '../ErrorPage'; + +const MfaBinding = () => { + const { state } = useLocation(); + const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard); + const { factors } = mfaFactorsState ?? {}; + + if (!factors || factors.length === 0) { + return ; + } + + return ( + + + + ); +}; + +export default MfaBinding; diff --git a/packages/experience/src/pages/MfaVerification/TotpVerification/index.module.scss b/packages/experience/src/pages/MfaVerification/TotpVerification/index.module.scss new file mode 100644 index 00000000000..f7685c7b528 --- /dev/null +++ b/packages/experience/src/pages/MfaVerification/TotpVerification/index.module.scss @@ -0,0 +1,5 @@ +@use '@/scss/underscore' as _; + +.switchFactorLink { + margin-top: _.unit(6); +} diff --git a/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx b/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx new file mode 100644 index 00000000000..dbd7bb36d2c --- /dev/null +++ b/packages/experience/src/pages/MfaVerification/TotpVerification/index.tsx @@ -0,0 +1,43 @@ +import { useLocation } from 'react-router-dom'; +import { validate } from 'superstruct'; + +import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import SectionLayout from '@/Layout/SectionLayout'; +import SwitchIcon from '@/assets/icons/switch-icon.svg'; +import TextLink from '@/components/TextLink'; +import TotpCodeVerification from '@/containers/TotpCodeVerification'; +import ErrorPage from '@/pages/ErrorPage'; +import { UserMfaFlow } from '@/types'; +import { totpVerificationStateGuard } from '@/types/guard'; + +import * as styles from './index.module.scss'; + +const TotpVerification = () => { + const { state } = useLocation(); + const [, totpVerificationState] = validate(state, totpVerificationStateGuard); + + if (!totpVerificationState) { + return ; + } + + return ( + + + + + {totpVerificationState.allowOtherFactors && ( + } + className={styles.switchFactorLink} + /> + )} + + ); +}; + +export default TotpVerification; diff --git a/packages/experience/src/pages/MfaVerification/index.tsx b/packages/experience/src/pages/MfaVerification/index.tsx new file mode 100644 index 00000000000..73f1cf04cd3 --- /dev/null +++ b/packages/experience/src/pages/MfaVerification/index.tsx @@ -0,0 +1,27 @@ +import { useLocation } from 'react-router-dom'; +import { validate } from 'superstruct'; + +import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import MfaFactorList from '@/containers/MfaFactorList'; +import { UserMfaFlow } from '@/types'; +import { mfaFactorsStateGuard } from '@/types/guard'; + +import ErrorPage from '../ErrorPage'; + +const MfaVerification = () => { + const { state } = useLocation(); + const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard); + const { factors } = mfaFactorsState ?? {}; + + if (!factors || factors.length === 0) { + return ; + } + + return ( + + + + ); +}; + +export default MfaVerification; diff --git a/packages/experience/src/pages/RegisterPassword/index.tsx b/packages/experience/src/pages/RegisterPassword/index.tsx index 79e461167ee..e082ed22de3 100644 --- a/packages/experience/src/pages/RegisterPassword/index.tsx +++ b/packages/experience/src/pages/RegisterPassword/index.tsx @@ -7,6 +7,7 @@ import { setUserPassword } from '@/apis/interaction'; import SetPassword from '@/containers/SetPassword'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { type ErrorHandlers } from '@/hooks/use-error-handler'; +import useMfaVerificationErrorHandler from '@/hooks/use-mfa-verification-error-handler'; import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action'; import { usePasswordPolicy, useSieMethods } from '@/hooks/use-sie'; @@ -21,6 +22,9 @@ const RegisterPassword = () => { const clearErrorMessage = useCallback(() => { setErrorMessage(undefined); }, []); + + const mfaVerificationErrorHandler = useMfaVerificationErrorHandler({ replace: true }); + const errorHandlers: ErrorHandlers = useMemo( () => ({ // Incase previous page submitted username has been taken @@ -28,9 +32,11 @@ const RegisterPassword = () => { await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); navigate(-1); }, + ...mfaVerificationErrorHandler, }), - [navigate, show] + [navigate, mfaVerificationErrorHandler, show] ); + const successHandler: SuccessHandler = useCallback((result) => { if (result && 'redirectTo' in result) { window.location.replace(result.redirectTo); diff --git a/packages/experience/src/types/guard.ts b/packages/experience/src/types/guard.ts index b8e2885610b..256ac06aa57 100644 --- a/packages/experience/src/types/guard.ts +++ b/packages/experience/src/types/guard.ts @@ -1,4 +1,4 @@ -import { SignInIdentifier, MissingProfile } from '@logto/schemas'; +import { SignInIdentifier, MissingProfile, MfaFactor } from '@logto/schemas'; import * as s from 'superstruct'; import { UserFlow } from '.'; @@ -61,3 +61,44 @@ export const socialAccountNotExistErrorDataGuard = s.object({ export type SocialRelatedUserInfo = s.Infer< typeof socialAccountNotExistErrorDataGuard >['relatedUser']; + +/* Mfa */ +const mfaFactorsGuard = s.array( + s.union([ + s.literal(MfaFactor.TOTP), + s.literal(MfaFactor.WebAuthn), + s.literal(MfaFactor.BackupCode), + ]) +); + +export const missingMfaFactorsErrorDataGuard = s.object({ + missingFactors: mfaFactorsGuard, +}); + +export const requireMfaFactorsErrorDataGuard = s.object({ + availableFactors: mfaFactorsGuard, +}); + +export const mfaFactorsStateGuard = s.object({ + factors: mfaFactorsGuard, +}); + +export type MfaFactorsState = s.Infer; + +const mfaFlowStateGuard = s.object({ + allowOtherFactors: s.boolean(), +}); + +export const totpBindingStateGuard = s.assign( + s.object({ + secret: s.string(), + secretQrCode: s.string(), + }), + mfaFlowStateGuard +); + +export type TotpBindingState = s.Infer; + +export const totpVerificationStateGuard = mfaFlowStateGuard; + +export type TotpVerificationState = s.Infer; diff --git a/packages/experience/src/types/index.ts b/packages/experience/src/types/index.ts index b45f2483193..9eefa76833e 100644 --- a/packages/experience/src/types/index.ts +++ b/packages/experience/src/types/index.ts @@ -7,6 +7,11 @@ export enum UserFlow { Continue = 'continue', } +export enum UserMfaFlow { + MfaBinding = 'mfa-binding', + MfaVerification = 'mfa-verification', +} + export enum SearchParameters { NativeCallbackLink = 'native_callback', RedirectTo = 'redirect_to', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cd80fd7492..2fb5a37bea4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3472,6 +3472,10 @@ importers: version: 3.20.2 packages/experience: + dependencies: + qrcode: + specifier: ^1.5.3 + version: 1.5.3 devDependencies: '@jest/types': specifier: ^29.5.0 @@ -3548,6 +3552,9 @@ importers: '@types/jest': specifier: ^29.4.0 version: 29.4.0 + '@types/qrcode': + specifier: ^1.5.2 + version: 1.5.2 '@types/react': specifier: ^18.0.31 version: 18.0.31 @@ -9759,6 +9766,12 @@ packages: resolution: {integrity: sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==} dev: true + /@types/qrcode@1.5.2: + resolution: {integrity: sha512-W4KDz75m7rJjFbyCctzCtRzZUj+PrUHV+YjqDp50sSRezTbrtEAIq2iTzC6lISARl3qw+8IlcCyljdcVJE0Wug==} + dependencies: + '@types/node': 18.11.18 + dev: true + /@types/qs@6.9.7: resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} dev: true @@ -10790,7 +10803,6 @@ packages: /camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - dev: true /camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} @@ -10963,7 +10975,6 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - dev: true /cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -11565,7 +11576,6 @@ packages: /decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} - dev: true /decamelize@5.0.1: resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} @@ -11758,6 +11768,10 @@ packages: engines: {node: '>=0.3.1'} dev: true + /dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dev: false + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -11903,6 +11917,10 @@ packages: /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + /encode-utf8@1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==} + dev: false + /encodeurl@1.0.2: resolution: {integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=} engines: {node: '>= 0.8'} @@ -12835,7 +12853,6 @@ packages: dependencies: locate-path: 5.0.0 path-exists: 4.0.0 - dev: true /find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} @@ -15348,7 +15365,6 @@ packages: engines: {node: '>=8'} dependencies: p-locate: 4.1.0 - dev: true /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -16752,7 +16768,6 @@ packages: engines: {node: '>=6'} dependencies: p-try: 2.2.0 - dev: true /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} @@ -16773,7 +16788,6 @@ packages: engines: {node: '>=8'} dependencies: p-limit: 2.3.0 - dev: true /p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} @@ -16818,7 +16832,6 @@ packages: /p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - dev: true /pac-proxy-agent@7.0.0: resolution: {integrity: sha512-t4tRAMx0uphnZrio0S0Jw9zg3oDbz1zVhQ/Vy18FjLfP1XOLNUEjaVxYCYRI6NS+BsMBXKIzV6cTLOkO9AtywA==} @@ -16984,7 +16997,6 @@ packages: /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - dev: true /path-exists@5.0.0: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} @@ -17189,6 +17201,11 @@ packages: engines: {node: '>=4'} dev: true + /pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + dev: false + /postcss-media-query-parser@0.2.3: resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} dev: true @@ -17662,6 +17679,17 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} dev: true + /qrcode@1.5.3: + resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==} + engines: {node: '>=10.13.0'} + hasBin: true + dependencies: + dijkstrajs: 1.0.3 + encode-utf8: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + dev: false + /qs@6.10.3: resolution: {integrity: sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==} engines: {node: '>=0.6'} @@ -18380,7 +18408,6 @@ packages: /require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - dev: true /requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -18675,7 +18702,6 @@ packages: /set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - dev: true /setprototypeof@1.1.0: resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} @@ -20463,7 +20489,6 @@ packages: /which-module@2.0.0: resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==} - dev: true /which-pm@2.0.0: resolution: {integrity: sha512-Lhs9Pmyph0p5n5Z3mVnN0yWcbQYUAD7rbQUiMsQxOJ3T57k7RFe35SUwWMf7dsbDZks1uOmw4AecB/JMDj3v/w==} @@ -20616,7 +20641,6 @@ packages: /y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - dev: true /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} @@ -20644,7 +20668,6 @@ packages: dependencies: camelcase: 5.3.1 decamelize: 1.2.0 - dev: true /yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} @@ -20670,7 +20693,6 @@ packages: which-module: 2.0.0 y18n: 4.0.3 yargs-parser: 18.1.3 - dev: true /yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}