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 { 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 (
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
|
@ -6,3 +6,7 @@
|
||||||
margin-bottom: _.unit(6);
|
margin-bottom: _.unit(6);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switchLink {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>;
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue