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 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 { type TotpVerificationState } from '@/types/guard';
import { type MfaFactorsState } from '@/types/guard';
import * as styles from './index.module.scss';
@ -22,17 +22,17 @@ const MfaFactorList = ({ flow, factors }: Props) => {
async (factor: MfaFactor) => {
if (factor === MfaFactor.TOTP) {
if (flow === UserMfaFlow.MfaBinding) {
await startTotpBinding(factors.length > 1);
await startTotpBinding(factors);
}
if (flow === UserMfaFlow.MfaVerification) {
const state: TotpVerificationState = { allowOtherFactors: true };
const state: MfaFactorsState = { availableFactors: factors };
navigate(`/${UserMfaFlow.MfaVerification}/${factor}`, { state });
}
}
// Todo @xiaoyijun implement other factors
},
[factors.length, flow, navigate, startTotpBinding]
[factors, flow, navigate, startTotpBinding]
);
return (

View file

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

View file

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

View file

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

View file

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

View file

@ -11,15 +11,15 @@ import ErrorPage from '../ErrorPage';
const MfaBinding = () => {
const { state } = useLocation();
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 (
<SecondaryPageLayout title="mfa.add_mfa_factors" description="mfa.add_mfa_description">
<MfaFactorList flow={UserMfaFlow.MfaBinding} factors={factors} />
<MfaFactorList flow={UserMfaFlow.MfaBinding} factors={availableFactors} />
</SecondaryPageLayout>
);
};

View file

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

View file

@ -11,15 +11,15 @@ import ErrorPage from '../ErrorPage';
const MfaVerification = () => {
const { state } = useLocation();
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 (
<SecondaryPageLayout title="mfa.verify_mfa_factors" description="mfa.verify_mfa_description">
<MfaFactorList flow={UserMfaFlow.MfaVerification} factors={factors} />
<MfaFactorList flow={UserMfaFlow.MfaVerification} factors={availableFactors} />
</SecondaryPageLayout>
);
};

View file

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