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:
parent
f1f75aa37e
commit
985a3637fb
10 changed files with 73 additions and 48 deletions
|
@ -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;
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 });
|
||||
}
|
|
@ -6,3 +6,7 @@
|
|||
margin-bottom: _.unit(6);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.switchLink {
|
||||
align-self: start;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Add table
Reference in a new issue