Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/experience/src/Layout/SecondaryPageLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Props = {
readonly notification?: TFuncKey;
readonly onSkip?: () => void;
readonly isNavBarHidden?: boolean;
readonly isBackHidden?: boolean;
readonly children: React.ReactNode;
};

Expand All @@ -30,6 +31,7 @@ const SecondaryPageLayout = ({
notification,
onSkip,
isNavBarHidden,
isBackHidden,
children,
}: Props) => {
const { isMobile } = usePlatform();
Expand All @@ -40,6 +42,7 @@ const SecondaryPageLayout = ({
<PageMeta titleKey={title} />
<NavBar
isHidden={isNavBarHidden}
isBackHidden={isBackHidden}
onSkip={onSkip}
onBack={() => {
navigate(-1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const UserInteractionContextProvider = ({ children }: Props) => {
remove(StorageKeys.IdentifierInputValue);
remove(StorageKeys.ForgotPasswordIdentifierInputValue);
remove(StorageKeys.verificationIds);
remove(StorageKeys.OneTimeTokenSignIn);
Comment thread
wangsijie marked this conversation as resolved.
Outdated
}, [remove]);

const setVerificationId = useCallback((type: VerificationType, id: string) => {
Expand Down
7 changes: 6 additions & 1 deletion packages/experience/src/hooks/use-mfa-error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { validate } from 'superstruct';

import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import { UserMfaFlow } from '@/types';
import { type MfaFlowState, mfaErrorDataGuard } from '@/types/guard';
import { isNativeWebview } from '@/utils/native-sdk';
Expand All @@ -22,6 +23,7 @@ export type Options = {

const useMfaErrorHandler = ({ replace }: Options = {}) => {
const navigate = useNavigateWithPreservedSearchParams();
const { get } = useSessionStorage();
const { t } = useTranslation();
const { setToast } = useToast();
const startTotpBinding = useStartTotpBinding();
Expand Down Expand Up @@ -136,16 +138,19 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
? factors.filter((factor) => factor !== MfaFactor.WebAuthn)
: factors;

const hideBack = Boolean(get(StorageKeys.OneTimeTokenSignIn));
Comment thread
wangsijie marked this conversation as resolved.
Outdated

await handleMfaRedirect(flow, {
availableFactors,
skippable,
maskedIdentifiers,
suggestion,
isWebAuthnUsedAsSignInPasskey,
hideBack,
Comment thread
wangsijie marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
});
};
},
[handleMfaRedirect, navigate, replace, setToast]
[get, handleMfaRedirect, navigate, replace, setToast]
);

const mfaVerificationErrorHandler = useMemo<ErrorHandlers>(
Expand Down
2 changes: 2 additions & 0 deletions packages/experience/src/hooks/use-session-storages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum StorageKeys {
IdentifierInputValue = 'identifier-input-value',
ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value',
verificationIds = 'verification-ids',
OneTimeTokenSignIn = 'one-time-token-sign-in',
}

const valueGuard = Object.freeze({
Expand All @@ -26,6 +27,7 @@ const valueGuard = Object.freeze({
[StorageKeys.IdentifierInputValue]: identifierInputValueGuard,
[StorageKeys.ForgotPasswordIdentifierInputValue]: identifierInputValueGuard,
[StorageKeys.verificationIds]: verificationIdsMapGuard,
[StorageKeys.OneTimeTokenSignIn]: s.literal(true),
Comment thread
wangsijie marked this conversation as resolved.
Outdated
Comment thread
wangsijie marked this conversation as resolved.
Outdated
Comment thread
wangsijie marked this conversation as resolved.
Outdated
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details
} satisfies { [key in StorageKeys]: s.Struct<any> });

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';

/**
* Hide the MFA setup back button when the user signed in via a one-time token.
* Going back would re-run token verification and fail with `one_time_token.token_consumed`.
*/
const useShouldHideMfaBackNavigation = () => {
const flowState = useMfaFlowState();
const { get } = useSessionStorage();

return Boolean(flowState?.hideBack || get(StorageKeys.OneTimeTokenSignIn));
Comment thread
wangsijie marked this conversation as resolved.
Outdated
};
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

export default useShouldHideMfaBackNavigation;
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Divider from '@/components/Divider';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import useSkipMfa from '@/hooks/use-skip-mfa';
import useSkipOptionalMfa from '@/hooks/use-skip-optional-mfa';
import ErrorPage from '@/pages/ErrorPage';
Expand All @@ -26,6 +27,7 @@ const TotpBinding = () => {

const skipMfa = useSkipMfa();
const skipOptionalMfa = useSkipOptionalMfa();
const shouldHideBack = useShouldHideMfaBackNavigation();

if (!totpBindingState || !verificationId) {
return <ErrorPage title="error.invalid_session" />;
Expand All @@ -35,6 +37,7 @@ const TotpBinding = () => {

return (
<SecondaryPageLayout
isBackHidden={shouldHideBack}
title="mfa.add_authenticator_app"
onSkip={conditional(skippable && (suggestion ? skipOptionalMfa : skipMfa))}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { sendVerificationCode } from '@/apis/experience';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useErrorHandler from '@/hooks/use-error-handler';
import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import useSkipMfa from '@/hooks/use-skip-mfa';
import useSkipOptionalMfa from '@/hooks/use-skip-optional-mfa';
import IdentifierProfileForm from '@/pages/Continue/IdentifierProfileForm';
Expand Down Expand Up @@ -42,6 +43,7 @@ const VerificationCodeMfaBinding = ({

const skipMfa = useSkipMfa();
const skipOptionalMfa = useSkipOptionalMfa();
const shouldHideBack = useShouldHideMfaBackNavigation();
const handleError = useErrorHandler();

const clearErrorMessage = useCallback(() => {
Expand Down Expand Up @@ -91,6 +93,7 @@ const VerificationCodeMfaBinding = ({

return (
<SecondaryPageLayout
isBackHidden={shouldHideBack}
title={titleKey}
description={descriptionKey}
onSkip={conditional(skippable && (suggestion ? skipOptionalMfa : skipMfa))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import useSkipMfa from '@/hooks/use-skip-mfa';
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
import ErrorPage from '@/pages/ErrorPage';
Expand All @@ -25,6 +26,7 @@ const WebAuthnBinding = () => {

const handleWebAuthn = useWebAuthnOperation();
const skipMfa = useSkipMfa();
const shouldHideBack = useShouldHideMfaBackNavigation();
const [isCreatingPasskey, setIsCreatingPasskey] = useState(false);

if (!webAuthnState || !verificationId) {
Expand All @@ -39,6 +41,7 @@ const WebAuthnBinding = () => {

return (
<SecondaryPageLayout
isBackHidden={shouldHideBack}
title="mfa.create_a_passkey"
description="mfa.create_passkey_description"
onSkip={conditional(skippable && skipMfa)}
Expand Down
3 changes: 3 additions & 0 deletions packages/experience/src/pages/MfaBinding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { conditional } from '@silverhand/essentials';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import MfaFactorList from '@/containers/MfaFactorList';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import useSkipMfa from '@/hooks/use-skip-mfa';
import useSkipOptionalMfa from '@/hooks/use-skip-optional-mfa';
import { UserMfaFlow } from '@/types';
Expand All @@ -11,6 +12,7 @@ import ErrorPage from '../ErrorPage';

const MfaBinding = () => {
const flowState = useMfaFlowState();
const shouldHideBack = useShouldHideMfaBackNavigation();
Comment thread
wangsijie marked this conversation as resolved.
Outdated
const skipMfa = useSkipMfa();
const skipOptionalMfa = useSkipOptionalMfa();

Expand All @@ -20,6 +22,7 @@ const MfaBinding = () => {

return (
<SecondaryPageLayout
isBackHidden={shouldHideBack}
title={flowState.suggestion ? 'mfa.add_another_mfa_factor' : 'mfa.add_mfa_factors'}
description={
flowState.suggestion ? 'mfa.add_another_mfa_description' : 'mfa.add_mfa_description'
Expand Down
3 changes: 3 additions & 0 deletions packages/experience/src/pages/MfaOnboarding/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import useEnableMfa from '@/hooks/use-enable-mfa';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import useSkipMfa from '@/hooks/use-skip-mfa';
import Button from '@/shared/components/Button';

const MfaOnboarding = () => {
const skipMfa = useSkipMfa();
const enableMfa = useEnableMfa();
const shouldHideBack = useShouldHideMfaBackNavigation();

return (
<SecondaryPageLayout
isBackHidden={shouldHideBack}
title="mfa.onboarding"
description="mfa.onboarding_description"
onSkip={skipMfa}
Expand Down
5 changes: 5 additions & 0 deletions packages/experience/src/pages/OneTimeToken/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
signInWithOneTimeToken,
} from '@/apis/experience';
import useApi from '@/hooks/use-api';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
Comment thread
wangsijie marked this conversation as resolved.
Outdated
import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import useNavigateWithPreservedSearchParams from '@/hooks/use-navigate-with-preserved-search-params';
Expand All @@ -30,6 +31,7 @@ const OneTimeToken = () => {
const hasTermsAgreed = useRef(false);
const isSubmitted = useRef(false);

const { set: setSessionStorage } = useSessionStorage();
const asyncIdentifyUserAndSubmit = useApi(identifyAndSubmitInteraction);
const asyncSignInWithOneTimeToken = useApi(signInWithOneTimeToken);
const asyncRegisterWithVerifiedIdentifier = useApi(registerWithVerifiedIdentifier);
Expand Down Expand Up @@ -184,6 +186,8 @@ const OneTimeToken = () => {
return;
}

setSessionStorage(StorageKeys.OneTimeTokenSignIn, true);
Comment thread
wangsijie marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
wangsijie marked this conversation as resolved.
Outdated

// Set email identifier to the <HiddenIdentifierInput />, so that when being asked for fulfilling
// the password later, the browser password manager can pick up both the email and the password.
setIdentifierInputValue({ type: SignInIdentifier.Email, value: email });
Expand All @@ -198,6 +202,7 @@ const OneTimeToken = () => {
handleError,
navigate,
setIdentifierInputValue,
setSessionStorage,
submit,
termsValidation,
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
display: none;
}

.navButton.hidden {
visibility: hidden;
}

.navBar.hidden {
visibility: hidden;
}
Expand All @@ -54,6 +58,10 @@
&:hover {
text-decoration: underline;
}

&.hidden {
display: none;
}
}

.skipButton {
Expand Down
13 changes: 11 additions & 2 deletions packages/experience/src/shared/components/NavBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,21 @@ type Props = {
readonly title?: string;
readonly type?: 'back' | 'close';
readonly isHidden?: boolean;
readonly isBackHidden?: boolean;
readonly onClose?: () => void;
readonly onBack?: () => void;
readonly onSkip?: () => void;
};

const NavBar = ({ title, type = 'back', isHidden, onClose, onBack, onSkip }: Props) => {
const NavBar = ({
title,
type = 'back',
isHidden,
isBackHidden,
onClose,
onBack,
onSkip,
}: Props) => {
const { t } = useTranslation();

const isClosable = type === 'close';
Expand Down Expand Up @@ -54,7 +63,7 @@ const NavBar = ({ title, type = 'back', isHidden, onClose, onBack, onSkip }: Pro
<div
role="button"
tabIndex={0}
className={styles.navButton}
className={classNames(styles.navButton, isBackHidden && styles.hidden)}
Comment thread
wangsijie marked this conversation as resolved.
Outdated
onKeyDown={onKeyDownHandler(clickHandler)}
onClick={clickHandler}
>
Expand Down
12 changes: 12 additions & 0 deletions packages/experience/src/types/guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,16 @@ describe('guard', () => {
);
}).not.toThrow();
});

it('mfaErrorDataGuard should accept hideBack for one-time token sign-in', () => {
expect(() => {
s.assert(
{
availableFactors: [MfaFactor.TOTP],
hideBack: true,
},
mfaErrorDataGuard
);
}).not.toThrow();
});
});
2 changes: 2 additions & 0 deletions packages/experience/src/types/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export const mfaErrorDataGuard = s.object({
suggestion: s.optional(s.boolean()),
// Whether the current WebAuthn factor is used as a sign-in passkey.
isWebAuthnUsedAsSignInPasskey: s.optional(s.boolean()),
// Hide back navigation (e.g. one-time token sign-in where back re-consumes the token).
hideBack: s.optional(s.boolean()),
});

export const mfaFlowStateGuard = mfaErrorDataGuard;
Expand Down
Loading