Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
@@ -1,9 +1,11 @@
import { type SsoConnectorMetadata, type VerificationType } from '@logto/schemas';
import { ExtraParamsKey, type SsoConnectorMetadata, type VerificationType } from '@logto/schemas';
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';

import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import { useSieMethods } from '@/hooks/use-sie';
import { type IdentifierInputValue } from '@/shared/components/InputFields/SmartInputField';
import { UserMfaFlow } from '@/types';

import UserInteractionContext, { type UserInteractionContextType } from './UserInteractionContext';

Expand All @@ -21,6 +23,8 @@ type Props = {
* The cached data provided by this provider primarily helps improve the sign-in experience for end users.
*/
const UserInteractionContextProvider = ({ children }: Props) => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const { ssoConnectors } = useSieMethods();
const { get, set, remove } = useSessionStorage();
const [ssoEmail, setSsoEmail] = useState<string | undefined>(get(StorageKeys.SsoEmail));
Expand Down Expand Up @@ -86,6 +90,23 @@ const UserInteractionContextProvider = ({ children }: Props) => {
set(StorageKeys.verificationIds, verificationIdsMap);
}, [verificationIdsMap, remove, set]);

useEffect(() => {
Comment thread
wangsijie marked this conversation as resolved.
Outdated
if (typeof pathname !== 'string') {
return;
}

const isOneTimeTokenRoute = pathname.includes('one-time-token');
const hasOneTimeTokenParam = searchParams.has(ExtraParamsKey.OneTimeToken);
const isMfaRoute =
pathname.includes(`/${UserMfaFlow.MfaBinding}`) ||
pathname.includes(`/${UserMfaFlow.MfaVerification}`) ||
pathname.includes('/mfa-onboarding');
Comment thread
wangsijie marked this conversation as resolved.
Outdated

if (!isOneTimeTokenRoute && !hasOneTimeTokenParam && !isMfaRoute) {
remove(StorageKeys.OneTimeTokenSignIn);
}
}, [pathname, remove, searchParams]);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const ssoConnectorsMap = useMemo(
() => new Map(ssoConnectors.map((connector) => [connector.id, connector])),
[ssoConnectors]
Expand All @@ -95,6 +116,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
11 changes: 11 additions & 0 deletions packages/experience/src/hooks/use-session-storages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ describe('useSessionStorage', () => {
expect(get(StorageKeys.SsoConnectors)).toBeUndefined();
});

it('should set and get one-time token sign-in flag', () => {
const { result } = renderHook(() => useSessionStorage());
const { get, set, remove } = result.current;

expect(get(StorageKeys.OneTimeTokenSignIn)).toBeUndefined();
set(StorageKeys.OneTimeTokenSignIn, true);
expect(get(StorageKeys.OneTimeTokenSignIn)).toBe(true);
remove(StorageKeys.OneTimeTokenSignIn);
expect(get(StorageKeys.OneTimeTokenSignIn)).toBeUndefined();
});

it('should return undefined if the value is invalid', () => {
const { result } = renderHook(() => useSessionStorage());
const { get, set } = result.current;
Expand Down
4 changes: 3 additions & 1 deletion 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 All @@ -38,7 +40,7 @@ const useSessionStorage = () => {
return;
}

sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, value);
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, String(value));
}, []);

