0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

refactor(experience): reorg mfa factor switching logic (#4628)

This commit is contained in:
Xiao Yijun 2023-10-10 14:44:40 +08:00 committed by GitHub
parent f1f75aa37e
commit 985a3637fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 73 additions and 48 deletions

View file

@ -0,0 +1,29 @@
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 TextLink from '../TextLink';
type Props = {
flow: UserMfaFlow;
factors: MfaFactor[];
className?: string;
};
const SwitchMfaFactorsLink = ({ flow, factors, className }: Props) => (
<TextLink
to={`/${flow}`}
text={
flow === UserMfaFlow.MfaBinding
? 'mfa.link_another_mfa_factor'
: 'mfa.try_another_verification_method'
}
className={className}
icon={<SwitchIcon />}
state={{ availableFactors: factors } satisfies MfaFactorsState}
/>
);
export default SwitchMfaFactorsLink;

View file

@ -3,9 +3,9 @@ import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import MfaFactorButton from '@/components/Button/MfaFactorButton'; import MfaFactorButton from '@/components/Button/MfaFactorButton';
import useStartTotpBinding from '@/hooks/use-start-binding-totp'; import useStartTotpBinding from '@/hooks/use-start-totp-binding';
import { UserMfaFlow } from '@/types'; import { UserMfaFlow } from '@/types';
import { type TotpVerificationState } from '@/types/guard'; import { type MfaFactorsState } from '@/types/guard';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
@ -22,17 +22,17 @@ const MfaFactorList = ({ flow, factors }: Props) => {
async (factor: MfaFactor) => { async (factor: MfaFactor) => {
if (factor === MfaFactor.TOTP) { if (factor === MfaFactor.TOTP) {
if (flow === UserMfaFlow.MfaBinding) { if (flow === UserMfaFlow.MfaBinding) {
await startTotpBinding(factors.length > 1); await startTotpBinding(factors);
} }
if (flow === UserMfaFlow.MfaVerification) { if (flow === UserMfaFlow.MfaVerification) {
const state: TotpVerificationState = { allowOtherFactors: true }; const state: MfaFactorsState = { availableFactors: factors };
navigate(`/${UserMfaFlow.MfaVerification}/${factor}`, { state }); navigate(`/${UserMfaFlow.MfaVerification}/${factor}`, { state });
} }
} }
// Todo @xiaoyijun implement other factors // Todo @xiaoyijun implement other factors
}, },
[factors.length, flow, navigate, startTotpBinding] [factors, flow, navigate, startTotpBinding]
); );
return ( return (

View file

@ -8,11 +8,10 @@ import {
type MfaFactorsState, type MfaFactorsState,
missingMfaFactorsErrorDataGuard, missingMfaFactorsErrorDataGuard,
requireMfaFactorsErrorDataGuard, requireMfaFactorsErrorDataGuard,
type TotpVerificationState,
} from '@/types/guard'; } from '@/types/guard';
import type { ErrorHandlers } from './use-error-handler'; import type { ErrorHandlers } from './use-error-handler';
import useStartTotpBinding from './use-start-binding-totp'; import useStartTotpBinding from './use-start-totp-binding';
import useToast from './use-toast'; import useToast from './use-toast';
export type Options = { export type Options = {
@ -22,7 +21,7 @@ export type Options = {
const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => { const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { setToast } = useToast(); const { setToast } = useToast();
const startBindingTotp = useStartTotpBinding({ replace }); const startTotpBinding = useStartTotpBinding({ replace });
const mfaVerificationErrorHandler = useMemo<ErrorHandlers>( const mfaVerificationErrorHandler = useMemo<ErrorHandlers>(
() => ({ () => ({
@ -36,7 +35,7 @@ const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => {
} }
if (missingFactors.length > 1) { if (missingFactors.length > 1) {
const state: MfaFactorsState = { factors: missingFactors }; const state: MfaFactorsState = { availableFactors: missingFactors };
navigate({ pathname: `/${UserMfaFlow.MfaBinding}` }, { replace, state }); navigate({ pathname: `/${UserMfaFlow.MfaBinding}` }, { replace, state });
return; return;
} }
@ -44,7 +43,7 @@ const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => {
const factor = missingFactors[0]; const factor = missingFactors[0];
if (factor === MfaFactor.TOTP) { if (factor === MfaFactor.TOTP) {
void startBindingTotp(); void startTotpBinding(missingFactors);
} }
// Todo: @xiaoyijun handle other factors // Todo: @xiaoyijun handle other factors
}, },
@ -57,7 +56,7 @@ const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => {
} }
if (availableFactors.length > 1) { if (availableFactors.length > 1) {
const state: MfaFactorsState = { factors: availableFactors }; const state: MfaFactorsState = { availableFactors };
navigate({ pathname: `/${UserMfaFlow.MfaVerification}` }, { replace, state }); navigate({ pathname: `/${UserMfaFlow.MfaVerification}` }, { replace, state });
return; return;
} }
@ -69,13 +68,13 @@ const useMfaVerificationErrorHandler = ({ replace }: Options = {}) => {
} }
if (factor === MfaFactor.TOTP) { if (factor === MfaFactor.TOTP) {
const state: TotpVerificationState = { allowOtherFactors: false }; const state: MfaFactorsState = { availableFactors };
navigate({ pathname: `/${UserMfaFlow.MfaVerification}/${factor}` }, { replace, state }); navigate({ pathname: `/${UserMfaFlow.MfaVerification}/${factor}` }, { replace, state });
} }
// Todo: @xiaoyijun handle other factors // Todo: @xiaoyijun handle other factors
}, },
}), }),
[navigate, replace, setToast, startBindingTotp] [navigate, replace, setToast, startTotpBinding]
); );
return mfaVerificationErrorHandler; return mfaVerificationErrorHandler;

