diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index 3d94c304f..092a6ead5 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -80,7 +80,6 @@ const App = () => { {isDevelopmentFeaturesEnabled && ( <> {/* Mfa binding */} - {/* Todo @xiaoyijun reorg these routes when factors are all implemented */} } /> } /> @@ -89,7 +88,6 @@ const App = () => { {/* Mfa verification */} - {/* Todo @xiaoyijun reorg these routes when factors are all implemented */} } /> } /> diff --git a/packages/experience/src/Layout/SecondaryPageLayout/index.tsx b/packages/experience/src/Layout/SecondaryPageLayout/index.tsx index f5a5899ae..3cd23db0b 100644 --- a/packages/experience/src/Layout/SecondaryPageLayout/index.tsx +++ b/packages/experience/src/Layout/SecondaryPageLayout/index.tsx @@ -14,6 +14,7 @@ type Props = { title: TFuncKey; description?: TFuncKey | ReactElement | ''; titleProps?: Record; + onSkip?: () => void; descriptionProps?: Record; notification?: TFuncKey; children: React.ReactNode; @@ -23,6 +24,7 @@ const SecondaryPageLayout = ({ title, description, titleProps, + onSkip, descriptionProps, notification, children, @@ -32,7 +34,7 @@ const SecondaryPageLayout = ({ return (
- + {isMobile && notification && ( )} @@ -51,7 +53,6 @@ const SecondaryPageLayout = ({
)} - {children} {!isMobile && notification && ( diff --git a/packages/experience/src/apis/interaction.ts b/packages/experience/src/apis/interaction.ts index e1567ab75..daf8a0c9c 100644 --- a/packages/experience/src/apis/interaction.ts +++ b/packages/experience/src/apis/interaction.ts @@ -253,3 +253,6 @@ export const verifyMfa = async (payload: VerifyMfaPayload) => { return api.post(`${interactionPrefix}/submit`).json(); }; + +export const submitInteraction = async () => + api.post(`${interactionPrefix}/submit`).json(); diff --git a/packages/experience/src/components/NavBar/index.module.scss b/packages/experience/src/components/NavBar/index.module.scss index 743da3b55..76cf404e9 100644 --- a/packages/experience/src/components/NavBar/index.module.scss +++ b/packages/experience/src/components/NavBar/index.module.scss @@ -28,6 +28,17 @@ cursor: pointer; } +.skipButton { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + font: var(--font-label-1); + cursor: pointer; + color: var(--color-type-link); + padding-right: _.unit(1); +} + :global(body.mobile) { .navButton > span { display: none; diff --git a/packages/experience/src/components/NavBar/index.tsx b/packages/experience/src/components/NavBar/index.tsx index 08e71431b..afc9a19f0 100644 --- a/packages/experience/src/components/NavBar/index.tsx +++ b/packages/experience/src/components/NavBar/index.tsx @@ -12,9 +12,10 @@ type Props = { title?: string; type?: 'back' | 'close'; onClose?: () => void; + onSkip?: () => void; }; -const NavBar = ({ title, type = 'back', onClose }: Props) => { +const NavBar = ({ title, type = 'back', onClose, onSkip }: Props) => { const navigate = useNavigate(); const { t } = useTranslation(); @@ -49,6 +50,17 @@ const NavBar = ({ title, type = 'back', onClose }: Props) => { {!isClosable && {t('action.nav_back')}} {title &&
{title}
} + {onSkip && ( +
+ {t('action.nav_skip')} +
+ )} ); }; diff --git a/packages/experience/src/components/SwitchMfaFactorsLink/index.tsx b/packages/experience/src/components/SwitchMfaFactorsLink/index.tsx index ed4fb229b..ecaf07d4c 100644 --- a/packages/experience/src/components/SwitchMfaFactorsLink/index.tsx +++ b/packages/experience/src/components/SwitchMfaFactorsLink/index.tsx @@ -1,29 +1,35 @@ -import { type MfaFactor } from '@logto/schemas'; - import SwitchIcon from '@/assets/icons/switch-icon.svg'; import { UserMfaFlow } from '@/types'; -import { type MfaFactorsState } from '@/types/guard'; +import { type MfaFlowState } from '@/types/guard'; import TextLink from '../TextLink'; type Props = { flow: UserMfaFlow; - factors: MfaFactor[]; + flowState: MfaFlowState; className?: string; }; -const SwitchMfaFactorsLink = ({ flow, factors, className }: Props) => ( - } - state={{ availableFactors: factors } satisfies MfaFactorsState} - /> -); +const SwitchMfaFactorsLink = ({ flow, flowState, className }: Props) => { + const { availableFactors } = flowState; + + if (availableFactors.length < 2) { + return null; + } + + return ( + } + state={flowState} + /> + ); +}; export default SwitchMfaFactorsLink; diff --git a/packages/experience/src/containers/MfaFactorList/index.tsx b/packages/experience/src/containers/MfaFactorList/index.tsx index 09eebf139..67f78d6ca 100644 --- a/packages/experience/src/containers/MfaFactorList/index.tsx +++ b/packages/experience/src/containers/MfaFactorList/index.tsx @@ -5,36 +5,35 @@ import { useNavigate } from 'react-router-dom'; import MfaFactorButton from '@/components/Button/MfaFactorButton'; import useStartTotpBinding from '@/hooks/use-start-totp-binding'; import { UserMfaFlow } from '@/types'; -import { type MfaFactorsState } from '@/types/guard'; +import { type MfaFlowState } from '@/types/guard'; import * as styles from './index.module.scss'; type Props = { flow: UserMfaFlow; - factors: MfaFactor[]; + flowState: MfaFlowState; }; -const MfaFactorList = ({ flow, factors }: Props) => { +const MfaFactorList = ({ flow, flowState }: Props) => { const startTotpBinding = useStartTotpBinding(); const navigate = useNavigate(); + const { availableFactors } = flowState; const handleSelectFactor = useCallback( (factor: MfaFactor) => { if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) { - void startTotpBinding(factors); + void startTotpBinding(flowState); return; } - navigate(`/${flow}/${factor}`, { - state: { availableFactors: factors } satisfies MfaFactorsState, - }); + navigate(`/${flow}/${factor}`, { state: flowState }); }, - [factors, flow, navigate, startTotpBinding] + [flow, flowState, navigate, startTotpBinding] ); return (
- {factors.map((factor) => ( + {availableFactors.map((factor) => ( { const startTotpBinding = useStartTotpBinding({ replace }); const handleMfaRedirect = useCallback( - (flow: UserMfaFlow, availableFactors: MfaFactor[]) => { - const mfaFactorsState: MfaFactorsState = { - availableFactors, - }; + (flow: UserMfaFlow, state: MfaFlowState) => { + const { availableFactors } = state; if (availableFactors.length > 1) { - navigate({ pathname: `/${flow}` }, { replace, state: mfaFactorsState }); + navigate({ pathname: `/${flow}` }, { replace, state }); return; } @@ -42,11 +40,11 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { } if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) { - void startTotpBinding(availableFactors); + void startTotpBinding(state); return; } - navigate({ pathname: `/${flow}/${factor}` }, { replace, state: mfaFactorsState }); + navigate({ pathname: `/${flow}/${factor}` }, { replace, state }); }, [navigate, replace, startTotpBinding] ); @@ -56,13 +54,14 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { return (error: RequestErrorBody) => { const [_, data] = validate(error.data, mfaErrorDataGuard); const availableFactors = data?.availableFactors ?? []; + const skippable = data?.skippable; if (availableFactors.length === 0) { setToast(error.message); return; } - handleMfaRedirect(flow, availableFactors); + handleMfaRedirect(flow, { availableFactors, skippable }); }; }, [handleMfaRedirect, setToast] diff --git a/packages/experience/src/hooks/use-mfa-factors-state.ts b/packages/experience/src/hooks/use-mfa-factors-state.ts index 5b264c814..925193c21 100644 --- a/packages/experience/src/hooks/use-mfa-factors-state.ts +++ b/packages/experience/src/hooks/use-mfa-factors-state.ts @@ -1,13 +1,13 @@ import { useLocation } from 'react-router-dom'; import { validate } from 'superstruct'; -import { mfaFactorsStateGuard } from '@/types/guard'; +import { mfaFlowStateGuard } from '@/types/guard'; -const useMfaFactorsState = () => { +const useMfaFlowState = () => { const { state } = useLocation(); - const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard); + const [, mfaFlowState] = validate(state, mfaFlowStateGuard); - return mfaFactorsState; + return mfaFlowState; }; -export default useMfaFactorsState; +export default useMfaFlowState; diff --git a/packages/experience/src/hooks/use-skip-mfa.ts b/packages/experience/src/hooks/use-skip-mfa.ts new file mode 100644 index 000000000..065b83df7 --- /dev/null +++ b/packages/experience/src/hooks/use-skip-mfa.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; + +import { submitInteraction } from '@/apis/interaction'; + +import useApi from './use-api'; +import useErrorHandler from './use-error-handler'; +import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; + +const useSkipMfa = () => { + const asyncSubmitInteraction = useApi(submitInteraction); + + const handleError = useErrorHandler(); + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); + + return useCallback(async () => { + const [error, result] = await asyncSubmitInteraction(); + if (error) { + await handleError(error, preSignInErrorHandler); + return; + } + + if (result) { + window.location.replace(result.redirectTo); + } + }, [asyncSubmitInteraction, handleError, preSignInErrorHandler]); +}; + +export default useSkipMfa; diff --git a/packages/experience/src/hooks/use-start-totp-binding.ts b/packages/experience/src/hooks/use-start-totp-binding.ts index 1c12e9e39..12347533b 100644 --- a/packages/experience/src/hooks/use-start-totp-binding.ts +++ b/packages/experience/src/hooks/use-start-totp-binding.ts @@ -6,7 +6,7 @@ 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'; +import { type MfaFlowState, type TotpBindingState } from '@/types/guard'; type Options = { replace?: boolean; @@ -19,7 +19,7 @@ const useStartTotpBinding = ({ replace }: Options = {}) => { const handleError = useErrorHandler(); return useCallback( - async (availableFactors: MfaFactor[]) => { + async (flowState: MfaFlowState) => { const [error, result] = await asyncCreateTotpSecret(); if (error) { @@ -33,7 +33,7 @@ const useStartTotpBinding = ({ replace }: Options = {}) => { const state: TotpBindingState = { secret, secretQrCode, - availableFactors, + ...flowState, }; navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state }); } diff --git a/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx b/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx index 6326f03e9..213bfcdb3 100644 --- a/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx +++ b/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx @@ -1,9 +1,11 @@ +import { conditional } from '@silverhand/essentials'; import { useLocation } from 'react-router-dom'; import { validate } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import Divider from '@/components/Divider'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink'; +import useSkipMfa from '@/hooks/use-skip-mfa'; import ErrorPage from '@/pages/ErrorPage'; import { UserMfaFlow } from '@/types'; import { totpBindingStateGuard } from '@/types/guard'; @@ -15,15 +17,19 @@ import * as styles from './index.module.scss'; const TotpBinding = () => { const { state } = useLocation(); const [, totpBindingState] = validate(state, totpBindingStateGuard); + const skipMfa = useSkipMfa(); if (!totpBindingState) { return ; } - const { availableFactors } = totpBindingState; + const { availableFactors, skippable } = totpBindingState; return ( - +
@@ -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 ( - +