const remove = useCallback((key: StorageKeys) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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 { get } = useSessionStorage();

return Boolean(get(StorageKeys.OneTimeTokenSignIn));
};
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { InputField } from '@/components/InputFields';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import ErrorPage from '@/pages/ErrorPage';
import Button from '@/shared/components/Button';
import { UserMfaFlow } from '@/types';
Expand All @@ -21,6 +22,7 @@ type FormState = {

const BackupCodeVerification = () => {
const flowState = useMfaFlowState();
const shouldHideBack = useShouldHideMfaBackNavigation();
const sendMfaPayload = useSendMfaPayload();
const {
register,
Expand All @@ -45,7 +47,7 @@ const BackupCodeVerification = () => {
}

return (
<SecondaryPageLayout title="mfa.verify_mfa_factors">
<SecondaryPageLayout isBackHidden={shouldHideBack} title="mfa.verify_mfa_factors">
<SectionLayout
title="mfa.enter_a_backup_code"
description="mfa.enter_backup_code_description"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import UserInteractionContext from '@/Providers/UserInteractionContextProvider/U
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import MfaCodeVerification from '@/containers/MfaCodeVerification';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
Expand All @@ -15,6 +16,7 @@ import styles from './index.module.scss';

const EmailVerificationCode = () => {
const flowState = useMfaFlowState();
const shouldHideBack = useShouldHideMfaBackNavigation();
const { verificationIdsMap } = useContext(UserInteractionContext);

if (!flowState) {
Expand All @@ -30,7 +32,7 @@ const EmailVerificationCode = () => {
const maskedEmail = flowState.maskedIdentifiers?.[MfaFactor.EmailVerificationCode];

return (
<SecondaryPageLayout title="mfa.verify_mfa_factors">
<SecondaryPageLayout isBackHidden={shouldHideBack} title="mfa.verify_mfa_factors">
<SectionLayout
title="mfa.enter_email_verification_code"
description="mfa.enter_email_verification_code_description"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import UserInteractionContext from '@/Providers/UserInteractionContextProvider/U
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import MfaCodeVerification from '@/containers/MfaCodeVerification';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
Expand All @@ -15,6 +16,7 @@ import styles from './index.module.scss';

const PhoneVerificationCode = () => {
const flowState = useMfaFlowState();
const shouldHideBack = useShouldHideMfaBackNavigation();
const { verificationIdsMap } = useContext(UserInteractionContext);

if (!flowState) {
Expand All @@ -30,7 +32,7 @@ const PhoneVerificationCode = () => {
const maskedPhone = flowState.maskedIdentifiers?.[MfaFactor.PhoneVerificationCode];

return (
<SecondaryPageLayout title="mfa.verify_mfa_factors">
<SecondaryPageLayout isBackHidden={shouldHideBack} title="mfa.verify_mfa_factors">
<SectionLayout
title="mfa.enter_phone_verification_code"
description="mfa.enter_phone_verification_code_description"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import SectionLayout from '@/Layout/SectionLayout';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import TotpCodeVerification from '@/containers/TotpCodeVerification';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';

import styles from './index.module.scss';

const TotpVerification = () => {
const flowState = useMfaFlowState();
const shouldHideBack = useShouldHideMfaBackNavigation();

if (!flowState) {
return <ErrorPage title="error.invalid_session" />;
}

return (
<SecondaryPageLayout title="mfa.verify_mfa_factors">
<SecondaryPageLayout isBackHidden={shouldHideBack} title="mfa.verify_mfa_factors">
<SectionLayout
title="mfa.enter_one_time_code"
description="mfa.enter_one_time_code_description"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import SectionLayout from '@/Layout/SectionLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useShouldHideMfaBackNavigation from '@/hooks/use-should-hide-mfa-back-navigation';
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
import ErrorPage from '@/pages/ErrorPage';
import Button from '@/shared/components/Button';
Expand All @@ -23,6 +24,7 @@ const WebAuthnVerification = () => {
const verificationId = verificationIdsMap[VerificationType.WebAuthn];

const handleWebAuthn = useWebAuthnOperation();
const shouldHideBack = useShouldHideMfaBackNavigation();
const [isVerifying, setIsVerifying] = useState(false);

if (!webAuthnState || !verificationId) {
Expand All @@ -36,7 +38,7 @@ const WebAuthnVerification = () => {
}

return (
<SecondaryPageLayout title="mfa.verify_mfa_factors">
<SecondaryPageLayout isBackHidden={shouldHideBack} title="mfa.verify_mfa_factors">
<SectionLayout
title="mfa.verify_via_passkey"
description="mfa.verify_via_passkey_description"
Expand Down
8 changes: 7 additions & 1 deletion packages/experience/src/pages/MfaVerification/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
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 { UserMfaFlow } from '@/types';

import ErrorPage from '../ErrorPage';

const MfaVerification = () => {
const flowState = useMfaFlowState();
const shouldHideBack = useShouldHideMfaBackNavigation();

if (!flowState) {
return <ErrorPage title="error.invalid_session" />;
}

return (
<SecondaryPageLayout title="mfa.verify_mfa_factors" description="mfa.verify_mfa_description">
<SecondaryPageLayout
isBackHidden={shouldHideBack}
title="mfa.verify_mfa_factors"
description="mfa.verify_mfa_description"
>
<MfaFactorList flow={UserMfaFlow.MfaVerification} flowState={flowState} />
</SecondaryPageLayout>
);
Expand Down
Loading
Loading