View file

@ -20,7 +20,7 @@ const useStartTotpBinding = ({ replace }: Options = {}) => {
const handleError = useErrorHandler(); const handleError = useErrorHandler();
return useCallback( return useCallback(
async (allowOtherFactors = false) => { async (availableFactors: MfaFactor[]) => {
const [error, result] = await asyncCreateTotpSecret(); const [error, result] = await asyncCreateTotpSecret();
if (error) { if (error) {
@ -35,7 +35,7 @@ const useStartTotpBinding = ({ replace }: Options = {}) => {
secret, secret,
// Todo @wangsijie generate QR code on the server side // Todo @wangsijie generate QR code on the server side
secretQrCode: await qrcode.toDataURL(`otpauth://totp/?secret=${secret}`), secretQrCode: await qrcode.toDataURL(`otpauth://totp/?secret=${secret}`),
allowOtherFactors, availableFactors,
}; };
navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state }); navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state });
} }

View file

@ -6,3 +6,7 @@
margin-bottom: _.unit(6); margin-bottom: _.unit(6);
align-items: stretch; align-items: stretch;
} }
.switchLink {
align-self: start;
}

View file

@ -2,9 +2,8 @@ import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import SwitchIcon from '@/assets/icons/switch-icon.svg';
import Divider from '@/components/Divider'; import Divider from '@/components/Divider';
import TextLink from '@/components/TextLink'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import ErrorPage from '@/pages/ErrorPage'; import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types'; import { UserMfaFlow } from '@/types';
import { totpBindingStateGuard } from '@/types/guard'; import { totpBindingStateGuard } from '@/types/guard';
@ -21,19 +20,21 @@ const TotpBinding = () => {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
const { availableFactors } = totpBindingState;
return ( return (
<SecondaryPageLayout title="mfa.add_authenticator_app"> <SecondaryPageLayout title="mfa.add_authenticator_app">
<div className={styles.container}> <div className={styles.container}>
<SecretSection {...totpBindingState} /> <SecretSection {...totpBindingState} />
<Divider /> <Divider />
<VerificationSection /> <VerificationSection />
{totpBindingState.allowOtherFactors && ( {availableFactors.length > 1 && (
<> <>
<Divider /> <Divider />
<TextLink <SwitchMfaFactorsLink
to={`/${UserMfaFlow.MfaBinding}`} flow={UserMfaFlow.MfaBinding}
text="mfa.link_another_mfa_factor" factors={availableFactors}
icon={<SwitchIcon />} className={styles.switchLink}
/> />
</> </>
)} )}

View file

@ -11,15 +11,15 @@ import ErrorPage from '../ErrorPage';
const MfaBinding = () => { const MfaBinding = () => {
const { state } = useLocation(); const { state } = useLocation();
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard); const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
const { factors } = mfaFactorsState ?? {}; const { availableFactors } = mfaFactorsState ?? {};
if (!factors || factors.length === 0) { if (!availableFactors || availableFactors.length === 0) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
return ( return (
<SecondaryPageLayout title="mfa.add_mfa_factors" description="mfa.add_mfa_description"> <SecondaryPageLayout title="mfa.add_mfa_factors" description="mfa.add_mfa_description">
<MfaFactorList flow={UserMfaFlow.MfaBinding} factors={factors} /> <MfaFactorList flow={UserMfaFlow.MfaBinding} factors={availableFactors} />
</SecondaryPageLayout> </SecondaryPageLayout>
); );
}; };

View file

@ -3,23 +3,24 @@ import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import SectionLayout from '@/Layout/SectionLayout'; import SectionLayout from '@/Layout/SectionLayout';
import SwitchIcon from '@/assets/icons/switch-icon.svg'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import TextLink from '@/components/TextLink';
import TotpCodeVerification from '@/containers/TotpCodeVerification'; import TotpCodeVerification from '@/containers/TotpCodeVerification';
import ErrorPage from '@/pages/ErrorPage'; import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types'; import { UserMfaFlow } from '@/types';
import { totpVerificationStateGuard } from '@/types/guard'; import { mfaFactorsStateGuard } from '@/types/guard';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
const TotpVerification = () => { const TotpVerification = () => {
const { state } = useLocation(); const { state } = useLocation();
const [, totpVerificationState] = validate(state, totpVerificationStateGuard); const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
if (!totpVerificationState) { if (!mfaFactorsState) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
const { availableFactors } = mfaFactorsState;
return ( return (
<SecondaryPageLayout title="mfa.verify_mfa_factors"> <SecondaryPageLayout title="mfa.verify_mfa_factors">
<SectionLayout <SectionLayout
@ -28,11 +29,10 @@ const TotpVerification = () => {
> >
<TotpCodeVerification flow={UserMfaFlow.MfaVerification} /> <TotpCodeVerification flow={UserMfaFlow.MfaVerification} />
</SectionLayout> </SectionLayout>
{totpVerificationState.allowOtherFactors && ( {availableFactors.length > 1 && (
<TextLink <SwitchMfaFactorsLink
to={`/${UserMfaFlow.MfaVerification}`} flow={UserMfaFlow.MfaVerification}
text="mfa.try_another_verification_method" factors={availableFactors}
icon={<SwitchIcon />}
className={styles.switchFactorLink} className={styles.switchFactorLink}
/> />
)} )}

View file

@ -11,15 +11,15 @@ import ErrorPage from '../ErrorPage';
const MfaVerification = () => { const MfaVerification = () => {
const { state } = useLocation(); const { state } = useLocation();
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard); const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
const { factors } = mfaFactorsState ?? {}; const { availableFactors } = mfaFactorsState ?? {};
if (!factors || factors.length === 0) { if (!availableFactors || availableFactors.length === 0) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
return ( return (
<SecondaryPageLayout title="mfa.verify_mfa_factors" description="mfa.verify_mfa_description"> <SecondaryPageLayout title="mfa.verify_mfa_factors" description="mfa.verify_mfa_description">
<MfaFactorList flow={UserMfaFlow.MfaVerification} factors={factors} /> <MfaFactorList flow={UserMfaFlow.MfaVerification} factors={availableFactors} />
</SecondaryPageLayout> </SecondaryPageLayout>
); );
}; };

View file

@ -80,25 +80,17 @@ export const requireMfaFactorsErrorDataGuard = s.object({
}); });
export const mfaFactorsStateGuard = s.object({ export const mfaFactorsStateGuard = s.object({
factors: mfaFactorsGuard, availableFactors: mfaFactorsGuard,
}); });
export type MfaFactorsState = s.Infer<typeof mfaFactorsStateGuard>; export type MfaFactorsState = s.Infer<typeof mfaFactorsStateGuard>;
const mfaFlowStateGuard = s.object({
allowOtherFactors: s.boolean(),
});
export const totpBindingStateGuard = s.assign( export const totpBindingStateGuard = s.assign(
s.object({ s.object({
secret: s.string(), secret: s.string(),
secretQrCode: s.string(), secretQrCode: s.string(),
}), }),
mfaFlowStateGuard mfaFactorsStateGuard
); );
export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>; export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>;
export const totpVerificationStateGuard = mfaFlowStateGuard;
export type TotpVerificationState = s.Infer<typeof totpVerificationStateGuard>;